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/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 fbc43ca..2fba1e0 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'; @@ -39,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(); + }); +});