Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 75 additions & 26 deletions src/app/api/fee-compare/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@
netCostUsdc: number;
positionSizeUsdc: number;
avgFeeRateBps: number;
gainsExclusiveFeesUsdc?: number; // fees on coins not available on the other venue
recentTrades: Array<{
date: string;
pair: string;
Expand All @@ -196,7 +197,8 @@
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;
}>;
};
Expand Down Expand Up @@ -1115,31 +1117,40 @@
const INCREASE_ACTIONS = new Set(["TradePosSizeIncrease"]);
const CLOSE_ACTIONS = new Set(["TradeClosedMarket", "TradeClosedTP", "TradeClosedSL", "TradeClosedLIQ"]);

const byId = new Map<number, { open?: GainsApiTrade; close?: GainsApiTrade }>();
const byId = new Map<number, { open?: GainsApiTrade; close?: GainsApiTrade; lastIncrease?: GainsApiTrade }>();
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,
});
}

Expand Down Expand Up @@ -1212,7 +1223,7 @@
}

// Estimate GMX borrow fees for a set of position slices.
function estimateGmxBorrowFees(

Check warning on line 1226 in src/app/api/fee-compare/route.ts

View workflow job for this annotation

GitHub Actions / check

'estimateGmxBorrowFees' is defined but never used
positions: PositionSlice[],
borrowPerSecPerCoin: Record<string, number>
): number {
Expand Down Expand Up @@ -1342,14 +1353,16 @@
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;
Expand Down Expand Up @@ -1429,6 +1442,7 @@
let hlFillsData: HlFill[] = [];
let hlFundingData: HlFundingEvent[] = [];
let hlOpenPositions: HlOpenPos[] = [];
let hlAvailableCoins = new Set<string>();
let gainsTradesData: GainsApiTrade[] = [];
let gmxWalletData: GmxWalletData | null = null;
let dydxWalletData: DydxWalletData | null = null;
Expand Down Expand Up @@ -1456,6 +1470,21 @@
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(
Expand All @@ -1480,8 +1509,11 @@

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<string, Array<{ time: number; rate: number }>> = 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") &&
Expand All @@ -1494,7 +1526,13 @@
.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<string, Array<{ time: number; rate: number }>>()),
fetchGainsTrades(wallet, extendedCutoffMs).catch(() => gainsTradesData),
]);
hlFundingHistoryByCoins = fundingHistory;
gainsPositionData = extendedTrades;
}

function buildVenueResult(slug: string, rate: number, note: string, rateIsLive: boolean): VenueResult {
Expand All @@ -1520,13 +1558,15 @@
}));
} else if (fetchEvmWallet && slug === "gains") {
const CLOSE_ACTIONS = new Set(["TradeClosedMarket", "TradeClosedTP", "TradeClosedSL", "TradeClosedLIQ"]);
const usdcTrades = gainsTradesData.filter((t) => t.collateralIndex === 3);

Check warning on line 1561 in src/app/api/fee-compare/route.ts

View workflow job for this annotation

GitHub Actions / check

'CLOSE_ACTIONS' is assigned a value but never used
const otherSlug = slug === venueA ? venueB : venueA;
const otherRate = slug === venueA ? rateB : rateA;
let feesUsdc = 0;
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) {
Expand All @@ -1542,11 +1582,19 @@
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 });
}
}

Expand All @@ -1572,6 +1620,7 @@
netCostUsdc,
positionSizeUsdc: notionalUsd,
avgFeeRateBps: notionalUsd > 0 ? (netCostUsdc / notionalUsd) * 10000 : 0,
gainsExclusiveFeesUsdc: checkHlComparable ? gainsExclusiveFeesUsdc : undefined,
recentTrades,
} satisfies GainsWalletData;
} else if (fetchEvmWallet && slug === "gmx-v2" && gmxWalletData) {
Expand Down Expand Up @@ -1650,7 +1699,7 @@
}
}
} 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"];
Expand All @@ -1663,9 +1712,9 @@
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) {
Expand Down Expand Up @@ -1790,7 +1839,7 @@
}
}
} 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"];
Expand All @@ -1803,9 +1852,9 @@
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) {
Expand Down
18 changes: 16 additions & 2 deletions src/components/fee-compare-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ type GainsWalletData = {
netCostUsdc: number;
positionSizeUsdc: number;
avgFeeRateBps: number;
gainsExclusiveFeesUsdc?: number;
recentTrades: Array<{
date: string;
pair: string;
Expand All @@ -90,6 +91,7 @@ type GainsWalletData = {
fundingFee: number;
borrowingFee: number;
equivFee?: number;
hlComparable?: boolean;
pnl_net: number;
}>;
};
Expand Down Expand Up @@ -780,6 +782,11 @@ function WalletSide({
{fmtUsd(gW.netCostUsdc)}
</p>
</div>
{(gW.gainsExclusiveFeesUsdc ?? 0) > 0.01 && (
<p className="text-[10px] text-ink-faint/50 leading-snug pt-0.5">
Incl. {fmtUsd(gW.gainsExclusiveFeesUsdc!)} on pairs not listed on {otherVenue.name} — excluded from comparison.
</p>
)}
</div>
</div>
);
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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 (
<tr key={i} className="border-b border-ink/5 last:border-0 hover:bg-ink/2 transition-colors">
<tr key={i} className={`border-b border-ink/5 last:border-0 transition-colors ${isExclusive ? "opacity-40" : "hover:bg-ink/2"}`}>
<td className="px-5 py-3 font-mono text-xs text-ink-faint whitespace-nowrap">
{new Date(t.date).toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}
</td>
<td className="px-3 py-3 font-mono text-xs font-bold text-ink">{t.pair.replace("/USD", "")}</td>
<td className="px-3 py-3 font-mono text-xs font-bold text-ink">
{t.pair.replace("/USD", "")}
{isExclusive && (
<span className="ml-1.5 text-[9px] font-normal text-ink-faint/60 uppercase tracking-wide">Gains only</span>
)}
</td>
<td className="px-3 py-3 hidden sm:table-cell">
<span className={`inline-flex rounded-md px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.06em] ${
t.action.includes("Opened") ? "bg-emerald-500/12 text-emerald-500"
Expand Down
Loading