diff --git a/harnesses/hyperliquid-frontends/cmd/script/agg.go b/harnesses/hyperliquid-frontends/cmd/script/agg.go
index c90173691..b88eb171a 100644
--- a/harnesses/hyperliquid-frontends/cmd/script/agg.go
+++ b/harnesses/hyperliquid-frontends/cmd/script/agg.go
@@ -372,6 +372,11 @@ func (a *Aggregator) publish() {
}
}
+ // 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
for _, b := range a.builders {
@@ -393,6 +398,13 @@ func (a *Aggregator) publish() {
continue
}
s := a.merged(b, d)
+ 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
w.dayKnown = s != nil || a.allAbsent(b, d)
@@ -475,14 +487,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))
+ 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.
@@ -646,7 +664,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
@@ -658,7 +688,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
}
@@ -829,3 +859,142 @@ 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
+}
+
+// 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 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. 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.
+//
+// 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
+// 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, 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, _ := a.state.dayCoverage(dayKey(D))
+ hlDayCoverageHours.Set(cov)
+ if cov < coverageCompleteHours {
+ hlDayTruncated.Set(1)
+ } else {
+ hlDayTruncated.Set(0)
+ }
+
+ short := map[string]bool{}
+ measured := 0
+ for d := windowStart; !d.After(D); d = d.AddDate(0, 0, 1) {
+ k := dayKey(d)
+ h, ok := a.state.dayCoverage(k)
+ if !ok {
+ // 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
+ }
+ measured++
+ if h < coverageCompleteHours {
+ short[k] = true
+ }
+ }
+ 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
new file mode 100644
index 000000000..b017993fe
--- /dev/null
+++ b/harnesses/hyperliquid-frontends/cmd/script/coverage_test.go
@@ -0,0 +1,252 @@
+package main
+
+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)
+}
+
+// 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")
+ }
+}
+
+// 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. 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)
+
+ // 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)
+ }
+}
+
+// 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 302bd71ec..ecba730db 100644
--- a/harnesses/hyperliquid-frontends/cmd/script/metrics.go
+++ b/harnesses/hyperliquid-frontends/cmd/script/metrics.go
@@ -37,6 +37,23 @@ 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 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",
+ 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.",
+ })
+ 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",
Help: "UTC midnight (unix seconds) that opens the feed day the 24h gauges describe.",
diff --git a/harnesses/hyperliquid-frontends/cmd/script/state.go b/harnesses/hyperliquid-frontends/cmd/script/state.go
index c17aac3aa..aba7ebba3 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 18.
+ 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 (
diff --git a/src/components/hl-feed-coverage-note.tsx b/src/components/hl-feed-coverage-note.tsx
new file mode 100644
index 000000000..05384b661
--- /dev/null
+++ b/src/components/hl-feed-coverage-note.tsx
@@ -0,0 +1,77 @@
+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 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
+ * 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 && (
+
+ )}
+ >
) : 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..6624c0e95
--- /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
+ * 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. */
+ 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 18. */
+ 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;
+}