Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,35 @@ The API has a background page cache (`api/handlers/page_cache.go`) that pre-comp

Add caching when a page runs expensive queries, has a common default view, and 30–60s staleness is acceptable. See publisher check or edge scoreboard handlers for reference implementations.

## Hyperliquid Scoreboard Feed Config

The feeds shown on the Hyperliquid scoreboard are **not** in code — they live in the
Postgres table `hyperliquid_scoreboard_entry` (`feed`, `label`, `display_order`, `enabled`).
Only enabled rows are raced, counted, or displayed; a feed with no row never appears.

Add, remove, or reorder a feed by changing rows — no code change, no deploy.

**These statements are run by a human operator against the target environment.** They are
recorded here as a runbook, not as something to execute automatically: do not run them, or
any other write against this table, from an agent session unless explicitly asked to.

```sql
-- stop showing a feed
UPDATE hyperliquid_scoreboard_entry SET enabled = FALSE, updated_at = NOW() WHERE feed = '<feed>';
-- start showing a feed
INSERT INTO hyperliquid_scoreboard_entry (feed, label, display_order, enabled)
VALUES ('<feed>', '<label>', <n>, TRUE)
ON CONFLICT (feed) DO UPDATE SET enabled = TRUE, label = EXCLUDED.label,
display_order = EXCLUDED.display_order, updated_at = NOW();
```

Changes take effect on the next cache refresh with no restart: about 60s for the 1h view
(page-cache worker) and about 10min for the 24h/7d views (background refresher).

The migration creates the table **empty on purpose** — rows are environment config and are
inserted out of band, so they never live in this repository. An unseeded environment renders
an empty scoreboard, which is also the expected local-dev state.

## Logging Levels

ERROR-level log lines page on-call (alerts fire on `level="ERR"` — prod → `#alerts`, staging → `#alerts-l2`). Reserve raw `.Error(...)` calls for genuinely-actionable terminal failures: process/component death, startup failures, panics, config errors.
Expand Down
14 changes: 14 additions & 0 deletions api/config/migrations/00016_add_hyperliquid_scoreboard_entry.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- +goose Up
-- Scoreboard feed allow-list. Only enabled rows are raced, counted, or displayed on the
-- Hyperliquid scoreboard. Intentionally seeded with no rows: the rows are environment
-- config, inserted out of band, so they never live in this repository.
CREATE TABLE hyperliquid_scoreboard_entry (
feed TEXT PRIMARY KEY,
label TEXT NOT NULL CHECK (length(label) BETWEEN 1 AND 64),
display_order INT NOT NULL DEFAULT 0,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated_at freshness relies on operators remembering updated_at = NOW() in the runbook UPDATE. The repo's update_updated_at_column() trigger function (migrations 00001/00003) is attached to sibling tables for this; attach it here so a hand-typed UPDATE can't leave a stale timestamp.

);

-- +goose Down
DROP TABLE IF EXISTS hyperliquid_scoreboard_entry;
187 changes: 130 additions & 57 deletions api/handlers/hyperliquid_scoreboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,35 +13,105 @@ import (
"golang.org/x/sync/errgroup"
)

// hyperliquidCompetitors lists the non-DoubleZero feeds shown on the scoreboard,
// in display order, mapping each raw feed name to its label.
var hyperliquidCompetitors = []struct{ Feed, Label string }{
{"hyperliquid_public_bbo", "Public API"},
{"hydromancer_bbo", "Hydromancer"},
{"dwellir_l2book_bbo", "Dwellir"},
{"quicknode_l2book_bbo", "QuickNode"},
// hyperliquidFeedRe bounds feed ids to characters safe to inline into ClickHouse SQL.
// Config rows are operator-managed rather than user input, but the values are concatenated
// into queries, so anything outside this set is dropped rather than trusted.
var hyperliquidFeedRe = regexp.MustCompile(`^[A-Za-z0-9:_.-]{1,64}$`)

// hyperliquidEntry is one configured scoreboard feed.
type hyperliquidEntry struct{ Feed, Label string }

// hyperliquidEntries is the scoreboard's configured feed allow-list, loaded from Postgres.
// Only feeds present here are raced, counted, or displayed, so a feed is added, removed, or
// reordered by changing rows — no code change and no deploy.
type hyperliquidEntries struct {
ordered []hyperliquidEntry // display order
labels map[string]string // feed -> label
}

// hyperliquidExcludedFeeds are competitor feeds withheld from every scoreboard measurement
// (per-competitor, per-node, headline totals, and recent races) because their upstream data is
// currently unreliable. hyperpc_shared_bbo (HypeRPC) arrives a median ~100s stale during
// recurring multi-hour backlog episodes — vs ~300ms for the other feeds — which would render
// DoubleZero "winning" by seconds-to-minutes and is a HypeRPC-side feed fault, not a real result.
// Remove entries here (and re-add to hyperliquidCompetitors) once the feed is fixed.
var hyperliquidExcludedFeeds = []string{"hyperpc_shared_bbo"}

// hyperliquidExcludedFeedsClause returns a SQL predicate dropping excluded feeds from either
// side of a race, or "" if none are excluded.
func hyperliquidExcludedFeedsClause() string {
if len(hyperliquidExcludedFeeds) == 0 {
// empty reports whether any feed is configured.
func (e hyperliquidEntries) empty() bool { return len(e.ordered) == 0 }

// inClause returns the SQL predicate restricting a race to configured feeds, or "" if none
// are configured. Every race pairs one tob_* DoubleZero feed with one competing feed, and DZ
// feeds are never configured entries, so requiring either side to be in the set is equivalent
// to requiring the non-DZ side to be configured.
func (e hyperliquidEntries) inClause() string {
if e.empty() {
return ""
}
quoted := make([]string, len(hyperliquidExcludedFeeds))
for i, f := range hyperliquidExcludedFeeds {
quoted[i] = "'" + f + "'"
quoted := make([]string, 0, len(e.ordered))
for _, en := range e.ordered {
quoted = append(quoted, "'"+en.Feed+"'")
}
in := strings.Join(quoted, ", ")
return fmt.Sprintf("AND feed NOT IN (%[1]s) AND loser_feed NOT IN (%[1]s)", in)
return fmt.Sprintf("AND (feed IN (%[1]s) OR loser_feed IN (%[1]s))", in)
}

// label maps a raw feed to its configured display label (falls back to the raw name).
func (e hyperliquidEntries) label(feed string) string {
if l, ok := e.labels[feed]; ok {
return l
}
return feed
}

// display returns "DoubleZero" for any tob_ DZ feed, else the configured label. Used so a
// competitor-won recent race reads "<Label> … vs DoubleZero", not a raw tob_ id.
func (e hyperliquidEntries) display(feed string) string {
if strings.HasPrefix(feed, "tob_") {
return "DoubleZero"
}
return e.label(feed)
}

// loadHyperliquidScoreboardEntries reads the enabled scoreboard feeds from Postgres, in
// display order. Zero configured rows is not an error — it is the deliberate "nothing
// configured yet" state and returns a clean empty set with a nil error. A genuine load
// failure (query, scan, or row iteration) returns a non-nil error instead of degrading to an
// empty set: the caller must not treat a Postgres blip as "zero feeds configured", or the
// background refresher and page-cache worker would overwrite the last-good cached payload
// with an empty one. Logged at WARN, never ERROR — ERROR pages on-call.
func (a *API) loadHyperliquidScoreboardEntries(ctx context.Context) (hyperliquidEntries, error) {
e := hyperliquidEntries{labels: map[string]string{}}
if a.PgPool == nil {
return e, nil
}
rows, err := a.PgPool.Query(ctx, `
SELECT feed, label FROM hyperliquid_scoreboard_entry
WHERE enabled ORDER BY display_order, feed`)
if err != nil {
slog.Warn("hyperliquid scoreboard entry load failed", "error", err)
return hyperliquidEntries{}, err
}
defer rows.Close()
for rows.Next() {
var feed, label string
if err := rows.Scan(&feed, &label); err != nil {
slog.Warn("hyperliquid scoreboard entry scan failed", "error", err)
return hyperliquidEntries{}, err
}
// A malformed feed would be inlined into SQL; drop it and keep serving the rest.
if !hyperliquidFeedRe.MatchString(feed) {
slog.Warn("hyperliquid scoreboard entry skipped: unsafe feed id", "feed", feed)
continue
}
// tob_ feeds are DoubleZero's own; the allow-list clause relies on them never being
// configured entries (see inClause). A tob_ config row would broaden the clause to
// match races against unconfigured competitors, leaking their raw feed ids into the
// public payload, so it is dropped rather than trusted.
if strings.HasPrefix(feed, "tob_") {
slog.Warn("hyperliquid scoreboard entry skipped: tob_ feed not allowed", "feed", feed)
continue
}
e.ordered = append(e.ordered, hyperliquidEntry{Feed: feed, Label: label})
e.labels[feed] = label
}
if err := rows.Err(); err != nil {
slog.Warn("hyperliquid scoreboard entry iteration failed", "error", err)
return hyperliquidEntries{}, err
}
return e, nil
}

// hyperliquidWindows maps window params to ClickHouse interval expressions.
Expand Down Expand Up @@ -176,23 +246,19 @@ type HyperliquidCompositeLatency struct {

const hyperliquidCompositeLatencyCacheKey = "hyperliquid_composite_latency"

// labelForFeed maps a raw competitor feed to its display label (falls back to the raw name).
func labelForFeed(feed string) string {
for _, c := range hyperliquidCompetitors {
if c.Feed == feed {
return c.Label
}
}
return feed
}

// hyperliquidFeedDisplay returns "DoubleZero" for any tob_ DZ feed, else the competitor label.
// Used so a competitor-won recent race reads "Hydromancer … vs DoubleZero", not a raw tob_ id.
func hyperliquidFeedDisplay(feed string) string {
if strings.HasPrefix(feed, "tob_") {
return "DoubleZero"
// emptyHyperliquidScoreboard is the empty-but-valid response served when the scoreboard has
// nothing to compute — the proxied summary table is absent (e.g. local dev) or no feeds are
// configured. Returning this instead of an error keeps the page-cache refresher caching a
// clean payload rather than logging every cycle.
func emptyHyperliquidScoreboard(window string) *HyperliquidScoreboardResponse {
return &HyperliquidScoreboardResponse{
Window: window,
GeneratedAt: time.Now().UTC(),
FeedType: "bbo",
Competitors: []HyperliquidCompetitor{},
Nodes: []HyperliquidNode{},
RecentRaces: []HyperliquidRace{},
}
return labelForFeed(feed)
}

// FetchHyperliquidScoreboardData computes the aggregated scoreboard for a window and
Expand All @@ -209,23 +275,30 @@ func (a *API) FetchHyperliquidScoreboardData(ctx context.Context, window, symbol
// proxy/seed), return an empty-but-valid response so the page-cache refresher
// caches a clean empty payload instead of logging an error every cycle.
if !a.hyperliquidFeedsTableExists(ctx) {
return &HyperliquidScoreboardResponse{
Window: window,
GeneratedAt: time.Now().UTC(),
FeedType: "bbo",
Competitors: []HyperliquidCompetitor{},
Nodes: []HyperliquidNode{},
RecentRaces: []HyperliquidRace{},
}, nil
return emptyHyperliquidScoreboard(window), nil
}

// The configured feed allow-list drives which feeds are raced, counted, and labelled.
// A load failure propagates as an error so the caller (page-cache refresher, request
// handler) does not mistake it for "nothing configured" and overwrite a last-good cached
// payload with an empty one. Zero configured rows is not an error — with nothing
// configured there is no scoreboard to compute, so serve the empty-but-valid payload
// rather than scanning for feeds we would then discard.
entries, err := a.loadHyperliquidScoreboardEntries(ctx)
if err != nil {
return nil, err

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The live request path now hard-fails during a Postgres outage where it previously served. Before this PR a Pg outage made readPageCache miss and the handler still served the scoreboard live from ClickHouse (config was in code); now the live path dies here with a 500 — and there is no cached fallback either, because the page cache also lives in Postgres. Other pages degrade to live-serving during a Pg outage; this one now 500s. The propagate-don't-blank choice is right for the refreshers, but if scoreboard uptime through Pg maintenance matters, consider a process-local last-good copy of hyperliquidEntries (e.g. an atomic.Pointer on API, updated on every successful load) as a fallback when the load fails.

}
if entries.empty() {
return emptyHyperliquidScoreboard(window), nil
}

symbolFilter := hyperliquidLiquidSymbolFilter()
if symbol != "" {
symbolFilter = fmt.Sprintf("AND symbol = '%s'", symbol)
}
// Drop excluded (unreliable) competitor feeds from the per-competitor and per-node
// aggregations. Flows into both compQ and nodeQ via the shared symbolFilter slot.
symbolFilter = strings.TrimSpace(symbolFilter + " " + hyperliquidExcludedFeedsClause())
// Restrict both aggregations to configured feeds. Flows into the scan via the shared
// symbolFilter slot.
symbolFilter = strings.TrimSpace(symbolFilter + " " + entries.inClause())
db := fmt.Sprintf("`%s`", a.FeedsDB)

resp := &HyperliquidScoreboardResponse{
Expand Down Expand Up @@ -318,7 +391,7 @@ func (a *API) FetchHyperliquidScoreboardData(ctx context.Context, window, symbol
return rows.Err()
})
g.Go(func() error {
r, err := a.fetchHyperliquidRecentRaces(gctx, time.Time{}, 10)
r, err := a.fetchHyperliquidRecentRaces(gctx, time.Time{}, 10, entries)
if err != nil {
return err
}
Expand All @@ -337,7 +410,7 @@ func (a *API) FetchHyperliquidScoreboardData(ctx context.Context, window, symbol
}

// Emit competitors in configured order.
for _, c := range hyperliquidCompetitors {
for _, c := range entries.ordered {
s, ok := byFeed[c.Feed]
if !ok {
continue
Expand Down Expand Up @@ -366,7 +439,7 @@ func (a *API) FetchHyperliquidScoreboardData(ctx context.Context, window, symbol
Competitors: []HyperliquidCompetitor{},
}
var wins, races uint64
for _, c := range hyperliquidCompetitors {
for _, c := range entries.ordered {
s, ok := na.byFeed[c.Feed]
if !ok {
continue
Expand Down Expand Up @@ -419,7 +492,7 @@ func (a *API) FetchHyperliquidScoreboardData(ctx context.Context, window, symbol

// fetchHyperliquidRecentRaces returns the most recent races (one row per race,
// winner + closest competitor + lead). sinceTs zero -> last 5 minutes.
func (a *API) fetchHyperliquidRecentRaces(ctx context.Context, sinceTs time.Time, perSymbol int) ([]HyperliquidRace, error) {
func (a *API) fetchHyperliquidRecentRaces(ctx context.Context, sinceTs time.Time, perSymbol int, entries hyperliquidEntries) ([]HyperliquidRace, error) {
if perSymbol <= 0 || perSymbol > 50 {
perSymbol = 10
}
Expand Down Expand Up @@ -447,7 +520,7 @@ func (a *API) fetchHyperliquidRecentRaces(ctx context.Context, sinceTs time.Time
GROUP BY capture_run_id, measurement_node_id, symbol, source_ts_ms, bbo_hash, location_code, feed
ORDER BY max_event_ts DESC
LIMIT %d BY symbol`, db,
strings.TrimSpace(hyperliquidRecentRaceSymbolFilter()+" "+hyperliquidExcludedFeedsClause()), timeFilter, perSymbol)
strings.TrimSpace(hyperliquidRecentRaceSymbolFilter()+" "+entries.inClause()), timeFilter, perSymbol)
rows, err := a.envDB(ctx).Query(ctx, q)
if err != nil {
return nil, err
Expand All @@ -461,8 +534,8 @@ func (a *API) fetchHyperliquidRecentRaces(ctx context.Context, sinceTs time.Time
return nil, err
}
r.IsDZ = isDZ == 1
r.WinnerLabel = hyperliquidFeedDisplay(r.WinnerFeed)
r.RunnerUpLabel = hyperliquidFeedDisplay(r.RunnerUpFeed)
r.WinnerLabel = entries.display(r.WinnerFeed)
r.RunnerUpLabel = entries.display(r.RunnerUpFeed)
out = append(out, r)
}
return out, rows.Err()
Expand Down
47 changes: 47 additions & 0 deletions api/handlers/hyperliquid_scoreboard_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package handlers

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestHyperliquidEntries_InClause(t *testing.T) {
var empty hyperliquidEntries
assert.Equal(t, "", empty.inClause(), "no configured feeds must produce no predicate")

e := hyperliquidEntries{ordered: []hyperliquidEntry{
{Feed: "feed_a_bbo", Label: "Feed A"},
{Feed: "feed_b_bbo", Label: "Feed B"},
}}
assert.Equal(t,
"AND (feed IN ('feed_a_bbo', 'feed_b_bbo') OR loser_feed IN ('feed_a_bbo', 'feed_b_bbo'))",
e.inClause())
}

func TestHyperliquidEntries_Display(t *testing.T) {
e := hyperliquidEntries{labels: map[string]string{"feed_a_bbo": "Feed A"}}

// Any tob_ feed is DoubleZero regardless of config.
assert.Equal(t, "DoubleZero", e.display("tob_gcp_tyo_hl_mainnet1"))
assert.Equal(t, "Feed A", e.display("feed_a_bbo"))
// Unknown feeds fall back to the raw name.
assert.Equal(t, "feed_z_bbo", e.display("feed_z_bbo"))
}

func TestHyperliquidEntries_Empty(t *testing.T) {
var e hyperliquidEntries
assert.True(t, e.empty())

e.ordered = []hyperliquidEntry{{Feed: "feed_a_bbo", Label: "Feed A"}}
assert.False(t, e.empty())
}

func TestHyperliquidFeedRe(t *testing.T) {
assert.True(t, hyperliquidFeedRe.MatchString("feed_a_bbo"))
assert.True(t, hyperliquidFeedRe.MatchString("tob_gcp_tyo_hl_mainnet1"))
// Anything that could break out of a quoted SQL literal must be rejected.
assert.False(t, hyperliquidFeedRe.MatchString("bad'; DROP TABLE x --"))
assert.False(t, hyperliquidFeedRe.MatchString(""))
assert.False(t, hyperliquidFeedRe.MatchString("has space"))
}
Loading
Loading