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 (
-

- {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..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 && ( + + )}
@@ -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..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; +}