Files
worldmonitor/scripts/seed-hyperliquid-flow.mjs
Elie Habib e32d9b631c feat(market): Hyperliquid perp positioning flow as leading indicator (#3074)
* feat(market): Hyperliquid perp positioning flow as leading indicator

Adds a 4-component composite (funding × volume × OI × basis) "positioning
stress" score for ~14 perps spanning crypto (BTC/ETH/SOL), tokenized gold
(PAXG), commodity perps (WTI, Brent, Gold, Silver, Pt, Pd, Cu, NatGas), and
FX perps (EUR, JPY). Polls Hyperliquid /info every 5min via Railway cron;
publishes a single self-contained snapshot with embedded sparkline arrays
(60 samples = 5h history). Surfaces as a new "Perp Flow" tab in
CommoditiesPanel with separate Commodities / FX sections.

Why: existing CFTC COT is weekly + US-centric; market quotes are price-only.
Hyperliquid xyz: perps give 24/7 global positioning data that has been shown
to lead spot moves on commodities and FX by minutes-to-hours.

Implementation:
- scripts/seed-hyperliquid-flow.mjs — pure scoring math, symbol whitelist,
  content-type + schema validation, prior-state read via readSeedSnapshot(),
  warmup contract (first run / post-outage zeroes vol/OI deltas),
  missing-symbol carry-forward, $500k/24h min-notional guard to suppress
  thin xyz: noise. TTL 2700s (9× cadence).
- proto/worldmonitor/market/v1/get_hyperliquid_flow.proto + service.proto
  registration; make generate regenerated client/server bindings.
- server/worldmonitor/market/v1/get-hyperliquid-flow.ts — getCachedJson
  reader matching get-cot-positioning.ts seeded-handler pattern.
- server/gateway.ts cache-tier entry (medium).
- api/health.js: hyperliquidFlow registered with maxStaleMin:15 (3× cadence)
  + transitional ON_DEMAND_KEYS gate for the first ~7 days of bake-in.
- api/seed-health.js mirror with intervalMin:5.
- scripts/seed-bundle-market-backup.mjs entry (NIXPACKS auto-redeploy on
  scripts/** watch).
- src/components/MarketPanel.ts: CommoditiesPanel grows a Perp Flow tab
  + fetchHyperliquidFlow() RPC method; OI Δ1h derived from sparkOi tail.
- src/App.ts: prime via primeVisiblePanelData() + recurring refresh via
  refreshScheduler.scheduleRefresh() at 5min cadence (panel does NOT own
  setInterval; matches the App.ts:1251 lifecycle convention).
- 28 unit tests covering scoring parity, warmup flag, min-notional guard,
  schema rejection, missing-symbol carry-forward, post-outage cold start,
  sparkline cap, alert threshold.

Tests: test:data 5169/5169, hyperliquid-flow-seed 28/28, route-cache-tier
5/5, typecheck + typecheck:api green. One pre-existing test:sidecar failure
(cloud-fallback origin headers) is unrelated and reproduces on origin/main.

* fix(hyperliquid-flow): address review feedback — volume baseline window, warmup lifecycle, error logging

Two real correctness bugs and four review nits from PR #3074 review pass.

Correctness fixes:

1. Volume baseline was anchored to the OLDEST 12 samples, not the newest.
   sparkVol is newest-at-tail (shiftAndAppend), so slice(0, 12) pinned the
   rolling mean to the first hour of data forever once len >= 12. Volume
   scoring would drift further from current conditions each poll. Switched
   to slice(-VOLUME_BASELINE_MIN_SAMPLES) so the baseline tracks the most
   recent window. Regression test added.

2. Warmup flag flipped to false on the second successful poll while volume
   scoring still needed 12+ samples to activate. UI told users warmup
   lasted ~1h but the badge disappeared after 5 min. Tied per-asset warmup
   to real baseline readiness (coldStart OR vol samples < 12 OR prior OI
   missing). Snapshot-level warmup = any asset still warming. Three new
   tests cover the persist-through-baseline-build, clear-once-ready, and
   missing-OI paths.

Review nits:

- Handler: bare catch swallowed Redis/parse errors; now logs err.message.
- Panel: bare catch in fetchHyperliquidFlow hid RPC 500s; now logs.
- MarketPanel.ts: deleted hand-rolled RawHyperliquidAsset; mapHyperliquidFlowResponse
  now takes GetHyperliquidFlowResponse from the generated client so proto
  drift fails compilation instead of silently.
- Seeder: added @ts-check + JSDoc on computeAsset per type-safety rule.
- validateUpstream: MAX_UPSTREAM_UNIVERSE=2000 cap bounds memory.
- buildSnapshot: logs unknown xyz: perps upstream (once per run) so ops
  sees when Hyperliquid adds markets we could whitelist.

Tests: 37/37 green; typecheck + typecheck:api clean.

* fix(hyperliquid-flow): wire bootstrap hydration per AGENTS.md mandate

Greptile review caught that AGENTS.md:187 mandates new data sources be wired
into bootstrap hydration. Plan had deferred this on "lazy deep-dive signal"
grounds, but the project convention is binding.

- server/_shared/cache-keys.ts: add hyperliquidFlow to BOOTSTRAP_CACHE_KEYS
  + BOOTSTRAP_TIERS ('slow' — non-blocking, page-load-parallel).
- api/bootstrap.js: add to inlined BOOTSTRAP_CACHE_KEYS + SLOW_KEYS so
  bootstrap.test.mjs canonical-mirror assertions pass.
- src/components/MarketPanel.ts:
  - Import getHydratedData from @/services/bootstrap.
  - New mapHyperliquidFlowSeed() normalizes the raw seed-JSON shape
    (numeric fields) into HyperliquidFlowView. The RPC mapper handles the
    proto shape (string-encoded numbers); bootstrap emits the raw blob.
  - fetchHyperliquidFlow now reads hydrated data first, renders
    immediately, then refreshes from RPC — mirrors FearGreedPanel pattern.

Tests: 72/72 green (bootstrap + cache-tier + hyperliquid-flow-seed).
2026-04-14 08:05:40 +04:00

334 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// @ts-check
/**
* Hyperliquid perp positioning flow seeder.
*
* Polls the public Hyperliquid /info endpoint every 5 minutes, computes a
* 4-component composite "positioning stress" score (funding / volume / OI /
* basis) per asset, and publishes a self-contained snapshot — current metrics
* plus short per-asset sparkline arrays for funding, OI and score.
*
* Used as a leading indicator for commodities / crypto / FX in CommoditiesPanel.
*/
import { loadEnvFile, runSeed, readSeedSnapshot } from './_seed-utils.mjs';
loadEnvFile(import.meta.url);
export const CANONICAL_KEY = 'market:hyperliquid:flow:v1';
export const CACHE_TTL_SECONDS = 2700; // 9× cron cadence (5 min); honest grace window
export const SPARK_MAX = 60; // 5h @ 5min
export const HYPERLIQUID_URL = 'https://api.hyperliquid.xyz/info';
export const REQUEST_TIMEOUT_MS = 15_000;
export const MIN_NOTIONAL_USD_24H = 500_000;
export const STALE_SYMBOL_DROP_AFTER_POLLS = 3;
export const VOLUME_BASELINE_MIN_SAMPLES = 12; // 1h @ 5min cadence — minimum history to score volume spike
export const MAX_UPSTREAM_UNIVERSE = 2000; // defensive cap; Hyperliquid has ~200 perps today
// Hardcoded symbol whitelist — never iterate the full universe.
// `class`: scoring threshold class. `display`: UI label. `group`: panel section.
export const ASSETS = [
{ symbol: 'BTC', class: 'crypto', display: 'BTC', group: 'crypto' },
{ symbol: 'ETH', class: 'crypto', display: 'ETH', group: 'crypto' },
{ symbol: 'SOL', class: 'crypto', display: 'SOL', group: 'crypto' },
{ symbol: 'PAXG', class: 'commodity', display: 'PAXG (gold)', group: 'metals' },
{ symbol: 'xyz:CL', class: 'commodity', display: 'WTI Crude', group: 'oil' },
{ symbol: 'xyz:BRENTOIL', class: 'commodity', display: 'Brent Crude', group: 'oil' },
{ symbol: 'xyz:GOLD', class: 'commodity', display: 'Gold', group: 'metals' },
{ symbol: 'xyz:SILVER', class: 'commodity', display: 'Silver', group: 'metals' },
{ symbol: 'xyz:PLATINUM', class: 'commodity', display: 'Platinum', group: 'metals' },
{ symbol: 'xyz:PALLADIUM', class: 'commodity', display: 'Palladium', group: 'metals' },
{ symbol: 'xyz:COPPER', class: 'commodity', display: 'Copper', group: 'industrial' },
{ symbol: 'xyz:NATGAS', class: 'commodity', display: 'Natural Gas', group: 'gas' },
{ symbol: 'xyz:EUR', class: 'commodity', display: 'EUR', group: 'fx' },
{ symbol: 'xyz:JPY', class: 'commodity', display: 'JPY', group: 'fx' },
];
// Risk weights — must sum to 1.0
export const WEIGHTS = { funding: 0.30, volume: 0.25, oi: 0.25, basis: 0.20 };
export const THRESHOLDS = {
crypto: { funding: 0.001, volume: 5.0, oi: 0.20, basis: 0.05 },
commodity: { funding: 0.0005, volume: 3.0, oi: 0.15, basis: 0.03 },
};
export const ALERT_THRESHOLD = 60;
// ── Pure scoring helpers ──────────────────────────────────────────────────────
export function clamp(x, lo = 0, hi = 100) {
if (!Number.isFinite(x)) return 0;
return Math.max(lo, Math.min(hi, x));
}
export function scoreFunding(rate, threshold) {
if (!Number.isFinite(rate) || threshold <= 0) return 0;
return clamp((Math.abs(rate) / threshold) * 100);
}
export function scoreVolume(currentVol, avgVol, threshold) {
if (!Number.isFinite(currentVol) || !(avgVol > 0) || threshold <= 0) return 0;
return clamp(((currentVol / avgVol) / threshold) * 100);
}
export function scoreOi(currentOi, prevOi, threshold) {
if (!Number.isFinite(currentOi) || !(prevOi > 0) || threshold <= 0) return 0;
return clamp((Math.abs(currentOi - prevOi) / prevOi / threshold) * 100);
}
export function scoreBasis(mark, oracle, threshold) {
if (!Number.isFinite(mark) || !(oracle > 0) || threshold <= 0) return 0;
return clamp((Math.abs(mark - oracle) / oracle / threshold) * 100);
}
/**
* Compute composite score and alerts for one asset.
*
* `prevAsset` may be null/undefined for cold start; in that case OI delta and
* volume spike are scored as 0 (we lack baselines).
*
* Per-asset `warmup` is TRUE until the volume baseline has VOLUME_BASELINE_MIN_SAMPLES
* and there is a prior OI to compute delta against — NOT just on the first poll after
* cold start. Without this, the "warming up" badge flips to false on poll 2 while the
* score is still missing most of its baseline.
*
* @param {{ symbol: string; display: string; class: 'crypto'|'commodity'; group: string }} meta
* @param {Record<string, string>} ctx
* @param {any} prevAsset
* @param {{ coldStart?: boolean }} [opts]
*/
export function computeAsset(meta, ctx, prevAsset, opts = {}) {
const t = THRESHOLDS[meta.class];
const fundingRate = Number(ctx.funding);
const currentOi = Number(ctx.openInterest);
const markPx = Number(ctx.markPx);
const oraclePx = Number(ctx.oraclePx);
const dayNotional = Number(ctx.dayNtlVlm);
const prevOi = prevAsset?.openInterest ?? null;
const prevVolSamples = /** @type {number[]} */ ((prevAsset?.sparkVol || []).filter(
/** @param {unknown} v */ (v) => Number.isFinite(v)
));
const fundingScore = scoreFunding(fundingRate, t.funding);
// Volume spike scored against the MOST RECENT 12 samples in sparkVol.
// sparkVol is newest-at-tail (see shiftAndAppend), so we must slice(-N) — NOT
// slice(0, N), which would anchor the baseline to the oldest window and never
// update after the first hour.
let volumeScore = 0;
const volumeBaselineReady = prevVolSamples.length >= VOLUME_BASELINE_MIN_SAMPLES;
if (dayNotional >= MIN_NOTIONAL_USD_24H && volumeBaselineReady) {
const recent = prevVolSamples.slice(-VOLUME_BASELINE_MIN_SAMPLES);
const avg = recent.reduce((a, b) => a + b, 0) / recent.length;
volumeScore = scoreVolume(dayNotional, avg, t.volume);
}
const oiScore = prevOi != null ? scoreOi(currentOi, prevOi, t.oi) : 0;
const basisScore = scoreBasis(markPx, oraclePx, t.basis);
const composite = clamp(
fundingScore * WEIGHTS.funding +
volumeScore * WEIGHTS.volume +
oiScore * WEIGHTS.oi +
basisScore * WEIGHTS.basis,
);
const sparkFunding = shiftAndAppend(prevAsset?.sparkFunding, Number.isFinite(fundingRate) ? fundingRate : 0);
const sparkOi = shiftAndAppend(prevAsset?.sparkOi, Number.isFinite(currentOi) ? currentOi : 0);
const sparkScore = shiftAndAppend(prevAsset?.sparkScore, composite);
const sparkVol = shiftAndAppend(prevAsset?.sparkVol, Number.isFinite(dayNotional) ? dayNotional : 0);
// Warmup stays TRUE until both baselines are usable — cold-start OR insufficient
// volume history OR missing prior OI. Clears only when the asset can produce all
// four component scores.
const warmup = opts.coldStart === true || !volumeBaselineReady || prevOi == null;
const alerts = [];
if (composite >= ALERT_THRESHOLD) {
alerts.push(`HIGH RISK ${composite.toFixed(0)}/100`);
}
return {
symbol: meta.symbol,
display: meta.display,
class: meta.class,
group: meta.group,
funding: Number.isFinite(fundingRate) ? fundingRate : null,
openInterest: Number.isFinite(currentOi) ? currentOi : null,
markPx: Number.isFinite(markPx) ? markPx : null,
oraclePx: Number.isFinite(oraclePx) ? oraclePx : null,
dayNotional: Number.isFinite(dayNotional) ? dayNotional : null,
fundingScore,
volumeScore,
oiScore,
basisScore,
composite,
sparkFunding,
sparkOi,
sparkScore,
sparkVol,
stale: false,
staleSince: null,
missingPolls: 0,
alerts,
warmup,
};
}
function shiftAndAppend(prev, value) {
const arr = Array.isArray(prev) ? prev.slice(-(SPARK_MAX - 1)) : [];
arr.push(value);
return arr;
}
// ── Hyperliquid client ────────────────────────────────────────────────────────
export async function fetchHyperliquidMetaAndCtxs(fetchImpl = fetch) {
const resp = await fetchImpl(HYPERLIQUID_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'User-Agent': 'WorldMonitor/1.0 (+https://worldmonitor.app)',
},
body: JSON.stringify({ type: 'metaAndAssetCtxs' }),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!resp.ok) throw new Error(`Hyperliquid HTTP ${resp.status}`);
const ct = resp.headers?.get?.('content-type') || '';
if (!ct.toLowerCase().includes('application/json')) {
throw new Error(`Hyperliquid wrong content-type: ${ct || '<missing>'}`);
}
const json = await resp.json();
return json;
}
/**
* Strict shape validation. Hyperliquid returns `[meta, assetCtxs]` where
* meta = { universe: [{ name, ... }, ...] }
* assetCtxs = [{ funding, openInterest, markPx, oraclePx, dayNtlVlm, ... }, ...]
* with assetCtxs[i] aligned to universe[i].
*
* Throws on any mismatch — never persist a partial / malformed payload.
*/
export function validateUpstream(raw) {
if (!Array.isArray(raw) || raw.length < 2) {
throw new Error('Hyperliquid payload not a [meta, assetCtxs] tuple');
}
const [meta, assetCtxs] = raw;
if (!meta || !Array.isArray(meta.universe)) {
throw new Error('Hyperliquid meta.universe missing or not array');
}
if (meta.universe.length < 50) {
throw new Error(`Hyperliquid universe suspiciously small: ${meta.universe.length}`);
}
if (meta.universe.length > MAX_UPSTREAM_UNIVERSE) {
throw new Error(`Hyperliquid universe over cap: ${meta.universe.length} > ${MAX_UPSTREAM_UNIVERSE}`);
}
if (!Array.isArray(assetCtxs) || assetCtxs.length !== meta.universe.length) {
throw new Error('Hyperliquid assetCtxs length does not match universe');
}
for (const m of meta.universe) {
if (typeof m?.name !== 'string') throw new Error('Hyperliquid universe entry missing name');
}
return { universe: meta.universe, assetCtxs };
}
export function indexBySymbol({ universe, assetCtxs }) {
const out = new Map();
for (let i = 0; i < universe.length; i++) {
out.set(universe[i].name, assetCtxs[i] || {});
}
return out;
}
// ── Main build path ──────────────────────────────────────────────────────────
/**
* Build a fresh snapshot from the upstream payload + the previous Redis snapshot.
* Pure function — caller passes both inputs.
*/
export function buildSnapshot(upstream, prevSnapshot, opts = {}) {
const validated = validateUpstream(upstream);
const ctxBySymbol = indexBySymbol(validated);
const now = opts.now || Date.now();
const prevByName = new Map();
if (prevSnapshot?.assets && Array.isArray(prevSnapshot.assets)) {
for (const a of prevSnapshot.assets) prevByName.set(a.symbol, a);
}
const prevAgeMs = prevSnapshot?.ts ? now - prevSnapshot.ts : Infinity;
// Treat stale prior snapshot (>3× cadence = 900s) as cold start.
const coldStart = !prevSnapshot || prevAgeMs > 900_000;
// Info-log unseen xyz: perps once per run so ops sees when Hyperliquid adds
// commodity/FX markets we could add to the whitelist.
const whitelisted = new Set(ASSETS.map((a) => a.symbol));
const unknownXyz = validated.universe
.map((/** @type {{ name: string }} */ u) => u.name)
.filter((name) => typeof name === 'string' && name.startsWith('xyz:') && !whitelisted.has(name));
if (unknownXyz.length > 0) {
console.log(` Unknown xyz: perps upstream (not whitelisted): ${unknownXyz.slice(0, 20).join(', ')}${unknownXyz.length > 20 ? ` (+${unknownXyz.length - 20} more)` : ''}`);
}
const assets = [];
for (const meta of ASSETS) {
const ctx = ctxBySymbol.get(meta.symbol);
if (!ctx) {
// Whitelisted symbol absent from upstream — carry forward prior with stale flag.
const prev = prevByName.get(meta.symbol);
if (!prev) continue; // never seen, skip silently (don't synthesize)
const missing = (prev.missingPolls || 0) + 1;
if (missing >= STALE_SYMBOL_DROP_AFTER_POLLS) {
console.warn(` Dropping ${meta.symbol} — missing for ${missing} consecutive polls`);
continue;
}
assets.push({
...prev,
stale: true,
staleSince: prev.staleSince || now,
missingPolls: missing,
});
continue;
}
const prev = coldStart ? null : prevByName.get(meta.symbol);
const asset = computeAsset(meta, ctx, prev, { coldStart });
assets.push(asset);
}
// Snapshot warmup = any asset still building a baseline. Reflects real
// component-score readiness, not just the first poll after cold start.
const warmup = assets.some((a) => a.warmup === true);
return {
ts: now,
fetchedAt: new Date(now).toISOString(),
warmup,
assetCount: assets.length,
assets,
};
}
export function validateFn(snapshot) {
return !!snapshot && Array.isArray(snapshot.assets) && snapshot.assets.length >= 12;
}
// ── Entry point ──────────────────────────────────────────────────────────────
const isMain = process.argv[1]?.endsWith('seed-hyperliquid-flow.mjs');
if (isMain) {
const prevSnapshot = await readSeedSnapshot(CANONICAL_KEY);
await runSeed('market', 'hyperliquid-flow', CANONICAL_KEY, async () => {
const upstream = await fetchHyperliquidMetaAndCtxs();
return buildSnapshot(upstream, prevSnapshot);
}, {
ttlSeconds: CACHE_TTL_SECONDS,
validateFn,
sourceVersion: 'hyperliquid-info-metaAndAssetCtxs-v1',
recordCount: (snap) => snap?.assets?.length || 0,
}).catch((err) => {
const cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : '';
console.error('FATAL:', (err.message || err) + cause);
process.exit(1);
});
}