diff --git a/src/app/api/fee-compare/route.ts b/src/app/api/fee-compare/route.ts index d6d53301..7841e74a 100644 --- a/src/app/api/fee-compare/route.ts +++ b/src/app/api/fee-compare/route.ts @@ -188,6 +188,7 @@ type GainsWalletData = { netCostUsdc: number; positionSizeUsdc: number; avgFeeRateBps: number; + gainsExclusiveFeesUsdc?: number; // fees on coins not available on the other venue recentTrades: Array<{ date: string; pair: string; @@ -196,7 +197,8 @@ type GainsWalletData = { tradingFee: number; fundingFee: number; borrowingFee: number; - equivFee?: number; // equivalent fee on the other venue + equivFee?: number; + hlComparable?: boolean; // false = coin not listed on HL pnl_net: number; }>; }; @@ -1115,31 +1117,40 @@ function reconstructGainsPositions(trades: GainsApiTrade[], cutoffMs: number): P const INCREASE_ACTIONS = new Set(["TradePosSizeIncrease"]); const CLOSE_ACTIONS = new Set(["TradeClosedMarket", "TradeClosedTP", "TradeClosedSL", "TradeClosedLIQ"]); - const byId = new Map(); + const byId = new Map(); for (const t of trades) { if (!byId.has(t.id)) byId.set(t.id, {}); const e = byId.get(t.id)!; if (OPEN_ACTIONS.has(t.action)) e.open = t; - else if (INCREASE_ACTIONS.has(t.action) && e.open) { - e.open = { ...e.open, size: t.size, leverage: t.leverage }; + else if (INCREASE_ACTIONS.has(t.action)) { + if (e.open) e.open = { ...e.open, size: t.size, leverage: t.leverage }; + // Track increases for positions opened before the window (no open event in data) + else if (!e.lastIncrease || new Date(t.date).getTime() < new Date(e.lastIncrease.date).getTime()) { + e.lastIncrease = t; + } } else if (CLOSE_ACTIONS.has(t.action)) e.close = t; } const now = Date.now(); const slices: PositionSlice[] = []; - for (const { open, close } of byId.values()) { - if (!open) continue; - const openMs = new Date(open.date).getTime(); - if (openMs < cutoffMs) continue; - // Still-open positions use now as close time (same as reconstructHlPositions) + for (const { open, close, lastIncrease } of byId.values()) { + // Use open event if available; fall back to earliest increase in the window + // for positions opened before the fetch window (open event not in data). + const anchor = open ?? lastIncrease; + if (!anchor) continue; + const rawOpenMs = new Date(anchor.date).getTime(); const closeMs = close ? new Date(close.date).getTime() : now; + // Skip positions that closed before the analysis window + if (closeMs < cutoffMs) continue; + // Cap openMs to the window start so long-running positions aren't missed + const openMs = Math.max(rawOpenMs, cutoffMs); slices.push({ - coin: open.pair.split("/")[0], - notionalUsd: open.size * open.leverage, + coin: anchor.pair.split("/")[0], + notionalUsd: anchor.size * anchor.leverage, openMs, closeMs, - isLong: open.buy !== false, + isLong: anchor.buy !== false, }); } @@ -1342,14 +1353,16 @@ function toChecksumAddress(address: string): string { return "0x" + result; } -function walletStats(slug: string, w: AnyWallet): { notional: number; fees: number } | null { +function walletStats(slug: string, w: AnyWallet, otherSlug?: string): { notional: number; fees: number } | null { if (slug === "hyperliquid") { const x = w as HlWalletData; return x.fills > 0 ? { notional: x.notionalUsd, fees: x.netCostUsd } : null; } if (slug === "gains") { const x = w as GainsWalletData; - return x.events > 0 ? { notional: x.positionSizeUsdc, fees: x.netCostUsdc } : null; + // When comparing against HL, exclude fees on coins not available on HL + const exclusiveFees = otherSlug === "hyperliquid" ? (x.gainsExclusiveFeesUsdc ?? 0) : 0; + return x.events > 0 ? { notional: x.positionSizeUsdc, fees: x.netCostUsdc - exclusiveFees } : null; } if (slug === "gmx-v2") { const x = w as GmxWalletData; @@ -1429,6 +1442,7 @@ export async function GET(req: Request) { let hlFillsData: HlFill[] = []; let hlFundingData: HlFundingEvent[] = []; let hlOpenPositions: HlOpenPos[] = []; + let hlAvailableCoins = new Set(); let gainsTradesData: GainsApiTrade[] = []; let gmxWalletData: GmxWalletData | null = null; let dydxWalletData: DydxWalletData | null = null; @@ -1456,6 +1470,21 @@ export async function GET(req: Request) { fetches.push( fetchGainsTrades(wallet, cutoffMs).then((d) => { gainsTradesData = d; }).catch(() => {}) ); + if (venueA === "hyperliquid" || venueB === "hyperliquid") { + fetches.push( + fetch(HL_API, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "meta" }), + signal: AbortSignal.timeout(5000), + }) + .then((r) => r.json()) + .then((d: { universe: Array<{ name: string }> }) => { + hlAvailableCoins = new Set(d.universe.map((c) => c.name)); + }) + .catch(() => {}) + ); + } } if (venueA === "gmx-v2" || venueB === "gmx-v2") { fetches.push( @@ -1480,8 +1509,11 @@ export async function GET(req: Request) { await Promise.all(fetches); - // Phase 2: fetch HL funding history for Gains positions (Gains→HL carry projection) + // Phase 2: fetch HL funding history + extended Gains history for position reconstruction let hlFundingHistoryByCoins: Map> = new Map(); + // Extended Gains history (1 year) used only for HL funding projection reconstruction — + // the fee accounting (taker/borrow/funding fees) still uses gainsTradesData (cutoffMs window). + let gainsPositionData: GainsApiTrade[] = gainsTradesData; if ( fetchEvmWallet && (venueA === "gains" || venueB === "gains") && @@ -1494,7 +1526,13 @@ export async function GET(req: Request) { .map((t) => t.pair.split("/")[0]) ); const coinsToFetch = [...gainsCoinSet].slice(0, 6); - hlFundingHistoryByCoins = await fetchHlFundingHistory(coinsToFetch, cutoffMs).catch(() => new Map()); + const extendedCutoffMs = cutoffMs - 365 * 24 * 60 * 60 * 1000; + const [fundingHistory, extendedTrades] = await Promise.all([ + fetchHlFundingHistory(coinsToFetch, cutoffMs).catch(() => new Map>()), + fetchGainsTrades(wallet, extendedCutoffMs).catch(() => gainsTradesData), + ]); + hlFundingHistoryByCoins = fundingHistory; + gainsPositionData = extendedTrades; } function buildVenueResult(slug: string, rate: number, note: string, rateIsLive: boolean): VenueResult { @@ -1527,6 +1565,8 @@ export async function GET(req: Request) { let fundingFeesUsdc = 0; let borrowingFeesUsdc = 0; let notionalUsd = 0; + let gainsExclusiveFeesUsdc = 0; + const checkHlComparable = otherSlug === "hyperliquid" && hlAvailableCoins.size > 0; const recentTrades: GainsWalletData["recentTrades"] = []; for (const t of usdcTrades) { @@ -1542,11 +1582,19 @@ export async function GET(req: Request) { borrowingFeesUsdc += borrowingFee; const tradeNotional = t.size * t.leverage; notionalUsd += tradeNotional; + const coin = t.pair.split("/")[0]; + const hlComparable = checkHlComparable ? hlAvailableCoins.has(coin) : undefined; + if (hlComparable === false) { + gainsExclusiveFeesUsdc += takerFee + fundingFee + borrowingFee; + } if (recentTrades.length < 50) { - const equivFee = otherSlug === "hyperliquid" - ? tradeNotional * (gainsData.perSide[t.pair.split("/")[0]] ?? otherRate) - : tradeNotional * otherRate; - recentTrades.push({ date: t.date, pair: t.pair, action: t.action, notional: tradeNotional, tradingFee: takerFee, fundingFee, borrowingFee, equivFee, pnl_net: t.pnl_net }); + // Don't show equivFee for Gains-exclusive coins — the coin doesn't exist on HL + const equivFee = hlComparable === false + ? undefined + : otherSlug === "hyperliquid" + ? tradeNotional * (gainsData.perSide[coin] ?? otherRate) + : tradeNotional * otherRate; + recentTrades.push({ date: t.date, pair: t.pair, action: t.action, notional: tradeNotional, tradingFee: takerFee, fundingFee, borrowingFee, equivFee, hlComparable, pnl_net: t.pnl_net }); } } @@ -1572,6 +1620,7 @@ export async function GET(req: Request) { netCostUsdc, positionSizeUsdc: notionalUsd, avgFeeRateBps: notionalUsd > 0 ? (netCostUsdc / notionalUsd) * 10000 : 0, + gainsExclusiveFeesUsdc: checkHlComparable ? gainsExclusiveFeesUsdc : undefined, recentTrades, } satisfies GainsWalletData; } else if (fetchEvmWallet && slug === "gmx-v2" && gmxWalletData) { @@ -1650,7 +1699,7 @@ export async function GET(req: Request) { } } } else { - const stats = walletStats(venueA, venueAResult.wallet); + const stats = walletStats(venueA, venueAResult.wallet, venueB); if (stats) { let equivFees = stats.notional * rateB; let projectedCarry: SimResult["projectedCarry"]; @@ -1663,9 +1712,9 @@ export async function GET(req: Request) { hlOpenPositions, cutoffMs ); - } else if (venueA === "gains" && gainsTradesData.length > 0) { + } else if (venueA === "gains" && gainsPositionData.length > 0) { positions = reconstructGainsPositions( - gainsTradesData.filter((t) => t.collateralIndex === 3), + gainsPositionData.filter((t) => t.collateralIndex === 3), cutoffMs ); } else if (venueA === "gmx-v2" && gmxWalletData) { @@ -1790,7 +1839,7 @@ export async function GET(req: Request) { } } } else { - const stats = walletStats(venueB, venueBResult.wallet); + const stats = walletStats(venueB, venueBResult.wallet, venueA); if (stats) { let equivFees = stats.notional * rateA; let projectedCarry: SimResult["projectedCarry"]; @@ -1803,9 +1852,9 @@ export async function GET(req: Request) { hlOpenPositions, cutoffMs ); - } else if (venueB === "gains" && gainsTradesData.length > 0) { + } else if (venueB === "gains" && gainsPositionData.length > 0) { positions = reconstructGainsPositions( - gainsTradesData.filter((t) => t.collateralIndex === 3), + gainsPositionData.filter((t) => t.collateralIndex === 3), cutoffMs ); } else if (venueB === "gmx-v2" && gmxWalletData) { diff --git a/src/components/fee-compare-client.tsx b/src/components/fee-compare-client.tsx index be240467..812a77ca 100644 --- a/src/components/fee-compare-client.tsx +++ b/src/components/fee-compare-client.tsx @@ -81,6 +81,7 @@ type GainsWalletData = { netCostUsdc: number; positionSizeUsdc: number; avgFeeRateBps: number; + gainsExclusiveFeesUsdc?: number; recentTrades: Array<{ date: string; pair: string; @@ -90,6 +91,7 @@ type GainsWalletData = { fundingFee: number; borrowingFee: number; equivFee?: number; + hlComparable?: boolean; pnl_net: number; }>; }; @@ -780,6 +782,11 @@ function WalletSide({ {fmtUsd(gW.netCostUsdc)}

+ {(gW.gainsExclusiveFeesUsdc ?? 0) > 0.01 && ( +

+ Incl. {fmtUsd(gW.gainsExclusiveFeesUsdc!)} on pairs not listed on {otherVenue.name} — excluded from comparison. +

+ )} ); @@ -1146,6 +1153,7 @@ function GainsTradeTable({ const PREVIEW = 10; const rows = showAll ? trades : trades.slice(0, PREVIEW); const hasEquiv = !!otherVenueName && trades.some((t) => t.equivFee !== undefined); + const hasComparability = trades.some((t) => t.hlComparable !== undefined); if (trades.length === 0) return null; @@ -1188,12 +1196,18 @@ function GainsTradeTable({ {rows.map((t, i) => { const netCost = t.tradingFee + t.fundingFee + t.borrowingFee; const diff = hasEquiv && t.equivFee !== undefined ? t.equivFee - netCost : undefined; + const isExclusive = hasComparability && t.hlComparable === false; return ( - + {new Date(t.date).toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })} - {t.pair.replace("/USD", "")} + + {t.pair.replace("/USD", "")} + {isExclusive && ( + Gains only + )} +