From 8eda9758216df274741ff0e40970cb1ec8eb4a83 Mon Sep 17 00:00:00 2001 From: Yaugourt <180565593+Yaugourt@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:39:45 -0400 Subject: [PATCH 1/2] docs(revenue): the priority line is HyperCore order priority, not HyperEVM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment-only. The client that feeds the revenue breakdown's `priority` field was documented as a "Daily HyperEVM priority-fees burn chart". It is neither HyperEVM nor the whole of priority fees. Per the protocol docs there are two priority mechanisms, both on HyperCore and both burning HYPE: order priority, charged from undelegated staking balance as up to 8 bps of filled notional (IOC) or resting notional (ALO); and gossip priority, two Dutch auctions on a three-minute cycle for faster market-data reads, charged from spot balance, resetting at 10x the last winning bid with a 0.1 HYPE floor. This endpoint returns the first only. Worth knowing for whoever picks this up: gossip priority is already served by /hip3/priority-fees/gossip/*, but that feed has been frozen since 2026-07-11 — 200 rows all stamped that day, 8 of them ever cleared — so the stream cannot be added to revenue until the ingestion is fixed. HyperEVM priority is a third and unrelated stream. Its sink, the EVM zero address, has been flat for at least a week (282,606.50 HYPE, moving by 0.03 over seven days), so nothing is being lost by leaving it out. No behaviour changes; every number the API returns is unchanged. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/priority-fees Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013zGKQKm432ZKEw1GjVWPZG --- .../analytics/analytics-indexer.client.ts | 19 ++++++++++++++++++- src/types/revenue.types.ts | 16 +++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/clients/hypedexer/rest/analytics/analytics-indexer.client.ts b/src/clients/hypedexer/rest/analytics/analytics-indexer.client.ts index c4da0f9..5956389 100644 --- a/src/clients/hypedexer/rest/analytics/analytics-indexer.client.ts +++ b/src/clients/hypedexer/rest/analytics/analytics-indexer.client.ts @@ -76,7 +76,24 @@ export class HypeDexerAnalyticsIndexerClient extends HypeDexerBaseClient { } /** - * Daily HyperEVM priority-fees burn chart. + * Daily HyperCore **order priority** burn chart — not HyperEVM, and not the + * whole of priority fees. + * + * Hyperliquid runs two priority mechanisms, both on HyperCore and both + * burning HYPE. This endpoint counts only the first: + * - order priority (write): up to 8 bps of notional, charged from + * undelegated staking balance on filled notional (IOC) or resting + * notional (ALO), deducted whether or not the order fills; + * - gossip priority (read): two Dutch auctions on a three-minute cycle for + * faster market-data reads, charged from spot balance, each auction + * resetting at 10x its last winning bid with a 0.1 HYPE floor. + * Served separately by `/hip3/priority-fees/gossip/*`. + * + * HyperEVM priority fees are a third, unrelated stream and are not here + * either. Anything consuming this as "priority fees" is understating them. + * + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/priority-fees + * * Server caps the window around ~42 days regardless of the query. * Returns `{ data: [{ date, fills, fillsWithFee, totalGas, uniqueUsers }] }` * where `totalGas` is the daily HYPE amount burned. diff --git a/src/types/revenue.types.ts b/src/types/revenue.types.ts index fbc43ca..720e38c 100644 --- a/src/types/revenue.types.ts +++ b/src/types/revenue.types.ts @@ -9,9 +9,19 @@ * - hip1: Spot listing auction proceeds (USDC). Sum of |deployGas| from /pastAuctions. * - hip3: Perp DEX auction proceeds (HYPE × USD). Closed auctions × HYPE price. * - hip4: Prediction-market fees. 0 until mainnet launch. - * - priority: HyperEVM priority fees (HYPE × USD). 100% burned on-chain — counts as - * protocol-captured value (EIP-1559-style mechanism). HypeDexer caps history - * to ~42 days, older days fall back to 0. + * - priority: HyperCore ORDER priority fees (HYPE × USD) — not HyperEVM, and not all of + * priority. Up to 8 bps of notional charged from undelegated staking balance + * on filled notional (IOC) or resting notional (ALO), burned whether or not + * the order fills. 100% burned, so it counts as protocol-captured value. + * HypeDexer caps history to ~42 days, older days fall back to 0. + * + * NOT included: gossip priority, the second HyperCore burn — two Dutch + * auctions on a three-minute cycle selling faster market-data reads, charged + * from spot balance, each resetting at 10x its last winning bid with a + * 0.1 HYPE floor. It is served by /hip3/priority-fees/gossip/* whose feed has + * been frozen since 2026-07-11, so it cannot be added until that is fixed. + * Also not included: HyperEVM priority fees, a third and unrelated stream. + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/priority-fees */ export type RevenueWindow = '7d' | '30d' | '90d' | '1y' | 'all'; From ab583a970469bd09a282cec933491255f381390f Mon Sep 17 00:00:00 2001 From: Yaugourt <180565593+Yaugourt@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:40:29 -0400 Subject: [PATCH 2/2] fix(revenue): bucket fees by true UTC days and detect frozen feeds The daily series was built by diffing consecutive points of Hypurrscan's cumulative counter and filing each diff under the newer point's date. That measures the interval between two timestamps, not a calendar day, so a point landing early turns its day into a partial one. Harmless mid-series, fatal at the tail: production was serving $59,286 for 2026-07-26 against a real ~$675K, and $48 for the running day, which reads as a collapse rather than as data we do not have yet. Interpolate the counters onto UTC midnight and diff midnight to midnight instead. Every emitted day is now a true UTC day, the running day is never emitted, and an outage is spread across the days it spans rather than dumped on one: 2024-12-18 was showing a fabricated $24.1M record day for what was actually a sixteen-day gap. Lifetime moves by 0.06%, all of it the running day we now correctly withhold. The same blindness applied to source health. A frozen upstream keeps answering 200 with a stale payload, which bucketing turns into zeros indistinguishable from a quiet day, and priorityStatus only checked that the promise settled. It reported ok through sixteen consecutive days of silent zeros while HypeDexer's priority aggregation sat frozen at 2026-07-11. Both perp/spot and priority are now judged on the newest day they actually populate, and meta.coverage carries those dates so the client can stop a series at its real end. The computation moves to revenue.daily.ts as a pure function so it is testable without a singleton, Redis or upstream clients. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013zGKQKm432ZKEw1GjVWPZG --- src/services/revenue/revenue.daily.ts | 133 ++++++++++++++++ src/services/revenue/revenue.service.ts | 104 +++++-------- src/types/revenue.types.ts | 12 ++ tests/unit/services/revenue.daily.test.ts | 177 ++++++++++++++++++++++ 4 files changed, 359 insertions(+), 67 deletions(-) create mode 100644 src/services/revenue/revenue.daily.ts create mode 100644 tests/unit/services/revenue.daily.test.ts diff --git a/src/services/revenue/revenue.daily.ts b/src/services/revenue/revenue.daily.ts new file mode 100644 index 0000000..fad816b --- /dev/null +++ b/src/services/revenue/revenue.daily.ts @@ -0,0 +1,133 @@ +/** + * Turning Hypurrscan's cumulative fee counter into daily revenue. + * + * Kept apart from RevenueService so it stays a pure function of its input: no + * singleton, no Redis, no upstream clients, and therefore directly testable. + */ +import { FeeData } from '../../types/fees.types'; + +export const MICRO_USD_DIVISOR = 1_000_000; +export const SPOT_DEPLOYER_MULTIPLIER = 2; + +export const SECONDS_PER_DAY = 86_400; + +/** + * Widest gap between two cumulative fee points we will interpolate a UTC + * midnight across. + * + * The upstream series ticks every 24 h (median 24.00 h, p95 24.17 h) but has + * gone dark for as long as sixteen days. Across such a gap the counter still + * advanced, so the fees are real and only their day-by-day shape is unknown. + * Spreading them linearly keeps the lifetime total exact and draws a flat + * plateau that reads as the outage it was; the alternatives both lie, either + * dumping sixteen days of fees onto one record-breaking day or claiming the + * protocol earned nothing. Past a month the flat-rate assumption stops meaning + * anything, so that is where we stop and leave the days out. + */ +export const MAX_INTERPOLATION_GAP_SECONDS = 30 * 24 * 3600; + +/** Daily perp/spot buckets plus the newest UTC day they actually cover. */ +export interface PerpSpotSeries { + daily: Map; + coverageThrough: string | null; +} + +/** Format a Date as `YYYY-MM-DD` in UTC. */ +export function utcDateKey(d: Date): string { + return d.toISOString().slice(0, 10); +} + +/** Convert a unix seconds timestamp to its UTC date key. */ +export function secondsToDateKey(seconds: number): string { + return utcDateKey(new Date(seconds * 1000)); +} + +/** + * Compute daily perp & spot from the cumulative `/fees` series. + * + * Hypurrscan publishes one cumulative point per ~day, near 23:50 UTC. Diffing + * consecutive points measures the interval between those two timestamps, not a + * calendar day, so any point that lands early turns the day it is filed under + * into a partial one. That is invisible in the middle of the series but fatal + * at its tail: a snapshot whose newest point is intraday emits a day worth an + * hour of fees, which reads as a collapse rather than as missing data. + * + * So we interpolate the counters onto UTC midnight and diff midnight to + * midnight. Every emitted day is then a true UTC day, the running day is never + * emitted (its closing boundary is in the future), an outage is spread over the + * days it spans instead of landing on one, and boundaries inside a gap wider + * than MAX_INTERPOLATION_GAP_SECONDS are skipped rather than guessed. + * `coverageThrough` is the newest day that survived, which the caller uses to + * stop the breakdown where its dominant source stops. + * + * spot is multiplied by SPOT_DEPLOYER_MULTIPLIER to reflect gross-user fees + * (Hypurrscan stores the protocol share only — the deployer takes the other + * half on HIP-1 spot pairs). + */ +export function computePerpSpotDaily(fees: FeeData[]): PerpSpotSeries { + const points = fees + .filter( + (p) => + Number.isFinite(p.time) && + Number.isFinite(p.total_fees) && + Number.isFinite(p.total_spot_fees) + ) + .sort((a, b) => a.time - b.time); + + const daily = new Map(); + if (points.length < 2) return { daily, coverageThrough: null }; + + const first = points[0].time; + const last = points[points.length - 1].time; + const firstBoundary = Math.ceil(first / SECONDS_PER_DAY) * SECONDS_PER_DAY; + const lastBoundary = Math.floor(last / SECONDS_PER_DAY) * SECONDS_PER_DAY; + if (lastBoundary <= firstBoundary) return { daily, coverageThrough: null }; + + // Read the counters at every UTC midnight the series brackets. `null` marks a + // boundary sitting inside a gap too wide to interpolate. + const atBoundary = new Map(); + let i = 1; + for (let t = firstBoundary; t <= lastBoundary; t += SECONDS_PER_DAY) { + while (i < points.length && points[i].time < t) i++; + const prev = points[i - 1]; + const next = points[i]; + const span = next ? next.time - prev.time : Infinity; + if (!next || span <= 0 || span > MAX_INTERPOLATION_GAP_SECONDS) { + atBoundary.set(t, null); + continue; + } + const fraction = (t - prev.time) / span; + atBoundary.set(t, { + total: prev.total_fees + (next.total_fees - prev.total_fees) * fraction, + spot: prev.total_spot_fees + (next.total_spot_fees - prev.total_spot_fees) * fraction, + }); + } + + let coverageThrough: string | null = null; + for (let t = firstBoundary; t < lastBoundary; t += SECONDS_PER_DAY) { + const open = atBoundary.get(t); + const close = atBoundary.get(t + SECONDS_PER_DAY); + if (!open || !close) continue; + + const totalDelta = (close.total - open.total) / MICRO_USD_DIVISOR; + const spotProtocolDelta = (close.spot - open.spot) / MICRO_USD_DIVISOR; + const date = secondsToDateKey(t); + + daily.set(date, { + perp: Math.max(0, totalDelta - spotProtocolDelta), + spot: Math.max(0, spotProtocolDelta * SPOT_DEPLOYER_MULTIPLIER), + }); + coverageThrough = date; + } + + return { daily, coverageThrough }; +} + +/** Newest key holding a strictly positive value, or null if there is none. */ +export function lastPopulatedDate(daily: Map): string | null { + let latest: string | null = null; + for (const [date, value] of daily) { + if (value > 0 && (latest === null || date > latest)) latest = date; + } + return latest; +} diff --git a/src/services/revenue/revenue.service.ts b/src/services/revenue/revenue.service.ts index 8dccee7..f593f4d 100644 --- a/src/services/revenue/revenue.service.ts +++ b/src/services/revenue/revenue.service.ts @@ -7,7 +7,6 @@ import { RevenueSourceStatus, RevenueWindow, } from '../../types/revenue.types'; -import { FeeData } from '../../types/fees.types'; import { AuctionInfo } from '../../types/auction.types'; import { redisService } from '../../core/redis.service'; import { logDeduplicator } from '../../utils/logDeduplicator'; @@ -16,9 +15,14 @@ import { HypurrscanClient } from '../../clients/hypurrscan/auction.client'; import { HypeDexerHip3Client } from '../../clients/hypedexer/rest/hip3/hip3.client'; import { HypeDexerHip4Client } from '../../clients/hypedexer/rest/hip4/hip4.client'; import { HypeDexerAnalyticsIndexerClient } from '../../clients/hypedexer/rest/analytics/analytics-indexer.client'; +import { + SECONDS_PER_DAY, + SPOT_DEPLOYER_MULTIPLIER, + computePerpSpotDaily, + lastPopulatedDate, + utcDateKey, +} from './revenue.daily'; -const MICRO_USD_DIVISOR = 1_000_000; -const SPOT_DEPLOYER_MULTIPLIER = 2; const HIP3_CACHE_KEY = 'revenue:hip3:auctions'; const HIP3_CACHE_TTL_SECONDS = 30 * 60; @@ -52,16 +56,6 @@ const WINDOW_DAYS: Record, number> = { '1y': 365, }; -/** Format a Date as `YYYY-MM-DD` in UTC. */ -function utcDateKey(d: Date): string { - return d.toISOString().slice(0, 10); -} - -/** Convert a unix seconds timestamp to its UTC date key. */ -function secondsToDateKey(seconds: number): string { - return utcDateKey(new Date(seconds * 1000)); -} - /** Convert a unix milliseconds timestamp to its UTC date key. */ function msToDateKey(ms: number): string { return utcDateKey(new Date(ms)); @@ -138,7 +132,8 @@ export class RevenueService { throw new RevenueError('No fees historical data available', 503, 'REVENUE_NO_DATA'); } - const perpSpotDaily = this.computePerpSpotDaily(fees); + const { daily: perpSpotDaily, coverageThrough: perpSpotThrough } = + computePerpSpotDaily(fees); const hip1Daily = this.bucketHip1ByDay(auctions); const hip3Daily = this.bucketHip3ByDay(hip3Rows, hypeUsd); const hip4Daily = this.bucketHip4ByDay(hip4Rows); @@ -151,7 +146,12 @@ export class RevenueService { ...hip4Daily.keys(), ...priorityDaily.keys(), ]); - const sortedDates = Array.from(allDates).sort(); + // Perp and spot are ~97% of the book. An auction or a HIP-4 bucket landing + // on a day perp/spot does not cover would render as a near-empty bar rather + // than as the absent day it is, so the breakdown ends where perp/spot ends. + const sortedDates = Array.from(allDates) + .filter((date) => perpSpotThrough === null || date <= perpSpotThrough) + .sort(); const days: RevenueDay[] = sortedDates.map((date) => { const ps = perpSpotDaily.get(date) ?? { perp: 0, spot: 0 }; @@ -166,78 +166,39 @@ export class RevenueService { const lifetime = this.computeLifetime(days); const windowed = this.sliceByWindow(days, window); - // perp/spot come from diffing a cumulative series. If that series stops - // advancing, every day past its last point gets a real-looking perp=0, - // spot=0 from the `?? { perp: 0, spot: 0 }` fallback above. Detect that by - // comparing the last date perp/spot actually covers against the newest - // date in the breakdown (auctions/HIP-3/HIP-4 keep advancing), and report - // it as stale so the client can warn instead of trusting a zeroed total. - const perpSpotDates = Array.from(perpSpotDaily.keys()).sort(); - const lastPerpSpotDate = perpSpotDates[perpSpotDates.length - 1] ?? null; - const latestDate = sortedDates[sortedDates.length - 1] ?? null; - const perpSpotStale = - lastPerpSpotDate !== null && latestDate !== null && lastPerpSpotDate < latestDate; + // A daily feed that stops advancing is indistinguishable from a quiet one + // once it is bucketed: both come out as zeros. The only tell is the newest + // day each source actually populates. The running UTC day never counts — + // no source can have closed it yet — so the bar is yesterday. + const expectedThrough = utcDateKey(new Date(Date.now() - SECONDS_PER_DAY * 1000)); + const priorityThrough = lastPopulatedDate(priorityDaily); + const isBehind = (through: string | null): boolean => + through === null || through < expectedThrough; + + const coverage = { perpSpot: perpSpotThrough, priority: priorityThrough }; const meta: RevenueMeta = { spotMultiplier: SPOT_DEPLOYER_MULTIPLIER, hypeUsd, lastUpdate: Date.now(), + coverage, sourceStatus: { perpSpot: feesResult.status !== 'fulfilled' || fees.length === 0 ? 'error' - : perpSpotStale + : isBehind(perpSpotThrough) ? 'stale' : 'ok', hip1: auctionsResult.status === 'fulfilled' ? 'ok' : 'error', hip3: this.hip3Status(hip3Result, hypeUsd), hip4: hip4Result.status === 'fulfilled' ? 'ok' : 'error', - priority: this.priorityStatus(priorityResult, hypeUsd), + priority: this.priorityStatus(priorityResult, hypeUsd, priorityThrough, expectedThrough), }, }; return { window, days: windowed, lifetime, meta }; } - /** - * Compute daily perp & spot from the cumulative `/fees` series. - * - * Hypurrscan returns one cumulative point per ~day. We snap to UTC days by - * keeping the LAST point of each day (closest to end-of-day cumulative - * total), then diff consecutive days. - * - * spot is multiplied by SPOT_DEPLOYER_MULTIPLIER to reflect gross-user fees - * (Hypurrscan stores the protocol share only — the deployer takes the other - * half on HIP-1 spot pairs). - */ - private computePerpSpotDaily(fees: FeeData[]): Map { - const lastPerDay = new Map(); - for (const point of fees) { - const key = secondsToDateKey(point.time); - const prev = lastPerDay.get(key); - if (!prev || point.time > prev.time) { - lastPerDay.set(key, point); - } - } - - const sortedDays = Array.from(lastPerDay.entries()).sort(([a], [b]) => a.localeCompare(b)); - const out = new Map(); - - for (let i = 1; i < sortedDays.length; i++) { - const [date, curr] = sortedDays[i]; - const [, prev] = sortedDays[i - 1]; - - const totalDelta = (curr.total_fees - prev.total_fees) / MICRO_USD_DIVISOR; - const spotProtocolDelta = (curr.total_spot_fees - prev.total_spot_fees) / MICRO_USD_DIVISOR; - const perp = Math.max(0, totalDelta - spotProtocolDelta); - const spot = Math.max(0, spotProtocolDelta * SPOT_DEPLOYER_MULTIPLIER); - - out.set(date, { perp, spot }); - } - - return out; - } - /** * Bucket /pastAuctions by UTC day. `deployGas` is the USDC paid by the * auction winner (stored negative — debit on the deployer side). We take @@ -434,12 +395,21 @@ export class RevenueService { return rows; } + /** + * The upstream chart answers 200 with a frozen payload when its aggregation + * job dies, so settling successfully proves nothing — the priority line read + * `ok` through sixteen consecutive days of silent zeros. Judge it on the + * newest day it populates instead. + */ private priorityStatus( priorityResult: PromiseSettledResult, hypeUsd: number | null, + priorityThrough: string | null, + expectedThrough: string, ): RevenueSourceStatus { if (priorityResult.status !== 'fulfilled') return 'error'; if (hypeUsd === null) return 'stale'; + if (priorityThrough === null || priorityThrough < expectedThrough) return 'stale'; return 'ok'; } } diff --git a/src/types/revenue.types.ts b/src/types/revenue.types.ts index 720e38c..2fba1e0 100644 --- a/src/types/revenue.types.ts +++ b/src/types/revenue.types.ts @@ -49,10 +49,22 @@ export interface RevenueLifetime { export type RevenueSourceStatus = 'ok' | 'stale' | 'error' | 'not_yet_live'; +/** + * Newest UTC day each series actually populates. A frozen upstream feed keeps + * answering 200 with a stale payload, which bucketing turns into zeros that are + * indistinguishable from a quiet day, so the client needs the real end of each + * series to stop drawing it there instead of down to the floor. + */ +export interface RevenueCoverage { + perpSpot: string | null; + priority: string | null; +} + export interface RevenueMeta { spotMultiplier: number; hypeUsd: number | null; lastUpdate: number; + coverage: RevenueCoverage; sourceStatus: { perpSpot: RevenueSourceStatus; hip1: RevenueSourceStatus; diff --git a/tests/unit/services/revenue.daily.test.ts b/tests/unit/services/revenue.daily.test.ts new file mode 100644 index 0000000..c9fd2ce --- /dev/null +++ b/tests/unit/services/revenue.daily.test.ts @@ -0,0 +1,177 @@ +import { + MAX_INTERPOLATION_GAP_SECONDS, + SECONDS_PER_DAY, + computePerpSpotDaily, + lastPopulatedDate, +} from '../../../src/services/revenue/revenue.daily'; +import { FeeData } from '../../../src/types/fees.types'; + +const MICRO = 1_000_000; + +/** Seconds since epoch for a UTC wall-clock time. */ +function at(iso: string): number { + return Math.floor(Date.parse(iso) / 1000); +} + +/** + * A cumulative point. `total` and `spot` are plain dollars here and scaled to + * the micro-USD the upstream actually publishes, so the fixtures stay readable. + */ +function point(iso: string, total: number, spot: number): FeeData { + return { time: at(iso), total_fees: total * MICRO, total_spot_fees: spot * MICRO } as FeeData; +} + +/** + * The upstream cadence: one point a day just before midnight, each adding + * `perDay` of protocol fees of which `spotPerDay` is spot. + */ +function dailySeries( + days: number, + { perDay = 1_400_000, spotPerDay = 20_000, clock = 'T23:51:00Z' } = {} +): FeeData[] { + const out: FeeData[] = []; + for (let i = 0; i < days; i++) { + const day = new Date(Date.UTC(2026, 5, 1 + i)).toISOString().slice(0, 10); + out.push(point(`${day}${clock}`, perDay * (i + 1), spotPerDay * (i + 1))); + } + return out; +} + +describe('computePerpSpotDaily', () => { + it('reads a steady 24h cadence as flat days', () => { + const { daily } = computePerpSpotDaily(dailySeries(6)); + + for (const [, value] of daily) { + expect(value.perp).toBeCloseTo(1_380_000, 0); + expect(value.spot).toBeCloseTo(40_000, 0); // 20k protocol share, doubled + } + }); + + it('never emits the running day', () => { + const fees = dailySeries(5); + const { daily, coverageThrough } = computePerpSpotDaily(fees); + + // Points run 2026-06-01..05 at 23:51. The last closed midnight is 06-05 + // 00:00, so 06-05 itself is still open and must not appear. + expect(coverageThrough).toBe('2026-06-04'); + expect(daily.has('2026-06-05')).toBe(false); + }); + + it('does not emit a partial day when the newest point is intraday', () => { + // The exact prod failure: a cached snapshot whose newest point sits an hour + // into the day. Diffing raw points filed 2026-06-06 under one hour of fees. + const fees = [...dailySeries(5), point('2026-06-06T01:00:00Z', 7_058_000, 100_800)]; + const { daily, coverageThrough } = computePerpSpotDaily(fees); + + expect(daily.has('2026-06-06')).toBe(false); + expect(coverageThrough).toBe('2026-06-05'); + // 06-05 is now a whole day, not the sliver between 06-05T23:51 and midnight. + expect(daily.get('2026-06-05')!.perp).toBeGreaterThan(1_000_000); + }); + + it('splits a late point across the days it really spans', () => { + // 06-03's point slips to 06-04T07:00, so the raw diff would starve 06-03 + // and stuff 31h of fees into 06-04. + const fees = [ + point('2026-06-01T23:51:00Z', 1_400_000, 20_000), + point('2026-06-02T23:51:00Z', 2_800_000, 40_000), + point('2026-06-04T07:00:00Z', 4_760_000, 68_000), + point('2026-06-04T23:51:00Z', 5_600_000, 80_000), + point('2026-06-05T23:51:00Z', 7_000_000, 100_000), + ]; + const { daily } = computePerpSpotDaily(fees); + + // Both days land near the true run rate instead of one starving the other, + // and together they still account for the two days of fees that accrued. + const spanned = daily.get('2026-06-03')!.perp + daily.get('2026-06-04')!.perp; + expect(daily.get('2026-06-03')!.perp).toBeGreaterThan(1_000_000); + expect(daily.get('2026-06-04')!.perp).toBeGreaterThan(1_000_000); + expect(Math.abs(spanned - 2_760_000) / 2_760_000).toBeLessThan(0.01); + }); + + it('leaves out the days inside a gap too wide to interpolate', () => { + const gapDays = MAX_INTERPOLATION_GAP_SECONDS / SECONDS_PER_DAY + 3; + const resumeAt = new Date(Date.UTC(2026, 5, 2 + gapDays)).toISOString().slice(0, 10); + const fees = [ + point('2026-06-01T23:51:00Z', 1_400_000, 20_000), + point('2026-06-02T23:51:00Z', 2_800_000, 40_000), + point(`${resumeAt}T23:51:00Z`, 30_000_000, 400_000), + ]; + const { daily } = computePerpSpotDaily(fees); + + // The outage is absent, not dumped onto one record-breaking day. + expect(daily.has('2026-06-03')).toBe(false); + for (const [, value] of daily) { + expect(value.perp).toBeLessThan(5_000_000); + } + }); + + it('holds the line against a counter that goes backwards', () => { + const fees = [ + point('2026-06-01T23:51:00Z', 1_400_000, 20_000), + point('2026-06-02T23:51:00Z', 2_800_000, 40_000), + point('2026-06-03T23:51:00Z', 2_000_000, 30_000), + point('2026-06-04T23:51:00Z', 4_200_000, 60_000), + ]; + const { daily } = computePerpSpotDaily(fees); + + for (const [, value] of daily) { + expect(value.perp).toBeGreaterThanOrEqual(0); + expect(value.spot).toBeGreaterThanOrEqual(0); + } + }); + + it('returns nothing usable when the series cannot close a single day', () => { + expect(computePerpSpotDaily([])).toEqual({ daily: new Map(), coverageThrough: null }); + expect(computePerpSpotDaily([point('2026-06-01T23:51:00Z', 1, 0)]).coverageThrough).toBeNull(); + + const sameDay = [ + point('2026-06-01T08:00:00Z', 1_000_000, 10_000), + point('2026-06-01T20:00:00Z', 1_200_000, 12_000), + ]; + expect(computePerpSpotDaily(sameDay).coverageThrough).toBeNull(); + }); + + it('ignores malformed points instead of poisoning the series', () => { + const fees = [ + ...dailySeries(4), + { time: NaN, total_fees: 1, total_spot_fees: 0 } as FeeData, + { time: at('2026-06-03T12:00:00Z'), total_fees: NaN, total_spot_fees: 0 } as FeeData, + ]; + const { daily } = computePerpSpotDaily(fees); + + expect(daily.size).toBeGreaterThan(0); + for (const [, value] of daily) { + expect(Number.isFinite(value.perp)).toBe(true); + expect(Number.isFinite(value.spot)).toBe(true); + } + }); +}); + +describe('lastPopulatedDate', () => { + it('reports the newest day carrying a value', () => { + const daily = new Map([ + ['2026-07-09', 50_515], + ['2026-07-11', 2_860], + ['2026-07-10', 46_665], + ]); + expect(lastPopulatedDate(daily)).toBe('2026-07-11'); + }); + + it('sees through the zeros a frozen feed leaves behind', () => { + // What prod actually served: the upstream job died on 2026-07-11 and every + // day after it bucketed to a silent zero. + const daily = new Map([ + ['2026-07-10', 46_665], + ['2026-07-11', 2_860], + ['2026-07-12', 0], + ['2026-07-13', 0], + ]); + expect(lastPopulatedDate(daily)).toBe('2026-07-11'); + }); + + it('returns null when nothing is populated at all', () => { + expect(lastPopulatedDate(new Map())).toBeNull(); + expect(lastPopulatedDate(new Map([['2026-07-12', 0]]))).toBeNull(); + }); +});