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
+ )}
+ |
|