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
28 changes: 26 additions & 2 deletions src/app/perp/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { PERP_VENUE_META } from "@/lib/perp-venue-context";
import { fetchPerpVenueKpis } from "@/lib/perp-venue-data";
import { fetchPerpCohort } from "@/lib/perp-stats";
import { fetchPerpVenueExternalStats } from "@/lib/perp-venue-external";
import { findVenue, getPerpVolumeHistory } from "@/lib/perp-volume-history";
import { loadBenchFromBlob } from "@/lib/bench-blob";
import { fmtUnit } from "@/lib/format";
import { PerpVenueKpiStrip } from "@/components/perp-venue-kpi-strip";
Expand Down Expand Up @@ -138,13 +139,36 @@ export default async function PerpVenuePage({

const { seed, meta, cohortSlug } = v;

const [, kpis, ext, ...benchBlobs] = await Promise.all([
const [, kpis, extRaw, volumeHistory, ...benchBlobs] = await Promise.all([
fetchPerpCohort(),
fetchPerpVenueKpis(cohortSlug),
fetchPerpVenueExternalStats(cohortSlug),
getPerpVolumeHistory(),
...LIVE_PERP_BENCH_SLUGS.map((s) => loadBenchFromBlob(s)),
]);

// The perp-volume-history harness keeps a perps-only daily series per
// venue on closed UTC days (traded notional, same perimeter as
// DeFiLlama's derivatives page). When the venue is in that cohort it
// becomes the volume chart: the venues' own counters mix perimeters
// (Gains' leveraged_volume adds resizes at full notional plus an
// "other" bucket, up to 13x the traded figure on 2026-09-14). Cohort
// key for GMX is gmx-v2, history slug is gmx. Venues not in the history
// fall back to the venue's own API.
const historyVenue = volumeHistory
? findVenue(volumeHistory, cohortSlug === "gmx-v2" ? "gmx" : cohortSlug)
: null;
const useHistory = !!historyVenue && historyVenue.days.length >= 3;
const ext = useHistory
? {
...extRaw,
dailyVolumeChart: historyVenue!.days
.slice(-30)
.map((p) => ({ date: p.day, valueUsd: p.usd })),
}
: extRaw;
const volumeChartTitle = useHistory ? "Daily perp volume (UTC days)" : "Daily Volume";

const rankings = buildRankings(benchBlobs as (Benchmark | null)[], cohortSlug);

const lastMeasured = rankings.reduce<string | null>((acc, a) => {
Expand Down Expand Up @@ -311,7 +335,7 @@ export default async function PerpVenuePage({
<div className="card-soft rounded-lg p-4 border border-ink/15">
<PerpBarChart
bars={ext.dailyVolumeChart!}
title="Daily Volume"
title={volumeChartTitle}
color="teal"
/>
</div>
Expand Down
82 changes: 82 additions & 0 deletions src/lib/perp-volume-history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, test } from "bun:test";
import {
headToHead,
shiftDay,
windowSum,
type PerpVolumeVenue,
} from "./perp-volume-history";

function venue(slug: string, series: Record<string, number>): PerpVolumeVenue {
return {
slug,
name: slug,
source: "test",
days: Object.entries(series)
.map(([day, usd]) => ({ day, usd }))
.sort((a, b) => (a.day < b.day ? -1 : 1)),
};
}

describe("shiftDay", () => {
test("crosses month boundaries in UTC", () => {
expect(shiftDay("2026-09-01", -1)).toBe("2026-08-31");
expect(shiftDay("2026-08-31", 1)).toBe("2026-09-01");
});
});

describe("windowSum", () => {
test("sums a complete window and refuses a short one", () => {
const v = venue("a", { "2026-09-10": 1, "2026-09-11": 2, "2026-09-12": 3 });
expect(windowSum(v, "2026-09-12", 3)).toBe(6);
expect(windowSum(v, "2026-09-12", 4)).toBeNull();
expect(windowSum(v, "2026-09-13", 1)).toBeNull();
});
});

describe("headToHead", () => {
test("anchors on the last day both venues have closed", () => {
const a = venue("a", { "2026-09-10": 10, "2026-09-11": 30, "2026-09-12": 5, "2026-09-13": 9 });
// b lags one day: 09-13 not closed yet.
const b = venue("b", { "2026-09-10": 20, "2026-09-11": 20, "2026-09-12": 20 });
const h = headToHead(a, b, 4);
expect(h).not.toBeNull();
expect(h!.asOf).toBe("2026-09-12");
expect(h!.days.map((d) => d.lead)).toEqual([null, "b", "a", "b"]);
expect(h!.window["1d"]).toEqual({ a: 5, b: 20 });
expect(h!.window["7d"]).toEqual({ a: null, b: null });
expect(h!.streak).toEqual({ side: "b", days: 1 });
expect(h!.streakSince).toBe("2026-09-12");
});

test("counts a multi-day streak back to its first day", () => {
const a = venue("a", { "2026-09-10": 1, "2026-09-11": 9, "2026-09-12": 9, "2026-09-13": 9 });
const b = venue("b", { "2026-09-10": 5, "2026-09-11": 5, "2026-09-12": 5, "2026-09-13": 5 });
const h = headToHead(a, b, 4)!;
expect(h.streak).toEqual({ side: "a", days: 3 });
expect(h.streakSince).toBe("2026-09-11");
expect(h.daysLed30).toEqual({ a: 3, b: 1 });
});

test("returns null when a side has no history", () => {
expect(headToHead(venue("a", {}), venue("b", { "2026-09-10": 1 }))).toBeNull();
});
});

import { weeklyRatio } from "./perp-volume-history";

describe("weeklyRatio", () => {
test("tiles complete seven-day windows back from asOf and takes the median", () => {
const series = (v: (d: number) => number) =>
Object.fromEntries(
Array.from({ length: 21 }, (_, i) => [shiftDay("2026-09-13", -i), v(i)]),
);
const a = venue("a", series(() => 10));
// b halves every week going back: latest week 20/day, then 10, then 5.
const b = venue("b", series((i) => (i < 7 ? 20 : i < 14 ? 10 : 5)));
const r = weeklyRatio(a, b, "2026-09-13", 4);
expect(r.points.map((p) => p.end)).toEqual(["2026-08-23", "2026-08-30", "2026-09-06", "2026-09-13"]);
expect(r.points.map((p) => p.ratioPct)).toEqual([null, 200, 100, 50]);
expect(r.points.at(-1)).toMatchObject({ start: "2026-09-07", a: 70, b: 140 });
expect(r.medianPct).toBe(100);
});
});
Loading
Loading