diff --git a/src/app/api/fee-compare/route.ts b/src/app/api/fee-compare/route.ts index 26b80aaf..b1c1c7e1 100644 --- a/src/app/api/fee-compare/route.ts +++ b/src/app/api/fee-compare/route.ts @@ -135,13 +135,27 @@ type GainsWalletData = { type GmxWalletData = { trades: number; feesUsdc: number; + borrowingFeesUsdc: number; + fundingFeesUsdc: number; + netCostUsdc: number; notionalUsd: number; avgFeeRateBps: number; + recentTrades: Array<{ + timestamp: number; + sizeDeltaUsd: number; + isLong: boolean; + tradingFee: number; + borrowingFee: number; + fundingFee: number; + pnlUsd: number; + }>; }; type DydxWalletData = { fills: number; feesUsdc: number; + fundingUsd: number; + netCostUsdc: number; notionalUsd: number; avgFeeRateBps: number; }; @@ -352,86 +366,170 @@ async function fetchHlFunding(wallet: string, startMs: number): Promise { - const query = ` - query GmxTrades($account: String!) { - tradeActions( - where: { - account_eq: $account - positionFeeAmount_isNull: false - sizeDeltaUsd_gt: "0" - orderType_in: [2, 3, 4] - initialCollateralTokenAddress_in: [ - "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", - "0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8" - ] +async function fetchGmxTrades(wallet: string, cutoffMs: number): Promise { + const fromTimestamp = Math.floor(cutoffMs / 1000); + const allTrades: Array<{ + timestamp: number; sizeDeltaUsd: string; isLong: boolean; + positionFeeAmount: string; borrowingFeeAmount: string | null; + fundingFeeAmount: string | null; pnlUsd: string | null; + }> = []; + let cursor: string | null = null; + let pages = 0; + + const USDC_ADDRS = [ + "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8", + ]; + + do { + const query = ` + query($account: String!, $from: Int!, $after: String) { + tradeActionsConnection( + where: { + account_eq: $account + timestamp_gte: $from + eventName_in: ["OrderExecuted"] + initialCollateralTokenAddress_in: ${JSON.stringify(USDC_ADDRS)} + sizeDeltaUsd_gt: "0" + } + first: 200 + after: $after + orderBy: timestamp_DESC + ) { + edges { + node { + timestamp + sizeDeltaUsd + isLong + positionFeeAmount + borrowingFeeAmount + fundingFeeAmount + pnlUsd + } + } + pageInfo { hasNextPage endCursor } } - orderBy: timestamp_DESC - limit: 200 - ) { - sizeDeltaUsd - positionFeeAmount - orderType } - } - `; - const res = await fetch(GMX_SUBSQUID, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ query, variables: { account: toChecksumAddress(wallet) } }), - signal: AbortSignal.timeout(15000), - }); - if (!res.ok) throw new Error(`GMX Subsquid ${res.status}`); - const body = (await res.json()) as { - data?: { - tradeActions: Array<{ - sizeDeltaUsd: string; - positionFeeAmount: string; - orderType: number; - }>; + `; + const res = await fetch(GMX_SUBSQUID, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query, variables: { account: toChecksumAddress(wallet), from: fromTimestamp, after: cursor } }), + signal: AbortSignal.timeout(15000), + }); + if (!res.ok) break; + const body = (await res.json()) as { + data?: { + tradeActionsConnection?: { + edges: Array<{ node: typeof allTrades[0] }>; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + }; + }; }; - }; - const trades = body.data?.tradeActions ?? []; + const conn = body.data?.tradeActionsConnection; + if (!conn) break; + allTrades.push(...conn.edges.map((e) => e.node)); + cursor = conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null; + pages++; + } while (cursor && pages < 10); let feesUsdc = 0; + let borrowingFeesUsdc = 0; + let fundingFeesUsdc = 0; let notionalUsd = 0; - for (const t of trades) { - feesUsdc += Number(BigInt(t.positionFeeAmount)) / 1e6; - // sizeDeltaUsd has 30 decimals; divide by 1e24 to get 6-decimal USD, then /1e6 - notionalUsd += - Number(BigInt(t.sizeDeltaUsd) / BigInt("1000000000000000000000000")) / 1e6; + const recentTrades: GmxWalletData["recentTrades"] = []; + + for (const t of allTrades) { + const tradingFee = Number(BigInt(t.positionFeeAmount ?? "0")) / 1e6; + const borrowingFee = Number(BigInt(t.borrowingFeeAmount ?? "0")) / 1e6; + // fundingFeeAmount can be negative (received); keep sign + const fundingFeeRaw = t.fundingFeeAmount ? BigInt(t.fundingFeeAmount) : BigInt(0); + const fundingFee = Number(fundingFeeRaw) / 1e6; + const notional = Number(BigInt(t.sizeDeltaUsd) / BigInt("1000000000000000000000000")) / 1e6; + const pnlUsd = t.pnlUsd ? Number(BigInt(t.pnlUsd) / BigInt("1000000000000000000000000")) / 1e6 : 0; + + feesUsdc += tradingFee; + borrowingFeesUsdc += borrowingFee; + fundingFeesUsdc += fundingFee; + notionalUsd += notional; + + if (recentTrades.length < 50) { + recentTrades.push({ timestamp: t.timestamp, sizeDeltaUsd: notional, isLong: t.isLong, tradingFee, borrowingFee, fundingFee, pnlUsd }); + } } + const netCostUsdc = feesUsdc + borrowingFeesUsdc + fundingFeesUsdc; + return { - trades: trades.length, + trades: allTrades.length, feesUsdc, + borrowingFeesUsdc, + fundingFeesUsdc, + netCostUsdc, notionalUsd, avgFeeRateBps: notionalUsd > 0 ? (feesUsdc / notionalUsd) * 10000 : 0, + recentTrades, }; } -async function fetchDydxFills(dydxAddress: string): Promise { - const res = await fetch( - `${DYDX_INDEXER}/v4/fills?address=${encodeURIComponent(dydxAddress)}&subaccountNumber=0&limit=100`, - { signal: AbortSignal.timeout(10000) } - ); - if (!res.ok) throw new Error(`dYdX indexer ${res.status}`); - const body = (await res.json()) as { - fills?: Array<{ fee: string; price: string; size: string; liquidity?: string }>; - }; - // Keep only taker fills (positive fee) - const fills = (body.fills ?? []).filter((f) => parseFloat(f.fee) > 0); +async function fetchDydxFills(dydxAddress: string, cutoffMs: number): Promise { + const allFills: Array<{ fee: string; price: string; size: string; liquidity?: string; createdAt: string }> = []; + let page = 1; + const limit = 100; + + outer: while (page <= 20) { + const res = await fetch( + `${DYDX_INDEXER}/v4/fills?address=${encodeURIComponent(dydxAddress)}&subaccountNumber=0&limit=${limit}&page=${page}`, + { signal: AbortSignal.timeout(10000) } + ); + if (!res.ok) break; + const body = (await res.json()) as { + fills?: Array<{ fee: string; price: string; size: string; liquidity?: string; createdAt: string }>; + totalResults?: number; + }; + const pageFills = body.fills ?? []; + if (pageFills.length === 0) break; + + for (const f of pageFills) { + if (new Date(f.createdAt).getTime() < cutoffMs) break outer; + allFills.push(f); + } + + if (pageFills.length < limit) break; + page++; + } + + // fetch funding from perpetualPositions (netFunding = settled + unsettled) + let fundingUsd = 0; + try { + const pfRes = await fetch( + `${DYDX_INDEXER}/v4/perpetualPositions?address=${encodeURIComponent(dydxAddress)}&subaccountNumber=0&limit=100`, + { signal: AbortSignal.timeout(8000) } + ); + if (pfRes.ok) { + const pfBody = (await pfRes.json()) as { + positions?: Array<{ netFunding?: string; closedAt?: string | null }>; + }; + for (const p of pfBody.positions ?? []) { + fundingUsd += parseFloat(p.netFunding ?? "0"); + } + } + } catch { /* funding is optional */ } + const takers = allFills.filter((f) => (f.liquidity ?? "").toUpperCase() !== "MAKER"); let feesUsdc = 0; let notionalUsd = 0; - for (const f of fills) { + for (const f of takers) { feesUsdc += parseFloat(f.fee); notionalUsd += parseFloat(f.price) * parseFloat(f.size); } + const netCostUsdc = feesUsdc - fundingUsd; return { - fills: fills.length, + fills: allFills.length, feesUsdc, + fundingUsd, + netCostUsdc, notionalUsd, avgFeeRateBps: notionalUsd > 0 ? (feesUsdc / notionalUsd) * 10000 : 0, }; @@ -535,11 +633,11 @@ function walletStats(slug: string, w: AnyWallet): { notional: number; fees: numb } if (slug === "gmx-v2") { const x = w as GmxWalletData; - return x.trades > 0 ? { notional: x.notionalUsd, fees: x.feesUsdc } : null; + return x.trades > 0 ? { notional: x.notionalUsd, fees: x.netCostUsdc } : null; } if (slug === "dydx") { const x = w as DydxWalletData; - return x.fills > 0 ? { notional: x.notionalUsd, fees: x.feesUsdc } : null; + return x.fills > 0 ? { notional: x.notionalUsd, fees: x.netCostUsdc } : null; } return null; } @@ -620,7 +718,7 @@ export async function GET(req: Request) { } if (venueA === "gmx-v2" || venueB === "gmx-v2") { fetches.push( - fetchGmxTrades(wallet) + fetchGmxTrades(wallet, cutoffMs) .then((d) => { gmxWalletData = d; }) @@ -631,7 +729,7 @@ export async function GET(req: Request) { if (fetchDydxWallet) { fetches.push( - fetchDydxFills(dydxAddress) + fetchDydxFills(dydxAddress, cutoffMs) .then((d) => { dydxWalletData = d; }) diff --git a/src/components/fee-compare-client.tsx b/src/components/fee-compare-client.tsx index da9b1138..63ba9844 100644 --- a/src/components/fee-compare-client.tsx +++ b/src/components/fee-compare-client.tsx @@ -95,13 +95,27 @@ type GainsWalletData = { type GmxWalletData = { trades: number; feesUsdc: number; + borrowingFeesUsdc: number; + fundingFeesUsdc: number; + netCostUsdc: number; notionalUsd: number; avgFeeRateBps: number; + recentTrades: Array<{ + timestamp: number; + sizeDeltaUsd: number; + isLong: boolean; + tradingFee: number; + borrowingFee: number; + fundingFee: number; + pnlUsd: number; + }>; }; type DydxWalletData = { fills: number; feesUsdc: number; + fundingUsd: number; + netCostUsdc: number; notionalUsd: number; avgFeeRateBps: number; }; @@ -151,7 +165,9 @@ type FeeCompareResult = { function walletFees(slug: string, w: AnyWallet): number { if (slug === "hyperliquid") return (w as HlWalletData).netCostUsd; if (slug === "gains") return (w as GainsWalletData).netCostUsdc; - return (w as GmxWalletData | DydxWalletData).feesUsdc; + if (slug === "gmx-v2") return (w as GmxWalletData).netCostUsdc; + if (slug === "dydx") return (w as DydxWalletData).netCostUsdc; + return 0; } function walletVolume(slug: string, w: AnyWallet): number { @@ -694,7 +710,84 @@ function WalletSide({ ); })()} - {venue.slug !== "hyperliquid" && venue.slug !== "gains" && ( + {venue.slug === "gmx-v2" && (() => { + const gW = w as GmxWalletData; + const hasBorrowing = gW.borrowingFeesUsdc > 0.5; + const hasFunding = Math.abs(gW.fundingFeesUsdc) > 0.5; + return ( +
+
+

Volume

+

{fmtUsd(volume)}

+
+
+
+

Trading fees

+

{fmtUsd(gW.feesUsdc)}

+
+ {hasBorrowing && ( +
+

Borrowing fees

+

−{fmtUsd(gW.borrowingFeesUsdc)}

+
+ )} + {hasFunding && ( +
+

Funding {gW.fundingFeesUsdc > 0 ? "paid" : "received"}

+

0 ? "text-red-400" : "text-emerald-500"}`}> + {gW.fundingFeesUsdc > 0 ? `−${fmtUsd(gW.fundingFeesUsdc)}` : `+${fmtUsd(Math.abs(gW.fundingFeesUsdc))}`} +

+
+ )} + {(hasBorrowing || hasFunding) && ( +
+

Net cost

+

+ {fmtUsd(gW.netCostUsdc)} +

+
+ )} +
+
+ ); + })()} + + {venue.slug === "dydx" && (() => { + const dW = w as DydxWalletData; + const hasFunding = Math.abs(dW.fundingUsd) > 0.5; + return ( +
+
+

Volume

+

{fmtUsd(volume)}

+
+
+
+

Trading fees

+

{fmtUsd(dW.feesUsdc)}

+
+ {hasFunding && ( +
+

Funding {dW.fundingUsd > 0 ? "received" : "paid"}

+

0 ? "text-emerald-500" : "text-red-400"}`}> + {dW.fundingUsd > 0 ? `+${fmtUsd(dW.fundingUsd)}` : `−${fmtUsd(Math.abs(dW.fundingUsd))}`} +

+
+ )} + {hasFunding && ( +
+

Net cost

+

+ {fmtUsd(dW.netCostUsdc)} +

+
+ )} +
+
+ ); + })()} + + {venue.slug !== "hyperliquid" && venue.slug !== "gains" && venue.slug !== "gmx-v2" && venue.slug !== "dydx" && (

Volume

{fmtUsd(volume)}

@@ -1059,6 +1152,83 @@ function GainsTradeTable({ ); } +// ────────────────────────────────────────────────────────────────────── +// GmxTradeTable +// ────────────────────────────────────────────────────────────────────── + +function GmxTradeTable({ + trades, + venueName, +}: { + trades: GmxWalletData["recentTrades"]; + venueName: string; +}) { + const [showAll, setShowAll] = useState(false); + const PREVIEW = 10; + const rows = showAll ? trades : trades.slice(0, PREVIEW); + + if (trades.length === 0) return null; + + return ( +
+
+
+ +

{venueName} trade history

+
+

{trades.length} trades

+
+
+ + + + + + + + + + + + {rows.map((t, i) => ( + + + + + + + + ))} + +
DateDirectionNotionalFeePnL
+ {new Date(t.timestamp * 1000).toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })} + + + {t.isLong ? "Long" : "Short"} + + {fmtUsd(t.sizeDeltaUsd)}{fmtUsd(t.tradingFee)} + {t.pnlUsd > 0.01 ? ( + +{fmtUsd(t.pnlUsd)} + ) : t.pnlUsd < -0.01 ? ( + {fmtUsd(t.pnlUsd)} + ) : ( + $0 + )} +
+
+ {trades.length > PREVIEW && ( + + )} +
+ ); +} + // ────────────────────────────────────────────────────────────────────── // Footnote // ────────────────────────────────────────────────────────────────────── @@ -1082,13 +1252,13 @@ function Footnote({ venueA, venueB }: { venueA: VenueResult; venueB: VenueResult )} {hasGmx && ( <> - GMX v2: fills and rate from Subsquid indexer ( - positionFeeAmount / 1e6). Rate = live avg of recent 50 trades.{" "} + GMX v2: Subsquid indexer with date filter and cursor pagination. Includes trading, borrowing, and funding fees.{" "} )} {hasDydx && ( <> - dYdX v4: public indexer fills. Rate = 5 bps tier-0 (protocol-governed). Address must be{" "} + dYdX v4: public indexer with full pagination and date filter. Funding from{" "} + perpetualPositions.netFunding. Address must be{" "} dydx1... Cosmos format.{" "} )} @@ -1118,6 +1288,8 @@ function Results({ result }: { result: FeeCompareResult }) { : null; const gainsVenueA = venueA.slug === "gains" && venueA.wallet ? (venueA.wallet as GainsWalletData) : null; const gainsVenueB = venueB.slug === "gains" && venueB.wallet ? (venueB.wallet as GainsWalletData) : null; + const gmxVenueA = venueA.slug === "gmx-v2" && venueA.wallet ? (venueA.wallet as GmxWalletData) : null; + const gmxVenueB = venueB.slug === "gmx-v2" && venueB.wallet ? (venueB.wallet as GmxWalletData) : null; return (
@@ -1151,6 +1323,12 @@ function Results({ result }: { result: FeeCompareResult }) { {gainsVenueB && gainsVenueB.recentTrades.length > 0 && ( )} + {gmxVenueA && gmxVenueA.recentTrades.length > 0 && ( + + )} + {gmxVenueB && gmxVenueB.recentTrades.length > 0 && ( + + )}
);