From 48b35caf4c092d9b6face8aa81efbe3b1e5a457a Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:10:11 +0200 Subject: [PATCH 1/3] Hyperliquid has published half a day since 22 September and we summed it as a whole one (#2814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as "our stats don't match CoinMarketMan, sometimes +50% diff". They do not, and the cause is upstream. Hyperliquid's per-builder fills export stops partway through each day. Trust Wallet (0x5af1…5f71), last fill in the published file: 10 Sep 23:59:57 whole 21 Sep 23:59:54 whole 22 Sep 12:27:11 truncated 25 Sep 12:13:31 truncated 30 Sep 12:11:42 truncated 2 Oct 12:10:58 truncated Every day since 22 September ends within three minutes of 12:11 UTC. The file is stable across re-downloads (identical md5), so this is the published artefact and not a partial fetch. Checked against the live info API for one user on 2 October: the CSV holds 953 fills, the API 1,299 carrying a builder fee, and every CSV fill is present in the API with none the other way. The export is a strict subset. Both sides imply the same 0.0950% fee rate, which is Trust Wallet's, so the extra fills are its own and not another builder's. By hour: 04-11 kept 100%, hour 12 kept 47%, hours 13-15 kept nothing. So our figures were a faithful read of an upstream that had quietly halved. Fees and volume were understated together, the rates stayed right, and nothing in the pipeline could tell. This publishes the coverage instead of hiding it. The cohort's furthest fill per day is already collected during the pass that builds the windows, so the check costs no extra parsing: one builder going quiet in the evening proves nothing, the whole cohort going quiet at the same minute is the feed being cut short. hl_frontend_day_coverage_hours_v2 hours the headline day reached hl_frontend_day_truncated_v2 1 when it is under 23 hl_frontend_truncated_days_window_v2 such days inside the 30-day window Figures stay published. The answer to a truncated upstream is to say so, not to drop the day and let the window silently shorten. The node is not an alternative: it is running on SGP but has not advanced since 3 October 09:10, looping on "could not read abci state: early eof", with 10 of 24 hours for that day and nothing for the 4th. --- .../hyperliquid-frontends/cmd/script/agg.go | 64 ++++++++++++++++ .../cmd/script/coverage_test.go | 76 +++++++++++++++++++ .../cmd/script/metrics.go | 13 ++++ 3 files changed, 153 insertions(+) create mode 100644 harnesses/hyperliquid-frontends/cmd/script/coverage_test.go diff --git a/harnesses/hyperliquid-frontends/cmd/script/agg.go b/harnesses/hyperliquid-frontends/cmd/script/agg.go index c90173691..bfa80b860 100644 --- a/harnesses/hyperliquid-frontends/cmd/script/agg.go +++ b/harnesses/hyperliquid-frontends/cmd/script/agg.go @@ -372,6 +372,10 @@ func (a *Aggregator) publish() { } } + // day key -> the latest fill second seen across every builder. Used to + // tell a short day from a truncated feed; see publishCoverage. + cohortLastFill := map[string]int64{} + rows := make(map[string]builderWindow, len(a.builders)) var cohortVol float64 for _, b := range a.builders { @@ -393,6 +397,12 @@ func (a *Aggregator) publish() { continue } s := a.merged(b, d) + if s != nil && s.lastFillS > cohortLastFill[dayKey(d)] { + // Furthest fill any builder saw that day. One builder going + // quiet in the evening proves nothing; the whole cohort going + // quiet at the same minute is the feed being cut short. + cohortLastFill[dayKey(d)] = s.lastFillS + } if i == 0 { w.day = s w.dayKnown = s != nil || a.allAbsent(b, d) @@ -483,6 +493,7 @@ func (a *Aggregator) publish() { hlDataDay.Set(float64(D.Unix())) hlDataDayEnd.Set(float64(D.Unix() + daySec)) + a.publishCoverage(D, windowStart, cohortLastFill) // Liveness means "the feed was reachable and the gauges were rebuilt", // not just that the process is up: a pass with transport failures // leaves the tick alone so the success query goes false after 2 h. @@ -829,3 +840,56 @@ func (a *Aggregator) writeLedgerDay(b Builder, d time.Time) { a.state.setLedger(b.Slug, dayKey(d), dayTotals{}) } } + +// dayCoverageHours is how far into a UTC day the cohort's furthest fill +// reached. A complete day lands within minutes of 24; anything materially +// short means the published file stops before the day does. +func dayCoverageHours(day time.Time, lastFillS int64) float64 { + if lastFillS <= 0 { + return 0 + } + h := float64(lastFillS-day.Unix()) / 3600 + switch { + case h < 0: + return 0 + case h > 24: + return 24 + } + return h +} + +// coverageCompleteHours is the point past which a day counts as whole. +// +// Hyperliquid's per-builder export started stopping at roughly 12:11 UTC on +// 2026-09-22 and has every day since: 21 September ran to 23:59:54, 22 +// September to 12:27:11, and 2 October to 12:10:58, with the file stable +// across re-downloads, so this is the published artefact and not a partial +// fetch. Summing such a day beside a whole one understates it by about half +// and says nothing, which is the shape of error this harness exists to +// avoid. 23 leaves an hour of slack for a genuinely quiet late evening +// across the entire cohort, which has never happened in the mirror. +const coverageCompleteHours = 23 + +// publishCoverage reports how much of each day the feed actually carried. +// The figures stay published either way: the fix for a truncated upstream is +// to say so, not to drop the day and let the window silently shorten. +func (a *Aggregator) publishCoverage(D, windowStart time.Time, lastFill map[string]int64) { + cov := dayCoverageHours(D, lastFill[dayKey(D)]) + hlDayCoverageHours.Set(cov) + if cov < coverageCompleteHours { + hlDayTruncated.Set(1) + } else { + hlDayTruncated.Set(0) + } + truncated := 0 + for d := windowStart; !d.After(D); d = d.AddDate(0, 0, 1) { + ls, ok := lastFill[dayKey(d)] + if !ok { + continue // no file for that day: already counted elsewhere + } + if dayCoverageHours(d, ls) < coverageCompleteHours { + truncated++ + } + } + hlTruncatedDaysWindow.Set(float64(truncated)) +} diff --git a/harnesses/hyperliquid-frontends/cmd/script/coverage_test.go b/harnesses/hyperliquid-frontends/cmd/script/coverage_test.go new file mode 100644 index 000000000..9bdc987e3 --- /dev/null +++ b/harnesses/hyperliquid-frontends/cmd/script/coverage_test.go @@ -0,0 +1,76 @@ +package main + +import ( + "math" + "testing" + "time" +) + +func utc(y int, m time.Month, d int) time.Time { + return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) +} + +// at returns the unix second of hh:mm:ss inside day. +func at(day time.Time, hh, mm, ss int) int64 { + return day.Add(time.Duration(hh)*time.Hour + time.Duration(mm)*time.Minute + time.Duration(ss)*time.Second).Unix() +} + +// The numbers are the real ones, read from Hyperliquid's published export +// for Trust Wallet (0x5af1…5f71) on 2026-10-04. They are the reason this +// check exists, so they are what it is tested against. +func TestDayCoverageOnTheRealFeed(t *testing.T) { + cases := []struct { + day time.Time + hh, mm, ss int + wantHours float64 + truncated bool + note string + }{ + {utc(2026, 9, 10), 23, 59, 57, 24.0, false, "whole day, the shape before the break"}, + {utc(2026, 9, 21), 23, 59, 54, 24.0, false, "last whole day published"}, + {utc(2026, 9, 22), 12, 27, 11, 12.45, true, "first truncated day"}, + {utc(2026, 9, 25), 12, 13, 31, 12.23, true, ""}, + {utc(2026, 9, 30), 12, 11, 42, 12.20, true, ""}, + {utc(2026, 10, 2), 12, 10, 58, 12.18, true, "the day the discrepancy was reported on"}, + } + for _, c := range cases { + got := dayCoverageHours(c.day, at(c.day, c.hh, c.mm, c.ss)) + if math.Abs(got-c.wantHours) > 0.01 { + t.Errorf("%s: coverage = %.2f h, want %.2f (%s)", c.day.Format("2006-01-02"), got, c.wantHours, c.note) + } + if tr := got < coverageCompleteHours; tr != c.truncated { + t.Errorf("%s: truncated = %v at %.2f h, want %v (%s)", c.day.Format("2006-01-02"), tr, got, c.truncated, c.note) + } + } +} + +func TestCoverageEdges(t *testing.T) { + d := utc(2026, 10, 2) + if got := dayCoverageHours(d, 0); got != 0 { + t.Errorf("no fill at all = %v, want 0", got) + } + // A day with no file must not read as a complete one. + if 0 >= coverageCompleteHours { + t.Error("zero coverage must count as truncated") + } + // Clock skew or a stray row must not invent coverage. + if got := dayCoverageHours(d, d.Unix()-3600); got != 0 { + t.Errorf("a fill before the day started = %v, want 0", got) + } + if got := dayCoverageHours(d, d.Unix()+30*3600); got != 24 { + t.Errorf("a fill past the day's end = %v, want it clamped to 24", got) + } +} + +// The threshold has to let a genuinely quiet late evening through while +// still catching the real break. 23 h sits between them with room on both +// sides: the widest truncation seen is 12.45 h and a whole day is 24. +func TestThresholdSeparatesQuietFromTruncated(t *testing.T) { + d := utc(2026, 10, 2) + if dayCoverageHours(d, at(d, 23, 10, 0)) < coverageCompleteHours { + t.Error("a cohort whose last fill is 23:10 is quiet, not truncated") + } + if dayCoverageHours(d, at(d, 12, 27, 11)) >= coverageCompleteHours { + t.Error("12:27 is the real truncation and must be caught") + } +} diff --git a/harnesses/hyperliquid-frontends/cmd/script/metrics.go b/harnesses/hyperliquid-frontends/cmd/script/metrics.go index 302bd71ec..a7a1cc9b5 100644 --- a/harnesses/hyperliquid-frontends/cmd/script/metrics.go +++ b/harnesses/hyperliquid-frontends/cmd/script/metrics.go @@ -37,6 +37,19 @@ var ( hlPercentile = gauge("hl_frontend_volume_by_percentile_30d_v2", "Share (0..1) of the builder's 30-day notional contributed by wallets in the rank bucket: top1, p1_5, p5_10, p10_25, p25_50, rest.", "builder", "bucket") hlCoinShare = gauge("hl_frontend_coin_volume_share_24h_v2", "Share (0..1) of the feed day's notional per coin, top 15 coins plus 'other'.", "builder", "coin") + hlDayCoverageHours = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_frontend_day_coverage_hours_v2", + Help: "How far into the headline feed day the cohort's furthest fill reached, in hours. A whole day lands within minutes of 24. Hyperliquid's per-builder export has stopped at roughly 12:11 UTC every day since 2026-09-22, so a reading near 12 means the published file ends before the day does and the 24h figures describe half a day.", + }) + hlDayTruncated = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_frontend_day_truncated_v2", + Help: "1 when the headline feed day covers less than 23 hours. The figures are still published: the honest answer to a truncated upstream is to say so, not to drop the day and let the window silently shorten.", + }) + hlTruncatedDaysWindow = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_frontend_truncated_days_window_v2", + Help: "Days inside the 30-day window whose published file stops short of 23 hours. Every windowed total is understated by roughly half a day for each one counted here.", + }) + hlDataDay = promauto.NewGauge(prometheus.GaugeOpts{ Name: "hl_frontend_data_day_unix_v2", Help: "UTC midnight (unix seconds) that opens the feed day the 24h gauges describe.", From a15a057124460120d251fc4c0fed476c67f33ce0 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:44:57 +0200 Subject: [PATCH 2/3] the biggest day on /products was measuring feed coverage, not activity (#2864) Hyperliquid's public per-builder export cuts days off around 12:10 UTC, and intermittently rather than from one clean break: 15, 16 and 18 September were already short while 17, 19, 20 and 21 ran to 23:5x, and 3 October ran full in the middle of an unbroken run of short days. Seventeen of the thirty days ending 6 October were short. On the days it did carry in full, the hours before 13:00 hold 43% of the notional and 44% of the fees. Nothing on the page said so, and three things followed from that. The biggest day was wrong rather than low. A maximum over days of unequal length ranks coverage: fomo's peak published as 21 September at $72,233 because 21 September is one of the few whole days in the window, while 28 September carried more than that before noon alone and summed to $47,480. CoinMarketMan, on a complete feed, puts the peak on 6 October. The scan now skips days the feed cut short and the card says "Biggest complete day", since a short day's total is a lower bound and not comparable to a whole one. The window sums were right arithmetic on an incomplete feed and read as half the answer. fomo's 30d volume is $1.489B against CoinMarketMan's $2.823B, and day-by-day correction brings it to $2.409B, which accounts for the gap bar 15%. Those figures stay as measured rather than scaled to a guess: what changes is that /hyperliquid and the #hl section now carry the coverage above them. A null coverage reading discloses too, because "we did not measure it" and "the feed is whole" are different claims. The truncated-day count itself understated the problem. It only looked at days whose files were still inside the mirror window and skipped the rest, so it reported 12 of 30 where the archive held 17. Coverage is now recorded per day in state and kept after the files go away, with a companion gauge for how many days were actually measured so the note never invents a denominator. Also the timeframe control the leaderboard was missing: the harness has published 24h, 7d and 30d all along and the hub only ever read 30d. "24h" is labelled "feed day" because it is the last complete UTC day, not a rolling window. Co-authored-by: Claude Opus 5 (1M context) --- .../hyperliquid-frontends/cmd/script/agg.go | 100 ++++++++++---- .../cmd/script/coverage_test.go | 124 +++++++++++++++++ .../cmd/script/metrics.go | 4 + .../hyperliquid-frontends/cmd/script/state.go | 44 ++++++ src/app/hyperliquid/page.tsx | 5 + src/components/hl-builder-dashboard.tsx | 22 ++- src/components/hl-cohort-leaderboard.tsx | 98 ++++++++++--- src/components/hl-feed-coverage-note.tsx | 75 ++++++++++ src/components/hl-frontend-section.tsx | 8 ++ src/lib/hl-builder-stats.ts | 130 +++++++++++++++--- src/lib/hl-feed.test.ts | 67 +++++++++ src/lib/hl-feed.ts | 69 ++++++++++ 12 files changed, 680 insertions(+), 66 deletions(-) create mode 100644 src/components/hl-feed-coverage-note.tsx create mode 100644 src/lib/hl-feed.test.ts create mode 100644 src/lib/hl-feed.ts diff --git a/harnesses/hyperliquid-frontends/cmd/script/agg.go b/harnesses/hyperliquid-frontends/cmd/script/agg.go index bfa80b860..43d25f877 100644 --- a/harnesses/hyperliquid-frontends/cmd/script/agg.go +++ b/harnesses/hyperliquid-frontends/cmd/script/agg.go @@ -485,15 +485,20 @@ func (a *Aggregator) publish() { ledger := a.state.ledgerFor(b.Slug) a.publishDeltas(b.Slug, ledger, D, mirrorFrom) - if ledgerDone { - a.publishLedgerStats(b.Slug, ledger) - } a.buildSnapshot(b.Slug, D, ledger, w.users30m) } hlDataDay.Set(float64(D.Unix())) hlDataDayEnd.Set(float64(D.Unix() + daySec)) - a.publishCoverage(D, windowStart, cohortLastFill) + short := a.publishCoverage(D, windowStart, cohortLastFill) + + // Second pass: the biggest day needs to know which days were cut short, + // and cohortLastFill is only complete once every builder has been walked. + if ledgerDone { + for _, b := range a.builders { + a.publishLedgerStats(b.Slug, a.state.ledgerFor(b.Slug), short) + } + } // Liveness means "the feed was reachable and the gauges were rebuilt", // not just that the process is up: a pass with transport failures // leaves the tick alone so the success query goes false after 2 h. @@ -657,7 +662,19 @@ var milestoneThresholds = []struct { // publishLedgerStats emits the biggest day and the revenue milestones from // the complete ledger. -func (a *Aggregator) publishLedgerStats(slug string, ledger map[string]dayTotals) { +// +// `short` is the set of day keys the feed published cut short. Those days are +// skipped when picking the biggest day, and only there. A sum over the window +// that includes a half day understates the window, which the coverage gauges +// disclose; a *maximum* over days of unequal length answers a different +// question than the one asked. Measured on fomo 2026-10-07, the biggest day +// came out as 21 September purely because 21 September is one of twelve days +// in the window the feed carried to 23:59, while the genuinely larger days +// after it were cut at 12:00 and summed to less than half their size. Ranking +// coverage and calling it activity is worse than publishing a smaller number, +// so the figure is the biggest day the feed carried *whole* and the site +// labels it that way. +func (a *Aggregator) publishLedgerStats(slug string, ledger map[string]dayTotals, short map[string]bool) { days := ledgerDaysSorted(ledger) var bestFees float64 var bestDay string @@ -669,7 +686,7 @@ func (a *Aggregator) publishLedgerStats(slug string, ledger map[string]dayTotals if t.Fills > 0 && first == "" { first = d } - if t.Fees > bestFees { + if t.Fees > bestFees && !short[d] { bestFees = t.Fees bestDay = d } @@ -860,20 +877,45 @@ func dayCoverageHours(day time.Time, lastFillS int64) float64 { // coverageCompleteHours is the point past which a day counts as whole. // -// Hyperliquid's per-builder export started stopping at roughly 12:11 UTC on -// 2026-09-22 and has every day since: 21 September ran to 23:59:54, 22 -// September to 12:27:11, and 2 October to 12:10:58, with the file stable -// across re-downloads, so this is the published artefact and not a partial -// fetch. Summing such a day beside a whole one understates it by about half -// and says nothing, which is the shape of error this harness exists to -// avoid. 23 leaves an hour of slack for a genuinely quiet late evening -// across the entire cohort, which has never happened in the mirror. +// Hyperliquid's per-builder export cuts days off at roughly 12:10 UTC, and it +// does so intermittently rather than from one clean break: measured across +// the archive on 2026-10-07, 15, 16 and 18 September were already short while +// 17, 19, 20 and 21 September ran to 23:5x, and 3 October ran to 23:58:44 in +// the middle of an otherwise unbroken run of short days from 22 September. +// Seventeen of the thirty days ending 6 October were short. The file is +// stable across re-downloads, so this is the published artefact and not a +// partial fetch. +// +// Summing such a day beside a whole one understates it by about half and says +// nothing, which is the shape of error this harness exists to avoid. 23 +// leaves an hour of slack for a genuinely quiet late evening across the +// entire cohort, which has never happened in the mirror. +// +// Note the threshold is applied to the cohort's furthest fill, never to one +// builder's. A single builder's file ending at midday usually means that +// builder stopped trading: fomo's own file ends at 12:xx on 8 September, a +// day the cohort carried to 23:59:51. const coverageCompleteHours = 23 -// publishCoverage reports how much of each day the feed actually carried. -// The figures stay published either way: the fix for a truncated upstream is -// to say so, not to drop the day and let the window silently shorten. -func (a *Aggregator) publishCoverage(D, windowStart time.Time, lastFill map[string]int64) { +// publishCoverage reports how much of each day the feed actually carried and +// returns the set of day keys it proved short. +// +// The window figures stay published either way: the fix for a truncated +// upstream is to say so, not to drop the day and let the window silently +// shorten. The returned set exists for the one figure where a short day is not +// an understatement but a wrong answer, the biggest day on record, which +// compares days against each other rather than summing them. +func (a *Aggregator) publishCoverage(D, windowStart time.Time, lastFill map[string]int64) map[string]bool { + // Record every day we could measure this pass, so the count below can + // still see it once the fetch range moves past it and the files are gone. + for k, ls := range lastFill { + d, err := time.Parse(dayFormat, k) + if err != nil { + continue + } + a.state.setDayCoverage(k, dayCoverageHours(d, ls)) + } + cov := dayCoverageHours(D, lastFill[dayKey(D)]) hlDayCoverageHours.Set(cov) if cov < coverageCompleteHours { @@ -881,15 +923,25 @@ func (a *Aggregator) publishCoverage(D, windowStart time.Time, lastFill map[stri } else { hlDayTruncated.Set(0) } - truncated := 0 + + short := map[string]bool{} + measured := 0 for d := windowStart; !d.After(D); d = d.AddDate(0, 0, 1) { - ls, ok := lastFill[dayKey(d)] + k := dayKey(d) + h, ok := a.state.dayCoverage(k) if !ok { - continue // no file for that day: already counted elsewhere + // Never measured: the harness started after this day left the + // mirror window. Not counted either way, and the examined count + // says so rather than letting the ratio imply a clean day. + continue } - if dayCoverageHours(d, ls) < coverageCompleteHours { - truncated++ + measured++ + if h < coverageCompleteHours { + short[k] = true } } - hlTruncatedDaysWindow.Set(float64(truncated)) + hlTruncatedDaysWindow.Set(float64(len(short))) + hlCoverageDaysMeasured.Set(float64(measured)) + a.state.pruneDayCoverage(dayKey(windowStart.AddDate(0, 0, -30))) + return short } diff --git a/harnesses/hyperliquid-frontends/cmd/script/coverage_test.go b/harnesses/hyperliquid-frontends/cmd/script/coverage_test.go index 9bdc987e3..6d848b3bb 100644 --- a/harnesses/hyperliquid-frontends/cmd/script/coverage_test.go +++ b/harnesses/hyperliquid-frontends/cmd/script/coverage_test.go @@ -4,8 +4,31 @@ import ( "math" "testing" "time" + + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" ) +// readPlainGauge pulls the value back out of an unlabelled Gauge. +func readPlainGauge(t *testing.T, g prometheus.Gauge) float64 { + t.Helper() + var m dto.Metric + if err := g.Write(&m); err != nil { + t.Fatalf("read gauge: %v", err) + } + return m.GetGauge().GetValue() +} + +// readGauge pulls one labelled value back out of a GaugeVec. +func readGauge(t *testing.T, g *prometheus.GaugeVec, labels ...string) float64 { + t.Helper() + var m dto.Metric + if err := g.WithLabelValues(labels...).Write(&m); err != nil { + t.Fatalf("read gauge %v: %v", labels, err) + } + return m.GetGauge().GetValue() +} + func utc(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) } @@ -74,3 +97,104 @@ func TestThresholdSeparatesQuietFromTruncated(t *testing.T) { t.Error("12:27 is the real truncation and must be caught") } } + +// TestBiggestDaySkipsShortDays is the fomo case from 2026-10-07, with the +// real ledger figures. The published biggest day was 21 September at +// $72,232.94, and it won only because 21 September is one of the twelve days +// in the window the feed carried to midnight: 28 September carried more than +// that in its first twelve hours alone and was cut at 12:00, so it summed to +// $47,480 and lost. CoinMarketMan, reading a complete feed, put the peak on +// 6 October. +// +// A maximum over days of unequal length ranks coverage, not activity, so a +// day the feed cut short cannot win. The figure then means "the biggest day +// the feed carried whole", which is what the site says it is. +func TestBiggestDaySkipsShortDays(t *testing.T) { + ledger := map[string]dayTotals{ + "20260920": {Fees: 41_018, Vol: 90e6, Fills: 1000, Users: 400}, + "20260921": {Fees: 72_232, Vol: 159e6, Fills: 2400, Users: 900}, + "20260928": {Fees: 47_480, Vol: 103e6, Fills: 1300, Users: 500}, + "20261006": {Fees: 25_287, Vol: 53e6, Fills: 1000, Users: 400}, + } + short := map[string]bool{"20260928": true, "20261006": true} + + a := &Aggregator{} + a.publishLedgerStats("fomo", ledger, short) + if got := readGauge(t, hlBiggestDay, "fomo"); got != 72_232 { + t.Fatalf("biggest day = %v, want the biggest whole day 72232", got) + } + if got := readGauge(t, hlBiggestDayAt, "fomo"); got != float64(utc(2026, 9, 21).Unix()) { + t.Fatalf("biggest day at = %v, want 2026-09-21", got) + } + + // Without the guard the same ledger would hand the title to whichever day + // happened to be longest, so a short day that genuinely led must not win + // on a number that is only a lower bound. + ledger["20261006"] = dayTotals{Fees: 200_000, Vol: 400e6, Fills: 3000, Users: 1200} + a.publishLedgerStats("fomo", ledger, short) + if got := readGauge(t, hlBiggestDay, "fomo"); got != 72_232 { + t.Fatalf("a short day won with %v; a half day is a lower bound, not a maximum", got) + } + + // With no short days the behaviour is the plain maximum it always was. + a.publishLedgerStats("fomo", ledger, map[string]bool{}) + if got := readGauge(t, hlBiggestDay, "fomo"); got != 200_000 { + t.Fatalf("biggest day = %v, want 200000 when every day is whole", got) + } +} + +// TestCoverageCountSpansTheWholeWindow is the bug the disclosure note first +// shipped with: the count only looked at days whose files were still in the +// mirror, so it reported 12 short days out of 30 on 2026-10-07 while the +// archive showed 17. A disclosure that understates the thing it discloses is +// worse than none, because it reads as a bounded problem. +func TestCoverageCountSpansTheWholeWindow(t *testing.T) { + D := utc(2026, 10, 6) + windowStart := D.AddDate(0, 0, -29) + + st := &State{Version: 2} + st.init() + // Recorded on earlier passes, while the files were still fetchable. These + // are the real last-fill times from the archive. + for _, c := range []struct { + day time.Time + hours float64 + }{ + {utc(2026, 9, 15), 12.15}, + {utc(2026, 9, 16), 12.15}, + {utc(2026, 9, 17), 23.78}, + {utc(2026, 9, 18), 12.14}, + {utc(2026, 9, 19), 23.97}, + } { + st.setDayCoverage(dayKey(c.day), c.hours) + } + a := &Aggregator{state: st} + + // This pass can only reach the last few days. + lastFill := map[string]int64{ + dayKey(utc(2026, 10, 4)): at(utc(2026, 10, 4), 12, 4, 17), + dayKey(utc(2026, 10, 5)): at(utc(2026, 10, 5), 11, 59, 53), + dayKey(D): at(D, 12, 6, 23), + } + + short := a.publishCoverage(D, windowStart, lastFill) + + // Three short days this pass plus three recorded earlier, and the two + // whole days recorded earlier stay out. + if len(short) != 6 { + t.Fatalf("short days = %d, want 6 (3 from this pass, 3 from state)", len(short)) + } + if got := readPlainGauge(t, hlTruncatedDaysWindow); got != 6 { + t.Fatalf("truncated gauge = %v, want 6", got) + } + // Eight days were measured across the two sources. The other 22 in the + // window were never measured, and must not inflate the denominator into + // reading as 22 clean days. + if got := readPlainGauge(t, hlCoverageDaysMeasured); got != 8 { + t.Fatalf("measured gauge = %v, want 8", got) + } + // What this pass measured is kept for the passes after the files go away. + if h, ok := st.dayCoverage(dayKey(D)); !ok || math.Abs(h-12.106) > 0.01 { + t.Fatalf("coverage for the feed day not recorded: %v %v", h, ok) + } +} diff --git a/harnesses/hyperliquid-frontends/cmd/script/metrics.go b/harnesses/hyperliquid-frontends/cmd/script/metrics.go index a7a1cc9b5..e0e09cef4 100644 --- a/harnesses/hyperliquid-frontends/cmd/script/metrics.go +++ b/harnesses/hyperliquid-frontends/cmd/script/metrics.go @@ -49,6 +49,10 @@ var ( Name: "hl_frontend_truncated_days_window_v2", Help: "Days inside the 30-day window whose published file stops short of 23 hours. Every windowed total is understated by roughly half a day for each one counted here.", }) + hlCoverageDaysMeasured = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_frontend_coverage_days_measured_v2", + Help: "Days in the 30-day window whose feed coverage was actually measured. The truncated-day count is out of this, not out of 30: a day that left the mirror window before this harness first ran was never measured, and counting it as clean would understate the truncation.", + }) hlDataDay = promauto.NewGauge(prometheus.GaugeOpts{ Name: "hl_frontend_data_day_unix_v2", diff --git a/harnesses/hyperliquid-frontends/cmd/script/state.go b/harnesses/hyperliquid-frontends/cmd/script/state.go index c17aac3aa..71c317806 100644 --- a/harnesses/hyperliquid-frontends/cmd/script/state.go +++ b/harnesses/hyperliquid-frontends/cmd/script/state.go @@ -34,6 +34,13 @@ type State struct { Hip3Daily map[string]map[string]float64 `json:"hip3_daily"` // DataDay is the last complete feed day the gauges were published for. DataDay string `json:"data_day"` + // DayCoverage[day] = the furthest fill second seen across the cohort on + // that day, as hours into the day. Written while the day is inside the + // mirror window and kept afterwards, because the files it was measured + // from are deleted once the fetch range moves on: without this the + // truncated-day count could only look at the mirror window and reported + // 12 of 30 where the real figure was 17. + DayCoverage map[string]float64 `json:"day_coverage"` mu sync.Mutex saveMu sync.Mutex // one writer of the file at a time (poller, aggregator, shutdown) @@ -67,9 +74,46 @@ func (s *State) init() { if s.Hip3Daily == nil { s.Hip3Daily = make(map[string]map[string]float64) } + if s.DayCoverage == nil { + s.DayCoverage = make(map[string]float64) + } s.Version = 2 } +// setDayCoverage records how far into `day` the cohort's furthest fill +// reached, in hours. Only ever called for a day whose files are present, so a +// day already recorded is refreshed rather than overwritten with a zero. +func (s *State) setDayCoverage(day string, hours float64) { + s.mu.Lock() + defer s.mu.Unlock() + if s.DayCoverage == nil { + s.DayCoverage = make(map[string]float64) + } + s.DayCoverage[day] = hours +} + +// dayCoverage returns the recorded coverage for a day and whether it was ever +// measured. The second return is the whole point: a day nobody measured must +// not read as a day measured at zero. +func (s *State) dayCoverage(day string) (float64, bool) { + s.mu.Lock() + defer s.mu.Unlock() + h, ok := s.DayCoverage[day] + return h, ok +} + +// pruneDayCoverage drops recorded days older than `oldest` so the file does +// not grow without bound. +func (s *State) pruneDayCoverage(oldest string) { + s.mu.Lock() + defer s.mu.Unlock() + for d := range s.DayCoverage { + if d < oldest { + delete(s.DayCoverage, d) + } + } +} + func (s *State) save() error { s.saveMu.Lock() defer s.saveMu.Unlock() diff --git a/src/app/hyperliquid/page.tsx b/src/app/hyperliquid/page.tsx index c42bf1c4a..4a852b9ae 100644 --- a/src/app/hyperliquid/page.tsx +++ b/src/app/hyperliquid/page.tsx @@ -5,6 +5,7 @@ import { fetchHlHistory, } from "@/lib/hl-builder-stats"; import { HlHubTabs } from "@/components/hl-hub-tabs"; +import { HlFeedCoverageNote } from "@/components/hl-feed-coverage-note"; import { pageMetadata } from "@/lib/page-metadata"; import { safeJsonLd } from "@/lib/jsonld"; @@ -206,6 +207,10 @@ export default async function HyperliquidHubPage() { + {frontends && ( + + )} + {frontends || hip3 ? ( <> + +
{stats.biggestDayRevenue > 0 && ( 0 + ? "Biggest complete day" + : "Biggest day" + } value={fmtUSD(stats.biggestDayRevenue)} detail={ stats.biggestDayUnix > 0 @@ -107,6 +118,11 @@ export function HlBuilderDashboard({ .slice(0, 10) : undefined } + tip={ + stats.feed && stats.feed.truncatedDaysWindow > 0 + ? "Highest single UTC day the per-builder feed published in full. Days the feed cut short are skipped: their totals are lower bounds and cannot be compared against a whole day." + : undefined + } /> )} {stats.milestoneDays["10k"] >= 0 && ( @@ -193,13 +209,15 @@ function Milestone({ label, value, detail, + tip, }: { label: string; value: string; detail?: string; + tip?: string; }) { return ( -
+

{label}

{value}

{detail &&

{detail}

} diff --git a/src/components/hl-cohort-leaderboard.tsx b/src/components/hl-cohort-leaderboard.tsx index 139e0c889..68e16c5e5 100644 --- a/src/components/hl-cohort-leaderboard.tsx +++ b/src/components/hl-cohort-leaderboard.tsx @@ -4,6 +4,8 @@ import Link from "next/link"; import { useMemo, useState } from "react"; import { ProviderLogo } from "@/components/provider-logo"; import { HlSparkline } from "@/components/hl-sparkline"; +import { HL_WINDOWS, HL_WINDOW_LABEL } from "@/lib/hl-feed"; +import type { HlWindow } from "@/lib/hl-feed"; import type { HlCohortRow, HlHistoryFrontendCompact, @@ -19,9 +21,18 @@ import type { * 12-month sparkline in the trend column (matches the parent hub's * per-frontend detail page). Slugs missing from the map render an * em-dash so builders without a history sample don't blank the column. + * + * The timeframe control swaps the revenue/volume/users columns between the + * three windows the harness publishes. 24h means the last complete UTC feed + * day rather than a rolling 24 hours, which is why the label says "feed day"; + * every builder is measured on the same day, so the column is comparable. + * Users do not add across windows (a wallet active on five days is one user + * over 7d), so the 7d and 30d figures are unions the harness computes, never + * sums of the 24h column. */ -type SortKey = "revenue30d" | "volume30d" | "users30d" | "cohortVolumeShare24h"; +/** The three value columns, plus the share column which only exists for 24h. */ +type SortKey = "revenue" | "volume" | "users" | "cohortVolumeShare24h"; export function HlCohortLeaderboard({ rows, @@ -30,9 +41,24 @@ export function HlCohortLeaderboard({ rows: HlCohortRow[]; historyBySlug?: Map; }) { - const [sortKey, setSortKey] = useState("revenue30d"); + const [sortKey, setSortKey] = useState("revenue"); const [sortDir, setSortDir] = useState<"desc" | "asc">("desc"); const [q, setQ] = useState(""); + const [timeframe, setTimeframe] = useState("30d"); + + // A snapshot written before the per-window fields existed has no byWindow, + // and the Upstash blob can be up to ten minutes old. Fall back to the flat + // 30d fields rather than render a table of zeros for one cache cycle. + const valueOf = (r: HlCohortRow, key: SortKey): number => { + if (key === "cohortVolumeShare24h") return r.cohortVolumeShare24h; + const w = r.byWindow?.[timeframe]; + if (w) return w[key]; + return key === "revenue" + ? r.revenue30d + : key === "volume" + ? r.volume30d + : r.users30d; + }; const filtered = useMemo(() => { const needle = q.trim().toLowerCase(); @@ -44,8 +70,12 @@ export function HlCohortLeaderboard({ ) : rows; const factor = sortDir === "desc" ? -1 : 1; - return [...out].sort((a, b) => factor * (a[sortKey] - b[sortKey])); - }, [rows, sortKey, sortDir, q]); + return [...out].sort( + (a, b) => factor * (valueOf(a, sortKey) - valueOf(b, sortKey)), + ); + // valueOf closes over `timeframe`, which is listed; it is stable otherwise. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rows, sortKey, sortDir, q, timeframe]); const setSort = (k: SortKey) => { if (k === sortKey) setSortDir((d) => (d === "desc" ? "asc" : "desc")); @@ -58,12 +88,36 @@ export function HlCohortLeaderboard({ return (
-

- {filtered.length} of {rows.length} builders -

+
+

+ {filtered.length} of {rows.length} builders +

+
+ {HL_WINDOWS.map((w) => ( + + ))} +
+
# Builder setSort("revenue30d")} + onClick={() => setSort("revenue")} > - Revenue 30d + Revenue {HL_WINDOW_LABEL[timeframe]} setSort("volume30d")} + onClick={() => setSort("volume")} > - Volume 30d + Volume {HL_WINDOW_LABEL[timeframe]} setSort("users30d")} + onClick={() => setSort("users")} > - Users 30d + Users {HL_WINDOW_LABEL[timeframe]} )} - {fmtUSD(r.revenue30d)} - {fmtUSD(r.volume30d)} - {fmtCount(r.users30d)} + {fmtUSD(valueOf(r, "revenue"))} + {fmtUSD(valueOf(r, "volume"))} + {fmtCount(valueOf(r, "users"))} {fmtPct(r.cohortVolumeShare24h)} {historyBySlug && ( diff --git a/src/components/hl-feed-coverage-note.tsx b/src/components/hl-feed-coverage-note.tsx new file mode 100644 index 000000000..2483f826b --- /dev/null +++ b/src/components/hl-feed-coverage-note.tsx @@ -0,0 +1,75 @@ +import { feedNeedsDisclosure } from "@/lib/hl-feed"; +import type { HlFeedCoverage } from "@/lib/hl-feed"; + +/** + * Says how much of each day Hyperliquid's public per-builder export actually + * carried, whenever that is less than all of it. + * + * Every figure on these pages is a sum over UTC days read from that export. + * It stopped publishing whole days on 2026-09-22: most days since end at + * roughly 12:00 UTC, and on the days it did carry in full, the hours before + * 13:00 hold about 43% of the notional and 44% of the fees. So a 30d total + * built from a window half made of those days lands near half of what a + * tracker reading a complete feed reports, and nothing on the page said so. + * + * The figures stay as measured rather than scaled up to a guess. What changes + * is that the reader is told what the denominator is, which is the difference + * between an incomplete measurement and a wrong one. + */ +export function HlFeedCoverageNote({ + feed, + className = "", +}: { + feed: HlFeedCoverage | null; + className?: string; +}) { + if (!feedNeedsDisclosure(feed)) return null; + + const hours = + feed && feed.coverageHours > 0 ? feed.coverageHours.toFixed(1) : null; + + return ( + + ); +} diff --git a/src/components/hl-frontend-section.tsx b/src/components/hl-frontend-section.tsx index 66b1f3ced..83ed51913 100644 --- a/src/components/hl-frontend-section.tsx +++ b/src/components/hl-frontend-section.tsx @@ -6,6 +6,7 @@ import { type HlHistoryFrontendCompact, } from "@/lib/hl-builder-stats"; import { HlBuilderDashboard } from "@/components/hl-builder-dashboard"; +import { HlFeedCoverageNote } from "@/components/hl-feed-coverage-note"; /** * Hyperliquid frontend view on /products/, behind the @@ -92,6 +93,12 @@ export async function HlFrontendSection({ {hlStats ? ( ) : frontend || cohortRow ? ( + <> + {/* The dashboard carries its own copy of this note. The fallback + grid reads the same partial-day sums, so it needs one too. */} + {cohort && ( + + )}
@@ -109,6 +116,7 @@ export async function HlFrontendSection({ /> ) : null}
+ ) : null} {!frontend && ( diff --git a/src/lib/hl-builder-stats.ts b/src/lib/hl-builder-stats.ts index 59e332ea0..4025af64f 100644 --- a/src/lib/hl-builder-stats.ts +++ b/src/lib/hl-builder-stats.ts @@ -8,12 +8,15 @@ import { unstable_cache } from "next/cache"; import { Prometheus } from "@/lib/prometheus"; +import type { HlWindow, HlFeedCoverage } from "@/lib/hl-feed"; import { getSpecs } from "@/lib/spec"; import { readCohortSnapshot, writeCohortSnapshot, } from "@/lib/cohort-snapshot"; +export type { HlWindow, HlFeedCoverage } from "@/lib/hl-feed"; + export type CoinShare = { coin: string; share: number }; export type PercentileBucket = { bucket: "top1" | "p1_5" | "p5_10" | "p10_25" | "p25_50" | "rest"; @@ -53,6 +56,9 @@ export type HlBuilderStats = { percentileShares30d: PercentileBucket[]; /** Fraction of 30d-active users with realized PnL > 0 (0..1). */ profitableUserPct30d: number; + /** Feed coverage for the window these figures were summed over. Null when + * the gauges are not published yet (pre-2026-10-07 harness builds). */ + feed: HlFeedCoverage | null; }; /** @@ -87,6 +93,35 @@ function promUrl(): string | null { return process.env.PROMETHEUS_URL?.trim() || null; } +/** + * Read the three cohort-wide coverage gauges. They carry no `builder` label, + * so one read serves every caller. A missing gauge returns null rather than + * a reassuring zero: "we did not measure the coverage" and "the feed is + * whole" must not look the same, which is the mistake the coverage gauges + * were added to stop making in the first place. + */ +async function readFeedCoverage(prom: Prometheus): Promise { + const [hours, truncated, inWindow, measured] = await Promise.all([ + prom.scalar(`hl_frontend_day_coverage_hours_v2`), + prom.scalar(`hl_frontend_day_truncated_v2`), + prom.scalar(`hl_frontend_truncated_days_window_v2`), + prom.scalar(`hl_frontend_coverage_days_measured_v2`), + ]); + if (hours === null && truncated === null && inWindow === null) return null; + const windowDays = 30; + return { + coverageHours: hours ?? 0, + latestDayTruncated: (truncated ?? 0) > 0, + truncatedDaysWindow: Math.round(inWindow ?? 0), + // 0 means the harness is too old to report how many days it measured. + // Neither denominator is safe to assume there: the full window overstates + // it and the numerator's own scope reads as "N of N". The note drops the + // denominator instead of inventing one. + daysMeasured: Math.round(measured ?? 0), + windowDays, + }; +} + const PERCENTILE_ORDER: PercentileBucket["bucket"][] = [ "top1", "p1_5", @@ -133,6 +168,7 @@ export async function fetchHlBuilderStatsFresh( profitableUserPct30d, coinSharesRaw, percentileSharesRaw, + feed, ] = await Promise.all([ prom.scalar(`hl_frontend_fees_usd_30d_v2${sel}`), prom.scalar(`hl_frontend_revenue_delta_pct_v2{builder="${slug}",window="30d"}`), @@ -147,6 +183,7 @@ export async function fetchHlBuilderStatsFresh( prom.scalar(`hl_frontend_profitable_user_pct_30d_v2${sel}`), queryVector(prom, `hl_frontend_coin_volume_share_24h_v2${sel}`), queryVector(prom, `hl_frontend_volume_by_percentile_30d_v2${sel}`), + readFeedCoverage(prom), ]); if ( @@ -199,6 +236,7 @@ export async function fetchHlBuilderStatsFresh( coinShares24h, percentileShares30d, profitableUserPct30d: profitableUserPct30d ?? 0, + feed, }; } @@ -255,6 +293,10 @@ export type HlCohortRow = { volume30d: number; users30d: number; cohortVolumeShare24h: number; + /** Revenue, volume and users per window. The leaderboard's timeframe + * control reads these; the flat `*30d` fields above stay for callers that + * only ever wanted the headline window. */ + byWindow: Record; }; export type HlCohortSummary = { @@ -263,6 +305,8 @@ export type HlCohortSummary = { totalVolume30d: number; totalUsers30d: number; asOf: number; + /** Feed coverage, shared by every row: the gauges are cohort-wide. */ + feed: HlFeedCoverage | null; }; export type HlHip3Row = { @@ -369,40 +413,88 @@ export async function fetchHlCohortFresh(): Promise { const providers = hl?.providers ?? []; const nameBySlug = new Map(providers.map((p) => [p.slug, p.name])); - const [feesRaw, volumeRaw, usersRaw, shareRaw] = await Promise.all([ + // Nine vectors rather than three: the leaderboard's timeframe control needs + // 24h and 7d alongside 30d, and a vector query costs the same whatever the + // cohort size, so this is three extra round trips, not 6 x 104. + const [ + feesRaw, + volumeRaw, + usersRaw, + shareRaw, + fees24hRaw, + volume24hRaw, + users24hRaw, + fees7dRaw, + volume7dRaw, + users7dRaw, + feed, + ] = await Promise.all([ queryVector(prom, `hl_frontend_fees_usd_30d_v2`), queryVector(prom, `hl_frontend_volume_usd_30d_v2`), queryVector(prom, `hl_frontend_users_30d_v2`), queryVector(prom, `hl_frontend_global_volume_share_24h_v2`), + queryVector(prom, `hl_frontend_fees_usd_24h_v2`), + queryVector(prom, `hl_frontend_volume_usd_24h_v2`), + queryVector(prom, `hl_frontend_users_24h_v2`), + queryVector(prom, `hl_frontend_fees_usd_7d_v2`), + queryVector(prom, `hl_frontend_volume_usd_7d_v2`), + queryVector(prom, `hl_frontend_users_7d_v2`), + readFeedCoverage(prom), ]); if (feesRaw === null && volumeRaw === null && usersRaw === null) { return null; } - const byBuilder = new Map< - string, - { revenue: number; volume: number; users: number; share: number } - >(); - const ensure = (slug: string) => { + type Acc = { + revenue: number; + volume: number; + users: number; + share: number; + byWindow: Record; + }; + const byBuilder = new Map(); + const ensure = (slug: string): Acc => { let r = byBuilder.get(slug); if (!r) { - r = { revenue: 0, volume: 0, users: 0, share: 0 }; + r = { + revenue: 0, + volume: 0, + users: 0, + share: 0, + byWindow: { + "24h": { revenue: 0, volume: 0, users: 0 }, + "7d": { revenue: 0, volume: 0, users: 0 }, + "30d": { revenue: 0, volume: 0, users: 0 }, + }, + }; byBuilder.set(slug, r); } return r; }; - for (const s of feesRaw ?? []) { - const b = s.labels.builder; - if (b) ensure(b).revenue = s.value; - } - for (const s of volumeRaw ?? []) { - const b = s.labels.builder; - if (b) ensure(b).volume = s.value; - } - for (const s of usersRaw ?? []) { - const b = s.labels.builder; - if (b) ensure(b).users = s.value; + const absorb = ( + raw: Awaited>, + window: HlWindow, + field: "revenue" | "volume" | "users", + ) => { + for (const s of raw ?? []) { + const b = s.labels.builder; + if (b) ensure(b).byWindow[window][field] = s.value; + } + }; + absorb(feesRaw, "30d", "revenue"); + absorb(volumeRaw, "30d", "volume"); + absorb(usersRaw, "30d", "users"); + absorb(fees24hRaw, "24h", "revenue"); + absorb(volume24hRaw, "24h", "volume"); + absorb(users24hRaw, "24h", "users"); + absorb(fees7dRaw, "7d", "revenue"); + absorb(volume7dRaw, "7d", "volume"); + absorb(users7dRaw, "7d", "users"); + for (const [, v] of byBuilder) { + v.revenue = v.byWindow["30d"].revenue; + v.volume = v.byWindow["30d"].volume; + v.users = v.byWindow["30d"].users; } for (const s of shareRaw ?? []) { const b = s.labels.builder; @@ -423,6 +515,7 @@ export async function fetchHlCohortFresh(): Promise { volume30d: v.volume, users30d: v.users, cohortVolumeShare24h: v.share, + byWindow: v.byWindow, }); totalRevenue30d += v.revenue; totalVolume30d += v.volume; @@ -436,6 +529,7 @@ export async function fetchHlCohortFresh(): Promise { totalVolume30d, totalUsers30d, asOf: Math.floor(Date.now() / 1000), + feed, }; } diff --git a/src/lib/hl-feed.test.ts b/src/lib/hl-feed.test.ts new file mode 100644 index 000000000..0777e93f3 --- /dev/null +++ b/src/lib/hl-feed.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "bun:test"; +import { feedNeedsDisclosure, HL_WINDOWS, HL_WINDOW_LABEL } from "@/lib/hl-feed"; + +describe("feedNeedsDisclosure", () => { + // The real shape on 2026-10-07: Hyperliquid's per-builder export carried + // 12.2 of 24 hours on the latest day and cut 18 of the window's 30 days + // short, which is why every 30d figure read about half of CoinMarketMan's. + it("discloses a window the feed cut short", () => { + expect( + feedNeedsDisclosure({ + coverageHours: 12.16, + latestDayTruncated: true, + truncatedDaysWindow: 17, + daysMeasured: 24, + windowDays: 30, + }), + ).toBe(true); + }); + + it("stays quiet on a whole feed", () => { + expect( + feedNeedsDisclosure({ + coverageHours: 24, + latestDayTruncated: false, + truncatedDaysWindow: 0, + daysMeasured: 30, + windowDays: 30, + }), + ).toBe(false); + }); + + // The window can be clean while the day in progress is not, which is the + // state the morning after the feed starts truncating. + it("discloses a short latest day even with a clean window", () => { + expect( + feedNeedsDisclosure({ + coverageHours: 12.16, + latestDayTruncated: true, + truncatedDaysWindow: 0, + daysMeasured: 30, + windowDays: 30, + }), + ).toBe(true); + }); + + // Not measured is not the same claim as measured whole. A harness too old + // to publish the coverage gauges is exactly the state this note exists for, + // so an absent measurement must not render as reassurance. + it("discloses when coverage was never measured", () => { + expect(feedNeedsDisclosure(null)).toBe(true); + }); +}); + +describe("window labels", () => { + // "24h" is the last complete UTC feed day, not a rolling 24 hours, and + // every builder is measured on the same day. A column headed "24h" would + // promise the wrong thing. + it("never calls the feed day 24h", () => { + expect(HL_WINDOW_LABEL["24h"]).toBe("feed day"); + }); + + it("labels every window it declares", () => { + for (const w of HL_WINDOWS) { + expect(HL_WINDOW_LABEL[w]).toBeTruthy(); + } + }); +}); diff --git a/src/lib/hl-feed.ts b/src/lib/hl-feed.ts new file mode 100644 index 000000000..c61b078db --- /dev/null +++ b/src/lib/hl-feed.ts @@ -0,0 +1,69 @@ +/** + * Facts about Hyperliquid's public per-builder daily fills feed that both the + * server loaders and the client components need: the windows it publishes and + * how much of each day it actually carried. + * + * Split out of `hl-builder-stats` because that module reaches Prometheus and + * Upstash, which pull `node:dns`. A type-only import from it is erased at + * compile time and costs nothing, but importing a runtime value drags the + * whole server module into the client chunk and the build fails on the node + * builtin. + */ + +export type HlWindow = "24h" | "7d" | "30d"; + +export const HL_WINDOWS: HlWindow[] = ["24h", "7d", "30d"]; + +/** "24h" is the last complete UTC feed day rather than a rolling window, and + * the column header says so to keep the reader from assuming otherwise. */ +export const HL_WINDOW_LABEL: Record = { + "24h": "feed day", + "7d": "7d", + "30d": "30d", +}; + +/** + * How much of each UTC day the export actually carried. Measured, not + * assumed: the harness reads the furthest fill second in each day's file + * across the whole cohort. + * + * This matters because every window figure is a sum over days. The export + * stopped publishing whole days on 2026-09-22 and most days since end at + * roughly 12:00 UTC, while on the complete days the hours before 13:00 carry + * only about 43% of the notional and 44% of the fees. A 30d total built from + * such a window is the right arithmetic on an incomplete feed and reads about + * half of what a tracker on a complete feed reports. Publishing the figure + * without the coverage beside it is the part that misleads. + */ +export type HlFeedCoverage = { + /** Hours of the latest feed day the export carried. 24 = whole day. */ + coverageHours: number; + /** The latest feed day stops materially short of midnight. */ + latestDayTruncated: boolean; + /** Days inside the window the export cut short. */ + truncatedDaysWindow: number; + /** Days in the window whose coverage was actually measured, or 0 when the + * harness does not report it. The count above is out of this, never out of + * `windowDays`: a day that left the harness's mirror window before it + * first ran was never measured, and treating it as clean is how the first + * version of this disclosure came to report 12 short days where the + * archive held 17. */ + daysMeasured: number; + /** Days the window spans, for context when it differs from daysMeasured. */ + windowDays: number; +}; + +/** + * Whether the reader has to be told about the feed before reading the + * figures. + * + * `null` means the coverage gauges were not published, which is not the same + * claim as "the feed is whole" and must not render as silence: a harness too + * old to measure coverage is exactly the state this disclosure exists for. + * So an unmeasured feed discloses, and only a feed measured whole stays + * quiet. + */ +export function feedNeedsDisclosure(feed: HlFeedCoverage | null): boolean { + if (!feed) return true; + return feed.truncatedDaysWindow > 0 || feed.latestDayTruncated; +} From 81fc939648ac913f85214256f38dcfc30b89b88f Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:04:49 +0200 Subject: [PATCH 3/3] the coverage check was a max, so one late file hid a cut-off day (#2866) Hyperliquid's export cuts a day at one instant for the whole cohort, so the per-builder last fills cluster inside a few minutes. Measured across the archive: on 6 October 2026 every qualifying builder's file ends between 11:58 and 12:09, on 23 September between 12:01 and 12:13, on 21 September between 23:31 and 23:59. The day's coverage was taken as the maximum of those. The reasoning was sound as far as it went, one builder going quiet in the evening proves nothing about the feed, but a maximum is maximally sensitive to an outlier in the other direction: a single file reaching past the cutoff lifts the day over the 23h threshold and the day reads whole. 23 September recorded 19.9 hours and 8 September recorded 24 exactly that way, and the window count came out at 12 short days where the median finds 18. So the reduction is now the median over builders with at least 100 fills that day, which leaves 29 to 53 samples a day in the archive. It is robust in both directions: a few quiet builders cannot drag it down, and one straggler cannot lift it. Under 8 samples it falls back to the max, which can only call a short day whole and never the reverse. The disclosure note reads the gauge, so it corrects itself. The figures it sits above do not change: they were always the sum of what the feed published. Co-authored-by: Claude Opus 5 (1M context) --- .../hyperliquid-frontends/cmd/script/agg.go | 103 +++++++++++++----- .../cmd/script/coverage_test.go | 62 ++++++++++- .../cmd/script/metrics.go | 2 +- .../hyperliquid-frontends/cmd/script/state.go | 2 +- src/components/hl-feed-coverage-note.tsx | 18 +-- src/lib/hl-feed.ts | 14 +-- 6 files changed, 154 insertions(+), 47 deletions(-) diff --git a/harnesses/hyperliquid-frontends/cmd/script/agg.go b/harnesses/hyperliquid-frontends/cmd/script/agg.go index 43d25f877..b88eb171a 100644 --- a/harnesses/hyperliquid-frontends/cmd/script/agg.go +++ b/harnesses/hyperliquid-frontends/cmd/script/agg.go @@ -372,9 +372,10 @@ func (a *Aggregator) publish() { } } - // day key -> the latest fill second seen across every builder. Used to - // tell a short day from a truncated feed; see publishCoverage. - cohortLastFill := map[string]int64{} + // day key -> one latest-fill second per builder that traded materially + // that day. The median of these is the day's coverage; see publishCoverage + // for why the maximum was the wrong statistic. + cohortLastFill := map[string][]int64{} rows := make(map[string]builderWindow, len(a.builders)) var cohortVol float64 @@ -397,11 +398,12 @@ func (a *Aggregator) publish() { continue } s := a.merged(b, d) - if s != nil && s.lastFillS > cohortLastFill[dayKey(d)] { - // Furthest fill any builder saw that day. One builder going - // quiet in the evening proves nothing; the whole cohort going - // quiet at the same minute is the feed being cut short. - cohortLastFill[dayKey(d)] = s.lastFillS + if s != nil && s.fills >= coverageMinFills { + // One sample per builder with enough activity that a fill in + // the evening would be expected. A builder with a handful of + // fills can legitimately finish at midday, which is why the + // small ones are left out rather than dragging the day down. + cohortLastFill[dayKey(d)] = append(cohortLastFill[dayKey(d)], s.lastFillS) } if i == 0 { w.day = s @@ -875,26 +877,73 @@ func dayCoverageHours(day time.Time, lastFillS int64) float64 { return h } +// coverageMinFills is the activity a builder needs on a day before its last +// fill says anything about the feed's horizon. Measured against the archive, +// filtering at roughly this level leaves 29 to 53 builders a day, which is +// plenty for a median and drops the near-empty files whose last fill is just +// where that builder stopped. +const coverageMinFills = 100 + +// coverageSamplesMin is how many qualifying builders a day needs before the +// median is trusted. Below it the day falls back to the furthest fill, which +// is the conservative reading: it can only call a short day whole. +const coverageSamplesMin = 8 + +// cohortLastFillSecond reduces a day's per-builder last-fill seconds to the +// one number that describes the feed's horizon: the median. +// +// It was the maximum until 2026-10-07, and the maximum is wrong in a way that +// cost the site half its Hyperliquid figures silently. The reasoning for it +// was sound as far as it went ("one builder going quiet in the evening proves +// nothing"), but a maximum is maximally sensitive to a single outlier in the +// other direction: one builder with a late fill makes a cohort-wide cutoff +// read as a whole day. +// +// Measured across the archive, the distribution leaves no room for doubt. On +// 6 October every qualifying builder's file ends between 11:58 and 12:09; on +// 23 September between 12:01 and 12:13; on 21 September between 23:31 and +// 23:59. The feed cuts the day at one instant for the whole cohort, so the +// cluster is tight and the median sits inside it. Yet 23 September recorded +// 19.9 hours and 8 September recorded 24, because one file in each reached +// past the cutoff. The max-based count reported 12 short days in the 30 ending +// 6 October where the median finds 18. +func cohortLastFillSecond(day time.Time, samples []int64) (int64, bool) { + if len(samples) == 0 { + return 0, false + } + if len(samples) < coverageSamplesMin { + var mx int64 + for _, v := range samples { + if v > mx { + mx = v + } + } + return mx, true + } + sorted := make([]int64, len(samples)) + copy(sorted, samples) + sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) + return sorted[len(sorted)/2], true +} + // coverageCompleteHours is the point past which a day counts as whole. // // Hyperliquid's per-builder export cuts days off at roughly 12:10 UTC, and it -// does so intermittently rather than from one clean break: measured across -// the archive on 2026-10-07, 15, 16 and 18 September were already short while -// 17, 19, 20 and 21 September ran to 23:5x, and 3 October ran to 23:58:44 in -// the middle of an otherwise unbroken run of short days from 22 September. -// Seventeen of the thirty days ending 6 October were short. The file is -// stable across re-downloads, so this is the published artefact and not a -// partial fetch. +// does so intermittently rather than from one clean break. Measured across the +// archive on 2026-10-07 with the cohort median, 18 of the 30 days ending 6 +// October were short: 8, 15, 16 and 18 September, then every day from 22 +// September bar 3 October. 17, 19, 20, 21 September and 3 October ran to +// 23:5x. The files are stable across re-downloads, so this is the published +// artefact and not a partial fetch. // // Summing such a day beside a whole one understates it by about half and says -// nothing, which is the shape of error this harness exists to avoid. 23 -// leaves an hour of slack for a genuinely quiet late evening across the -// entire cohort, which has never happened in the mirror. +// nothing, which is the shape of error this harness exists to avoid. On the +// whole days, the hours before 13:00 UTC carry about 43% of the notional and +// 44% of the fees, so a short day lands at well under half its real size. // -// Note the threshold is applied to the cohort's furthest fill, never to one -// builder's. A single builder's file ending at midday usually means that -// builder stopped trading: fomo's own file ends at 12:xx on 8 September, a -// day the cohort carried to 23:59:51. +// 23 leaves an hour of slack for a genuinely quiet late evening across the +// entire cohort, which has never happened in the mirror: a whole day's median +// last fill sits at 23:48 or later every time. const coverageCompleteHours = 23 // publishCoverage reports how much of each day the feed actually carried and @@ -905,18 +954,22 @@ const coverageCompleteHours = 23 // shorten. The returned set exists for the one figure where a short day is not // an understatement but a wrong answer, the biggest day on record, which // compares days against each other rather than summing them. -func (a *Aggregator) publishCoverage(D, windowStart time.Time, lastFill map[string]int64) map[string]bool { +func (a *Aggregator) publishCoverage(D, windowStart time.Time, lastFill map[string][]int64) map[string]bool { // Record every day we could measure this pass, so the count below can // still see it once the fetch range moves past it and the files are gone. - for k, ls := range lastFill { + for k, samples := range lastFill { d, err := time.Parse(dayFormat, k) if err != nil { continue } + ls, ok := cohortLastFillSecond(d, samples) + if !ok { + continue + } a.state.setDayCoverage(k, dayCoverageHours(d, ls)) } - cov := dayCoverageHours(D, lastFill[dayKey(D)]) + cov, _ := a.state.dayCoverage(dayKey(D)) hlDayCoverageHours.Set(cov) if cov < coverageCompleteHours { hlDayTruncated.Set(1) diff --git a/harnesses/hyperliquid-frontends/cmd/script/coverage_test.go b/harnesses/hyperliquid-frontends/cmd/script/coverage_test.go index 6d848b3bb..b017993fe 100644 --- a/harnesses/hyperliquid-frontends/cmd/script/coverage_test.go +++ b/harnesses/hyperliquid-frontends/cmd/script/coverage_test.go @@ -170,11 +170,13 @@ func TestCoverageCountSpansTheWholeWindow(t *testing.T) { } a := &Aggregator{state: st} - // This pass can only reach the last few days. - lastFill := map[string]int64{ - dayKey(utc(2026, 10, 4)): at(utc(2026, 10, 4), 12, 4, 17), - dayKey(utc(2026, 10, 5)): at(utc(2026, 10, 5), 11, 59, 53), - dayKey(D): at(D, 12, 6, 23), + // This pass can only reach the last few days. Each day carries one sample + // per qualifying builder; under coverageSamplesMin the reduction falls + // back to the furthest fill, which is what these single samples get. + lastFill := map[string][]int64{ + dayKey(utc(2026, 10, 4)): {at(utc(2026, 10, 4), 12, 4, 17)}, + dayKey(utc(2026, 10, 5)): {at(utc(2026, 10, 5), 11, 59, 53)}, + dayKey(D): {at(D, 12, 6, 23)}, } short := a.publishCoverage(D, windowStart, lastFill) @@ -198,3 +200,53 @@ func TestCoverageCountSpansTheWholeWindow(t *testing.T) { t.Fatalf("coverage for the feed day not recorded: %v %v", h, ok) } } + +// TestCohortLastFillIsTheMedian is the bug that made the first version of the +// coverage disclosure understate itself by a third. +// +// The feed cuts a day at one instant for the whole cohort, so the per-builder +// last fills cluster inside a few minutes: on 6 October 2026 every qualifying +// builder's file ended between 11:58 and 12:09. A single file reaching past +// that cutoff then lifted the day's recorded coverage over the 23h threshold +// and the day read whole. 23 September recorded 19.9 hours and 8 September +// recorded 24 that way, and the window count came out at 12 short days where +// the median finds 18. +func TestCohortLastFillIsTheMedian(t *testing.T) { + D := utc(2026, 10, 6) + + // The real shape: a tight cluster at the cutoff, plus one straggler. + cluster := []int64{} + for i := 0; i < 20; i++ { + cluster = append(cluster, at(D, 12, 8, 30+i)) + } + withOutlier := append(append([]int64{}, cluster...), at(D, 23, 59, 51)) + + ls, ok := cohortLastFillSecond(D, withOutlier) + if !ok { + t.Fatal("no reading from a populated day") + } + if h := dayCoverageHours(D, ls); h >= coverageCompleteHours { + t.Fatalf("one late file made a cut day read whole: %.2fh", h) + } + + // A genuinely whole day still reads whole. + whole := []int64{} + for i := 0; i < 20; i++ { + whole = append(whole, at(D, 23, 50+i/10, i)) + } + ls, _ = cohortLastFillSecond(D, whole) + if h := dayCoverageHours(D, ls); h < coverageCompleteHours { + t.Fatalf("a whole day read short: %.2fh", h) + } + + // Too few samples to trust a median: fall back to the furthest fill, + // which can only call a short day whole and never the reverse. + ls, _ = cohortLastFillSecond(D, []int64{at(D, 12, 8, 0), at(D, 23, 59, 0)}) + if h := dayCoverageHours(D, ls); h < coverageCompleteHours { + t.Fatalf("thin sample should fall back to the max, got %.2fh", h) + } + + if _, ok := cohortLastFillSecond(D, nil); ok { + t.Fatal("a day with no samples must report nothing, not zero coverage") + } +} diff --git a/harnesses/hyperliquid-frontends/cmd/script/metrics.go b/harnesses/hyperliquid-frontends/cmd/script/metrics.go index e0e09cef4..ecba730db 100644 --- a/harnesses/hyperliquid-frontends/cmd/script/metrics.go +++ b/harnesses/hyperliquid-frontends/cmd/script/metrics.go @@ -39,7 +39,7 @@ var ( hlDayCoverageHours = promauto.NewGauge(prometheus.GaugeOpts{ Name: "hl_frontend_day_coverage_hours_v2", - Help: "How far into the headline feed day the cohort's furthest fill reached, in hours. A whole day lands within minutes of 24. Hyperliquid's per-builder export has stopped at roughly 12:11 UTC every day since 2026-09-22, so a reading near 12 means the published file ends before the day does and the 24h figures describe half a day.", + Help: "How far into the headline feed day the cohort's median last fill reached, in hours. A whole day lands at 23:48 or later. Hyperliquid's per-builder export cuts days off at roughly 12:10 UTC on most days since 2026-09-22, so a reading near 12 means the published file ends before the day does and the 24h figures describe half a day.", }) hlDayTruncated = promauto.NewGauge(prometheus.GaugeOpts{ Name: "hl_frontend_day_truncated_v2", diff --git a/harnesses/hyperliquid-frontends/cmd/script/state.go b/harnesses/hyperliquid-frontends/cmd/script/state.go index 71c317806..aba7ebba3 100644 --- a/harnesses/hyperliquid-frontends/cmd/script/state.go +++ b/harnesses/hyperliquid-frontends/cmd/script/state.go @@ -39,7 +39,7 @@ type State struct { // mirror window and kept afterwards, because the files it was measured // from are deleted once the fetch range moves on: without this the // truncated-day count could only look at the mirror window and reported - // 12 of 30 where the real figure was 17. + // 12 of 30 where the real figure was 18. DayCoverage map[string]float64 `json:"day_coverage"` mu sync.Mutex diff --git a/src/components/hl-feed-coverage-note.tsx b/src/components/hl-feed-coverage-note.tsx index 2483f826b..05384b661 100644 --- a/src/components/hl-feed-coverage-note.tsx +++ b/src/components/hl-feed-coverage-note.tsx @@ -6,11 +6,12 @@ import type { HlFeedCoverage } from "@/lib/hl-feed"; * carried, whenever that is less than all of it. * * Every figure on these pages is a sum over UTC days read from that export. - * It stopped publishing whole days on 2026-09-22: most days since end at - * roughly 12:00 UTC, and on the days it did carry in full, the hours before - * 13:00 hold about 43% of the notional and 44% of the fees. So a 30d total - * built from a window half made of those days lands near half of what a - * tracker reading a complete feed reports, and nothing on the page said so. + * It cuts days off at roughly 12:10 UTC, intermittently since 2026-09-08 and + * on all but one day since 2026-09-22, and on the days it did carry in full, + * the hours before 13:00 hold about 43% of the notional and 44% of the fees. + * So a 30d total built from a window more than half made of those days lands + * near half of what a tracker reading a complete feed reports, and nothing on + * the page said so. * * The figures stay as measured rather than scaled up to a guess. What changes * is that the reader is told what the denominator is, which is the difference @@ -65,9 +66,10 @@ export function HlFeedCoverageNote({

Coverage of Hyperliquid's public per-builder export is not being measured on this run, so how much of each UTC day the figures below - cover is unknown. The export has been publishing days cut off around - 12:00 UTC since 2026-09-22, which halves a window sum, so treat the - figures as a floor until coverage reports again. + cover is unknown. The export has been publishing days cut off + around 12:10 UTC on all but one day since 2026-09-22, which halves a + window sum, so treat the figures as a floor until coverage reports + again.

)} diff --git a/src/lib/hl-feed.ts b/src/lib/hl-feed.ts index c61b078db..6624c0e95 100644 --- a/src/lib/hl-feed.ts +++ b/src/lib/hl-feed.ts @@ -28,12 +28,12 @@ export const HL_WINDOW_LABEL: Record = { * across the whole cohort. * * This matters because every window figure is a sum over days. The export - * stopped publishing whole days on 2026-09-22 and most days since end at - * roughly 12:00 UTC, while on the complete days the hours before 13:00 carry - * only about 43% of the notional and 44% of the fees. A 30d total built from - * such a window is the right arithmetic on an incomplete feed and reads about - * half of what a tracker on a complete feed reports. Publishing the figure - * without the coverage beside it is the part that misleads. + * cuts days off at roughly 12:10 UTC, intermittently since 2026-09-08 and on + * all but one day since 2026-09-22, while on the whole days the hours before + * 13:00 carry only about 43% of the notional and 44% of the fees. A 30d total + * built from such a window is the right arithmetic on an incomplete feed and + * reads about half of what a tracker on a complete feed reports. Publishing + * the figure without the coverage beside it is the part that misleads. */ export type HlFeedCoverage = { /** Hours of the latest feed day the export carried. 24 = whole day. */ @@ -47,7 +47,7 @@ export type HlFeedCoverage = { * `windowDays`: a day that left the harness's mirror window before it * first ran was never measured, and treating it as clean is how the first * version of this disclosure came to report 12 short days where the - * archive held 17. */ + * archive held 18. */ daysMeasured: number; /** Days the window spans, for context when it differs from daysMeasured. */ windowDays: number;