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).
This commit is contained in:
Elie Habib
2026-04-14 08:05:40 +04:00
committed by GitHub
parent 825313eee7
commit e32d9b631c
19 changed files with 1354 additions and 5 deletions

2
api/bootstrap.js vendored
View File

@@ -87,6 +87,7 @@ const BOOTSTRAP_CACHE_KEYS = {
eurostatIndProd: 'economic:eurostat:industrial-production:v1',
marketImplications: 'intelligence:market-implications:v1',
fearGreedIndex: 'market:fear-greed:v1',
hyperliquidFlow: 'market:hyperliquid:flow:v1',
crudeInventories: 'economic:crude-inventories:v1',
natGasStorage: 'economic:nat-gas-storage:v1',
ecbFxRates: 'economic:ecb-fx-rates:v1',
@@ -137,6 +138,7 @@ const SLOW_KEYS = new Set([
'eurostatIndProd',
'marketImplications',
'fearGreedIndex',
'hyperliquidFlow',
'crudeInventories',
'natGasStorage',
'ecbFxRates',

View File

@@ -71,6 +71,7 @@ const BOOTSTRAP_KEYS = {
earningsCalendar: 'market:earnings-calendar:v1',
econCalendar: 'economic:econ-calendar:v1',
cotPositioning: 'market:cot:v1',
hyperliquidFlow: 'market:hyperliquid:flow:v1',
crudeInventories: 'economic:crude-inventories:v1',
natGasStorage: 'economic:nat-gas-storage:v1',
spr: 'economic:spr:v1',
@@ -299,6 +300,7 @@ const SEED_META = {
earningsCalendar: { key: 'seed-meta:market:earnings-calendar', maxStaleMin: 1440 }, // 12h cron; 1440min = 24h = 2x interval
econCalendar: { key: 'seed-meta:economic:econ-calendar', maxStaleMin: 1440 }, // 12h cron; 1440min = 24h = 2x interval
cotPositioning: { key: 'seed-meta:market:cot', maxStaleMin: 14400 }, // weekly CFTC release; 14400min = 10d = 1.4x interval (weekend + delay buffer)
hyperliquidFlow: { key: 'seed-meta:market:hyperliquid-flow', maxStaleMin: 15 }, // Railway cron 5min; 15min = 3x interval
crudeInventories: { key: 'seed-meta:economic:crude-inventories', maxStaleMin: 20160 }, // weekly EIA data; 20160min = 14 days = 2x weekly cadence
natGasStorage: { key: 'seed-meta:economic:nat-gas-storage', maxStaleMin: 20160 }, // weekly EIA data; 20160min = 14 days = 2x weekly cadence
spr: { key: 'seed-meta:economic:spr', maxStaleMin: 20160 }, // weekly EIA data; 20160min = 14 days = 2x weekly cadence
@@ -382,6 +384,9 @@ const ON_DEMAND_KEYS = new Set([
// gate as on-demand so a deploy-order race or first-cron-run failure doesn't
// fire a CRIT health alarm. Remove from this set after ~7 days of clean
// production cron runs (verify via `seed-meta:economic:fx-yoy.fetchedAt`).
'hyperliquidFlow', // TRANSITIONAL: seed-hyperliquid-flow runs inside seed-bundle-market-backup on
// Railway; gate as on-demand so initial deploy-order race or first cold-start
// snapshot doesn't CRIT. Remove after ~7 days of clean production cron runs.
]);
// Keys where 0 records is a valid healthy state (e.g. no airports closed,

View File

@@ -21,6 +21,7 @@ const SEED_DOMAINS = {
'unrest:events': { key: 'seed-meta:unrest:events', intervalMin: 15 },
'cyber:threats': { key: 'seed-meta:cyber:threats', intervalMin: 240 },
'market:crypto': { key: 'seed-meta:market:crypto', intervalMin: 15 },
'market:hyperliquid-flow': { key: 'seed-meta:market:hyperliquid-flow', intervalMin: 5 }, // Railway cron 5min via seed-bundle-market-backup
'market:etf-flows': { key: 'seed-meta:market:etf-flows', intervalMin: 30 },
'market:gulf-quotes': { key: 'seed-meta:market:gulf-quotes', intervalMin: 15 },
'market:stablecoins': { key: 'seed-meta:market:stablecoins', intervalMin: 30 },

File diff suppressed because one or more lines are too long

View File

@@ -696,6 +696,32 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/market/v1/get-hyperliquid-flow:
get:
tags:
- MarketService
summary: GetHyperliquidFlow
description: GetHyperliquidFlow retrieves Hyperliquid perp positioning flow (funding/OI/basis composite scores).
operationId: GetHyperliquidFlow
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/GetHyperliquidFlowResponse'
"400":
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
default:
description: Error response
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
Error:
@@ -2130,3 +2156,93 @@ components:
deltaTonnes12m:
type: number
format: double
GetHyperliquidFlowRequest:
type: object
GetHyperliquidFlowResponse:
type: object
properties:
ts:
type: string
format: int64
fetchedAt:
type: string
warmup:
type: boolean
assetCount:
type: integer
format: int32
assets:
type: array
items:
$ref: '#/components/schemas/HyperliquidAssetFlow'
unavailable:
type: boolean
HyperliquidAssetFlow:
type: object
properties:
symbol:
type: string
display:
type: string
assetClass:
type: string
group:
type: string
funding:
type: string
description: Raw metrics (nullable as strings to preserve precision; "" = unavailable)
openInterest:
type: string
markPx:
type: string
oraclePx:
type: string
dayNotional:
type: string
fundingScore:
type: number
format: double
description: Component scores 0-100
volumeScore:
type: number
format: double
oiScore:
type: number
format: double
basisScore:
type: number
format: double
composite:
type: number
format: double
sparkFunding:
type: array
items:
type: number
format: double
description: Sparkline arrays (most recent last); up to 60 samples (5h @ 5min)
sparkOi:
type: array
items:
type: number
format: double
sparkScore:
type: array
items:
type: number
format: double
warmup:
type: boolean
description: State flags
stale:
type: boolean
staleSince:
type: string
format: int64
missingPolls:
type: integer
format: int32
alerts:
type: array
items:
type: string

View File

@@ -0,0 +1,45 @@
syntax = "proto3";
package worldmonitor.market.v1;
import "sebuf/http/annotations.proto";
message GetHyperliquidFlowRequest {}
message HyperliquidAssetFlow {
string symbol = 1;
string display = 2;
string asset_class = 3; // "crypto" or "commodity"
string group = 4; // "crypto" / "oil" / "metals" / "industrial" / "gas" / "fx"
// Raw metrics (nullable as strings to preserve precision; "" = unavailable)
string funding = 5; // hourly funding rate
string open_interest = 6;
string mark_px = 7;
string oracle_px = 8;
string day_notional = 9; // 24h notional volume in USD
// Component scores 0-100
double funding_score = 10;
double volume_score = 11;
double oi_score = 12;
double basis_score = 13;
double composite = 14; // weighted composite 0-100
// Sparkline arrays (most recent last); up to 60 samples (5h @ 5min)
repeated double spark_funding = 15;
repeated double spark_oi = 16;
repeated double spark_score = 17;
// State flags
bool warmup = 18; // true while baselines are being built (cold start)
bool stale = 19; // upstream omitted this symbol; carrying forward
int64 stale_since = 20; // epoch ms when stale flag first set; 0 if never stale
int32 missing_polls = 21;
repeated string alerts = 22;
}
message GetHyperliquidFlowResponse {
int64 ts = 1; // snapshot timestamp (epoch ms)
string fetched_at = 2; // ISO-8601 of `ts`
bool warmup = 3; // true while any asset is in warmup state
int32 asset_count = 4;
repeated HyperliquidAssetFlow assets = 5;
bool unavailable = 6; // true when seed key is empty/missing
}

View File

@@ -25,6 +25,7 @@ import "worldmonitor/market/v1/get_cot_positioning.proto";
import "worldmonitor/market/v1/get_insider_transactions.proto";
import "worldmonitor/market/v1/get_market_breadth_history.proto";
import "worldmonitor/market/v1/get_gold_intelligence.proto";
import "worldmonitor/market/v1/get_hyperliquid_flow.proto";
// MarketService provides APIs for financial market data from Finnhub, Yahoo Finance, and CoinGecko.
service MarketService {
@@ -139,4 +140,9 @@ service MarketService {
rpc GetGoldIntelligence(GetGoldIntelligenceRequest) returns (GetGoldIntelligenceResponse) {
option (sebuf.http.config) = {path: "/get-gold-intelligence", method: HTTP_METHOD_GET};
}
// GetHyperliquidFlow retrieves Hyperliquid perp positioning flow (funding/OI/basis composite scores).
rpc GetHyperliquidFlow(GetHyperliquidFlowRequest) returns (GetHyperliquidFlowResponse) {
option (sebuf.http.config) = {path: "/get-hyperliquid-flow", method: HTTP_METHOD_GET};
}
}

View File

@@ -3,6 +3,7 @@ import { runBundle, MIN } from './_bundle-runner.mjs';
await runBundle('market-backup', [
{ label: 'Crypto-Quotes', script: 'seed-crypto-quotes.mjs', seedMetaKey: 'market:crypto', intervalMs: 5 * MIN, timeoutMs: 120_000 },
{ label: 'Hyperliquid-Flow', script: 'seed-hyperliquid-flow.mjs', seedMetaKey: 'market:hyperliquid-flow', intervalMs: 5 * MIN, timeoutMs: 60_000 },
{ label: 'Stablecoin-Markets', script: 'seed-stablecoin-markets.mjs', seedMetaKey: 'market:stablecoins', intervalMs: 10 * MIN, timeoutMs: 120_000 },
{ label: 'ETF-Flows', script: 'seed-etf-flows.mjs', seedMetaKey: 'market:etf-flows', intervalMs: 15 * MIN, timeoutMs: 120_000 },
{ label: 'Gulf-Quotes', script: 'seed-gulf-quotes.mjs', seedMetaKey: 'market:gulf-quotes', intervalMs: 10 * MIN, timeoutMs: 120_000 },

View File

@@ -0,0 +1,333 @@
#!/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);
});
}

View File

@@ -189,6 +189,7 @@ export const BOOTSTRAP_CACHE_KEYS: Record<string, string> = {
nationalDebt: 'economic:national-debt:v1',
marketImplications: 'intelligence:market-implications:v1',
fearGreedIndex: 'market:fear-greed:v1',
hyperliquidFlow: 'market:hyperliquid:flow:v1',
crudeInventories: 'economic:crude-inventories:v1',
natGasStorage: 'economic:nat-gas-storage:v1',
ecbFxRates: 'economic:ecb-fx-rates:v1',
@@ -257,6 +258,7 @@ export const BOOTSTRAP_TIERS: Record<string, 'slow' | 'fast'> = {
nationalDebt: 'slow',
marketImplications: 'slow',
fearGreedIndex: 'slow',
hyperliquidFlow: 'slow',
crudeInventories: 'slow',
natGasStorage: 'slow',
ecbFxRates: 'slow',

View File

@@ -201,6 +201,7 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
'/api/market/v1/list-earnings-calendar': 'slow',
'/api/market/v1/get-cot-positioning': 'slow',
'/api/market/v1/get-gold-intelligence': 'slow',
'/api/market/v1/get-hyperliquid-flow': 'medium',
'/api/market/v1/get-insider-transactions': 'slow',
'/api/economic/v1/get-economic-calendar': 'slow',
'/api/intelligence/v1/list-market-implications': 'slow',

View File

@@ -0,0 +1,113 @@
import type {
ServerContext,
GetHyperliquidFlowRequest,
GetHyperliquidFlowResponse,
HyperliquidAssetFlow,
} from '../../../../src/generated/server/worldmonitor/market/v1/service_server';
import { getCachedJson } from '../../../_shared/redis';
const SEED_CACHE_KEY = 'market:hyperliquid:flow:v1';
interface SeededAsset {
symbol?: string;
display?: string;
class?: string;
group?: string;
funding?: number | null;
openInterest?: number | null;
markPx?: number | null;
oraclePx?: number | null;
dayNotional?: number | null;
fundingScore?: number;
volumeScore?: number;
oiScore?: number;
basisScore?: number;
composite?: number;
sparkFunding?: number[];
sparkOi?: number[];
sparkScore?: number[];
warmup?: boolean;
stale?: boolean;
staleSince?: number | null;
missingPolls?: number;
alerts?: string[];
}
interface SeededSnapshot {
ts?: number;
fetchedAt?: string;
warmup?: boolean;
assetCount?: number;
assets?: SeededAsset[];
}
function numToStr(v: number | null | undefined): string {
return v == null || !Number.isFinite(v) ? '' : String(v);
}
function arr(a: number[] | undefined): number[] {
return Array.isArray(a) ? a.filter((v) => Number.isFinite(v)) : [];
}
export async function getHyperliquidFlow(
_ctx: ServerContext,
_req: GetHyperliquidFlowRequest,
): Promise<GetHyperliquidFlowResponse> {
try {
const raw = await getCachedJson(SEED_CACHE_KEY, true) as SeededSnapshot | null;
if (!raw?.assets || raw.assets.length === 0) {
// No error — seeder hasn't run yet, or empty snapshot. Distinguish from
// parse/Redis failures below (those hit the catch and log).
return {
ts: '0',
fetchedAt: '',
warmup: true,
assetCount: 0,
assets: [],
unavailable: true,
};
}
const assets: HyperliquidAssetFlow[] = raw.assets.map((a) => ({
symbol: String(a.symbol ?? ''),
display: String(a.display ?? ''),
assetClass: String(a.class ?? ''),
group: String(a.group ?? ''),
funding: numToStr(a.funding ?? null),
openInterest: numToStr(a.openInterest ?? null),
markPx: numToStr(a.markPx ?? null),
oraclePx: numToStr(a.oraclePx ?? null),
dayNotional: numToStr(a.dayNotional ?? null),
fundingScore: Number(a.fundingScore ?? 0),
volumeScore: Number(a.volumeScore ?? 0),
oiScore: Number(a.oiScore ?? 0),
basisScore: Number(a.basisScore ?? 0),
composite: Number(a.composite ?? 0),
sparkFunding: arr(a.sparkFunding),
sparkOi: arr(a.sparkOi),
sparkScore: arr(a.sparkScore),
warmup: Boolean(a.warmup),
stale: Boolean(a.stale),
staleSince: String(a.staleSince ?? 0),
missingPolls: Number(a.missingPolls ?? 0),
alerts: Array.isArray(a.alerts) ? a.alerts.map((x) => String(x)) : [],
}));
return {
ts: String(raw.ts ?? 0),
fetchedAt: String(raw.fetchedAt ?? ''),
warmup: Boolean(raw.warmup),
assetCount: assets.length,
assets,
unavailable: false,
};
} catch (err) {
console.error('[getHyperliquidFlow] Redis read or parse failed:', err instanceof Error ? err.message : err);
return {
ts: '0',
fetchedAt: '',
warmup: true,
assetCount: 0,
assets: [],
unavailable: true,
};
}
}

View File

@@ -35,6 +35,7 @@ import { getCotPositioning } from './get-cot-positioning';
import { getInsiderTransactions } from './get-insider-transactions';
import { getMarketBreadthHistory } from './get-market-breadth-history';
import { getGoldIntelligence } from './get-gold-intelligence';
import { getHyperliquidFlow } from './get-hyperliquid-flow';
export const marketHandler: MarketServiceHandler = {
listMarketQuotes,
@@ -59,4 +60,5 @@ export const marketHandler: MarketServiceHandler = {
getInsiderTransactions,
getMarketBreadthHistory,
getGoldIntelligence,
getHyperliquidFlow,
};

View File

@@ -29,6 +29,7 @@ import type { ServiceStatusPanel } from '@/components/ServiceStatusPanel';
import type { StablecoinPanel } from '@/components/StablecoinPanel';
import type { EnergyCrisisPanel } from '@/components/EnergyCrisisPanel';
import type { ETFFlowsPanel } from '@/components/ETFFlowsPanel';
import type { CommoditiesPanel } from '@/components/MarketPanel';
import type { MacroSignalsPanel } from '@/components/MacroSignalsPanel';
import type { FearGreedPanel } from '@/components/FearGreedPanel';
import type { HormuzPanel } from '@/components/HormuzPanel';
@@ -265,6 +266,10 @@ export class App {
const panel = this.state.panels['hormuz-tracker'] as HormuzPanel | undefined;
if (panel) primeTask('hormuz-tracker', () => panel.fetchData());
}
if (shouldPrime('commodities')) {
const panel = this.state.panels['commodities'] as CommoditiesPanel | undefined;
if (panel) primeTask('commodities-hyperliquid-flow', () => panel.fetchHyperliquidFlow());
}
if (shouldPrime('etf-flows')) {
const panel = this.state.panels['etf-flows'] as ETFFlowsPanel | undefined;
if (panel) primeTask('etf-flows', () => panel.fetchData());
@@ -1297,6 +1302,12 @@ export class App {
REFRESH_INTERVALS.hormuzTracker,
() => this.isPanelNearViewport('hormuz-tracker')
);
this.refreshScheduler.scheduleRefresh(
'commodities-hyperliquid-flow',
() => (this.state.panels['commodities'] as CommoditiesPanel).fetchHyperliquidFlow(),
REFRESH_INTERVALS.hyperliquidFlow,
() => this.isPanelNearViewport('commodities')
);
this.refreshScheduler.scheduleRefresh(
'strategic-posture',
() => (this.state.panels['strategic-posture'] as StrategicPosturePanel).refresh(),

View File

@@ -5,6 +5,7 @@ import { formatPrice, formatChange, getChangeClass, getHeatmapClass } from '@/ut
import { escapeHtml } from '@/utils/sanitize';
import { miniSparkline } from '@/utils/sparkline';
import { SITE_VARIANT } from '@/config';
import { getHydratedData } from '@/services/bootstrap';
import {
getMarketWatchlistEntries,
parseMarketWatchlistInput,
@@ -343,7 +344,117 @@ interface EcbFxRateItem {
change1d?: number | null;
}
type CommoditiesTab = 'commodities' | 'fx' | 'xau';
type CommoditiesTab = 'commodities' | 'fx' | 'xau' | 'flow';
// Use the generated types directly — never hand-roll a subset, which silently
// drifts when the proto gains fields.
import type {
GetHyperliquidFlowResponse,
HyperliquidAssetFlow,
} from '@/generated/client/worldmonitor/market/v1/service_client';
function parseFiniteNumber(s: string): number | null {
if (typeof s !== 'string' || s === '') return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
/**
* OI Δ1h derived from sparkOi tail: (last - lookback) / lookback.
* 12 samples back = 1h at 5min cadence. Returns null if too few samples.
*/
function oiDelta1h(sparkOi: number[]): number | null {
if (!Array.isArray(sparkOi) || sparkOi.length < 13) return null;
const last = sparkOi[sparkOi.length - 1];
const lookback = sparkOi[sparkOi.length - 13];
if (last == null || lookback == null) return null;
if (!(lookback > 0) || !Number.isFinite(last)) return null;
return (last - lookback) / lookback;
}
/**
* Map the raw bootstrap-hydrated seed snapshot (seeder JSON shape) into the
* same view model the RPC mapper produces. Bootstrap returns the raw Redis
* blob (numeric fields), not the proto response (string-encoded numbers).
*/
export function mapHyperliquidFlowSeed(raw: Record<string, unknown>): HyperliquidFlowView | null {
const assets = Array.isArray(raw.assets) ? (raw.assets as Array<Record<string, unknown>>) : null;
if (!assets || assets.length === 0) return null;
const fxAssets: HyperliquidAssetView[] = [];
const commodityAssets: HyperliquidAssetView[] = [];
for (const a of assets) {
const funding = typeof a.funding === 'number' && Number.isFinite(a.funding) ? a.funding : null;
const sparkOi = Array.isArray(a.sparkOi) ? (a.sparkOi as number[]).filter((v) => Number.isFinite(v)) : [];
const sparkScore = Array.isArray(a.sparkScore) ? (a.sparkScore as number[]).filter((v) => Number.isFinite(v)) : [];
const view: HyperliquidAssetView = {
symbol: String(a.symbol ?? ''),
display: String(a.display ?? ''),
group: String(a.group ?? ''),
funding,
oiDelta1h: oiDelta1h(sparkOi),
composite: typeof a.composite === 'number' ? a.composite : 0,
warmup: Boolean(a.warmup),
stale: Boolean(a.stale),
sparkScore,
};
if (view.group === 'fx') fxAssets.push(view);
else commodityAssets.push(view);
}
return {
ts: typeof raw.ts === 'number' ? raw.ts : 0,
warmup: Boolean(raw.warmup),
fxAssets,
commodityAssets,
unavailable: false,
};
}
export function mapHyperliquidFlowResponse(resp: GetHyperliquidFlowResponse): HyperliquidFlowView {
const fxAssets: HyperliquidAssetView[] = [];
const commodityAssets: HyperliquidAssetView[] = [];
for (const a of resp.assets as HyperliquidAssetFlow[]) {
const view: HyperliquidAssetView = {
symbol: a.symbol,
display: a.display,
group: a.group,
funding: parseFiniteNumber(a.funding),
oiDelta1h: oiDelta1h(a.sparkOi),
composite: Number(a.composite || 0),
warmup: Boolean(a.warmup),
stale: Boolean(a.stale),
sparkScore: Array.isArray(a.sparkScore) ? a.sparkScore : [],
};
if (a.group === 'fx') fxAssets.push(view);
else commodityAssets.push(view);
}
return {
ts: Number(resp.ts || 0),
warmup: Boolean(resp.warmup),
fxAssets,
commodityAssets,
unavailable: false,
};
}
interface HyperliquidAssetView {
symbol: string;
display: string;
group: string;
funding: number | null;
oiDelta1h: number | null;
composite: number;
warmup: boolean;
stale: boolean;
sparkScore: number[];
}
interface HyperliquidFlowView {
ts: number;
warmup: boolean;
fxAssets: HyperliquidAssetView[];
commodityAssets: HyperliquidAssetView[];
unavailable: boolean;
}
// CCYUSD=X (e.g. EURUSD): USD is quote, rate = USD/FC → XAU_FC = XAU_USD / rate
// USDCCY=X (e.g. USDJPY, USDCHF): USD is base, rate = FC/USD → XAU_FC = XAU_USD * rate
@@ -363,6 +474,8 @@ export class CommoditiesPanel extends Panel {
private _tab: CommoditiesTab = 'commodities';
private _commodityData: Array<{ display: string; price: number | null; change: number | null; sparkline?: number[]; symbol?: string }> = [];
private _fxRates: EcbFxRateItem[] = [];
private _flow: HyperliquidFlowView | null = null;
private _flowLoading = false;
constructor() {
super({ id: 'commodities', title: t('panels.commodities'), infoTooltip: t('components.commodities.infoTooltip') });
@@ -370,13 +483,63 @@ export class CommoditiesPanel extends Panel {
this.content.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-tab]');
const tab = btn?.dataset.tab;
if (tab === 'commodities' || tab === 'fx' || (tab === 'xau' && SITE_VARIANT === 'commodity')) {
if (
tab === 'commodities' ||
tab === 'fx' ||
tab === 'flow' ||
(tab === 'xau' && SITE_VARIANT === 'commodity')
) {
this._tab = tab as CommoditiesTab;
this._render();
}
});
}
/**
* Fetch Hyperliquid perp positioning flow snapshot.
* Called from App.ts primeVisiblePanelData() and refreshScheduler.
* Uses bootstrap-hydrated data on first render if available (AGENTS.md mandates
* bootstrap hydration for new data sources), then refreshes from RPC.
*/
public async fetchHyperliquidFlow(): Promise<boolean> {
if (this._flowLoading) return false;
this._flowLoading = true;
try {
if (!this._flow) {
const hydrated = getHydratedData('hyperliquidFlow') as Record<string, unknown> | undefined;
if (hydrated && !hydrated.unavailable) {
const mapped = mapHyperliquidFlowSeed(hydrated);
if (mapped) {
this._flow = mapped;
if (this._tab === 'flow') this._render();
}
}
}
const { MarketServiceClient } = await import('@/generated/client/worldmonitor/market/v1/service_client');
const { getRpcBaseUrl } = await import('@/services/rpc-client');
const client = new MarketServiceClient(getRpcBaseUrl(), {
fetch: (...args: Parameters<typeof fetch>) => globalThis.fetch(...args),
});
const resp = await client.getHyperliquidFlow({});
if (resp.unavailable || !resp.assets || resp.assets.length === 0) {
if (!this._flow) this._flow = { ts: 0, warmup: true, fxAssets: [], commodityAssets: [], unavailable: true };
} else {
this._flow = mapHyperliquidFlowResponse(resp);
}
if (this._tab === 'flow') this._render();
return true;
} catch (err) {
console.error('[CommoditiesPanel.fetchHyperliquidFlow] RPC failed:', err instanceof Error ? err.message : err);
// Don't blow away an existing flow snapshot on transient fetch errors.
if (!this._flow) this._flow = { ts: 0, warmup: true, fxAssets: [], commodityAssets: [], unavailable: true };
if (this._tab === 'flow') this._render();
return false;
} finally {
this._flowLoading = false;
}
}
public renderCommodities(data: Array<{ symbol?: string; display: string; price: number | null; change: number | null; sparkline?: number[] }>): void {
this._commodityData = data;
this._render();
@@ -387,16 +550,63 @@ export class CommoditiesPanel extends Panel {
this._render();
}
private _buildTabBar(hasFx: boolean, hasXau: boolean): string {
private _buildTabBar(hasFx: boolean, hasXau: boolean, hasFlow: boolean): string {
const firstTabLabel = 'Commodities';
const tabs: string[] = [
`<button class="panel-tab${this._tab === 'commodities' ? ' active' : ''}" data-tab="commodities" style="font-size:11px;padding:3px 10px">${firstTabLabel}</button>`,
];
if (hasFx) tabs.push(`<button class="panel-tab${this._tab === 'fx' ? ' active' : ''}" data-tab="fx" style="font-size:11px;padding:3px 10px">EUR FX</button>`);
if (hasXau) tabs.push(`<button class="panel-tab${this._tab === 'xau' ? ' active' : ''}" data-tab="xau" style="font-size:11px;padding:3px 10px">XAU/FX</button>`);
if (hasFlow) tabs.push(`<button class="panel-tab${this._tab === 'flow' ? ' active' : ''}" data-tab="flow" style="font-size:11px;padding:3px 10px" title="Hyperliquid perp positioning stress (24/7 leading indicator)">Perp Flow</button>`);
return tabs.length > 1 ? `<div style="display:flex;gap:4px;margin-bottom:8px">${tabs.join('')}</div>` : '';
}
private _renderFlow(): string {
if (!this._flow) {
return `<div style="padding:8px;color:var(--text-dim);font-size:12px">Loading perp flow…</div>`;
}
if (this._flow.unavailable) {
return `<div style="padding:8px;color:var(--text-dim);font-size:12px">Perp flow snapshot unavailable. Warming up — first samples populate within 510 minutes.</div>`;
}
const sections: string[] = [];
if (this._flow.warmup) {
sections.push(`<div style="padding:6px 8px;background:rgba(230,126,34,0.10);color:#e67e22;border-radius:4px;font-size:10px;margin-bottom:6px">Warming up — volume/OI baselines build over the next ~12 polls (1h).</div>`);
}
if (this._flow.commodityAssets.length > 0) {
sections.push(`<div style="font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.5px;margin:4px 0 2px">Commodities</div>`);
sections.push(this._renderFlowGrid(this._flow.commodityAssets));
}
if (this._flow.fxAssets.length > 0) {
sections.push(`<div style="font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.5px;margin:8px 0 2px">FX (Crypto-Native)</div>`);
sections.push(this._renderFlowGrid(this._flow.fxAssets));
}
sections.push(`<div style="margin-top:6px;font-size:9px;color:var(--text-dim)">Composite 0100 from funding × volume × OI × basis · Hyperliquid /info · 5min cron</div>`);
return sections.join('');
}
private _renderFlowGrid(assets: HyperliquidAssetView[]): string {
return '<div class="commodities-grid">' + assets.map((a) => {
const score = Math.round(a.composite);
const scoreColor = score >= 60 ? '#e74c3c' : score >= 40 ? '#e67e22' : score >= 20 ? '#f1c40f' : 'var(--text-dim)';
const fundingStr = a.funding != null ? `${(a.funding * 100).toFixed(3)}%` : '—';
const fundingColor = a.funding != null && a.funding < 0 ? 'change-negative' : 'change-positive';
const oiStr = a.oiDelta1h != null ? `${a.oiDelta1h >= 0 ? '+' : ''}${(a.oiDelta1h * 100).toFixed(1)}%` : '—';
const oiColor = a.oiDelta1h != null && a.oiDelta1h < 0 ? 'change-negative' : 'change-positive';
const staleBadge = a.stale ? ` <span style="color:#e67e22;font-size:9px">stale</span>` : '';
const warmupBadge = a.warmup ? ` <span style="color:#888;font-size:9px">warm</span>` : '';
return `
<div class="commodity-item" title="${escapeHtml(a.symbol)} · score ${score}/100${a.warmup ? ' · warming up' : ''}${a.stale ? ' · upstream stale' : ''}">
<div class="commodity-name">${escapeHtml(a.display)}${staleBadge}${warmupBadge}</div>
${miniSparkline(a.sparkScore, score - 50, 60, 18)}
<div class="commodity-price" style="color:${scoreColor};font-weight:600">${score}</div>
<div style="display:flex;gap:6px;font-size:10px">
<span class="${fundingColor}" title="hourly funding">${escapeHtml(fundingStr)}</span>
<span class="${oiColor}" title="OI Δ1h">${escapeHtml(oiStr)}</span>
</div>
</div>`;
}).join('') + '</div>';
}
private _renderXau(): string {
const gcf = this._commodityData.find(d => d.symbol === 'GC=F' && d.price !== null);
if (!gcf?.price) return `<div style="padding:8px;color:var(--text-dim);font-size:12px">Gold price unavailable</div>`;
@@ -431,8 +641,15 @@ export class CommoditiesPanel extends Panel {
private _render(): void {
const hasFx = this._fxRates.length > 0;
const hasXau = SITE_VARIANT === 'commodity' && this._commodityData.some(d => d.symbol === 'GC=F' && d.price !== null);
const hasFlow = !!this._flow && (!this._flow.unavailable || this._flow.warmup);
if (this._tab === 'xau' && !hasXau) this._tab = 'commodities';
const tabBar = this._buildTabBar(hasFx, hasXau);
if (this._tab === 'flow' && !hasFlow) this._tab = 'commodities';
const tabBar = this._buildTabBar(hasFx, hasXau, hasFlow);
if (this._tab === 'flow') {
this.setContent(tabBar + this._renderFlow());
return;
}
if (this._tab === 'fx' && hasFx) {
const items = this._fxRates.map(r => {

View File

@@ -56,6 +56,7 @@ export const REFRESH_INTERVALS = {
wsbTickers: 10 * 60 * 1000,
crossSourceSignals: 15 * 60 * 1000,
hormuzTracker: 60 * 60 * 1000, // 1h — data updates daily
hyperliquidFlow: 5 * 60 * 1000, // 5min — matches Railway seed cadence
energyCrisis: 6 * 60 * 60 * 1000, // 6h — policy data updates infrequently
macroTiles: 30 * 60 * 1000,
fsi: 30 * 60 * 1000,

View File

@@ -611,6 +611,43 @@ export interface GoldCbMover {
deltaTonnes12m: number;
}
export interface GetHyperliquidFlowRequest {
}
export interface GetHyperliquidFlowResponse {
ts: string;
fetchedAt: string;
warmup: boolean;
assetCount: number;
assets: HyperliquidAssetFlow[];
unavailable: boolean;
}
export interface HyperliquidAssetFlow {
symbol: string;
display: string;
assetClass: string;
group: string;
funding: string;
openInterest: string;
markPx: string;
oraclePx: string;
dayNotional: string;
fundingScore: number;
volumeScore: number;
oiScore: number;
basisScore: number;
composite: number;
sparkFunding: number[];
sparkOi: number[];
sparkScore: number[];
warmup: boolean;
stale: boolean;
staleSince: string;
missingPolls: number;
alerts: string[];
}
export interface FieldViolation {
field: string;
description: string;
@@ -1197,6 +1234,29 @@ export class MarketServiceClient {
return await resp.json() as GetGoldIntelligenceResponse;
}
async getHyperliquidFlow(req: GetHyperliquidFlowRequest, options?: MarketServiceCallOptions): Promise<GetHyperliquidFlowResponse> {
let path = "/api/market/v1/get-hyperliquid-flow";
const url = this.baseURL + path;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.defaultHeaders,
...options?.headers,
};
const resp = await this.fetchFn(url, {
method: "GET",
headers,
signal: options?.signal,
});
if (!resp.ok) {
return this.handleError(resp);
}
return await resp.json() as GetHyperliquidFlowResponse;
}
private async handleError(resp: Response): Promise<never> {
const body = await resp.text();
if (resp.status === 400) {

View File

@@ -611,6 +611,43 @@ export interface GoldCbMover {
deltaTonnes12m: number;
}
export interface GetHyperliquidFlowRequest {
}
export interface GetHyperliquidFlowResponse {
ts: string;
fetchedAt: string;
warmup: boolean;
assetCount: number;
assets: HyperliquidAssetFlow[];
unavailable: boolean;
}
export interface HyperliquidAssetFlow {
symbol: string;
display: string;
assetClass: string;
group: string;
funding: string;
openInterest: string;
markPx: string;
oraclePx: string;
dayNotional: string;
fundingScore: number;
volumeScore: number;
oiScore: number;
basisScore: number;
composite: number;
sparkFunding: number[];
sparkOi: number[];
sparkScore: number[];
warmup: boolean;
stale: boolean;
staleSince: string;
missingPolls: number;
alerts: string[];
}
export interface FieldViolation {
field: string;
description: string;
@@ -678,6 +715,7 @@ export interface MarketServiceHandler {
getInsiderTransactions(ctx: ServerContext, req: GetInsiderTransactionsRequest): Promise<GetInsiderTransactionsResponse>;
getMarketBreadthHistory(ctx: ServerContext, req: GetMarketBreadthHistoryRequest): Promise<GetMarketBreadthHistoryResponse>;
getGoldIntelligence(ctx: ServerContext, req: GetGoldIntelligenceRequest): Promise<GetGoldIntelligenceResponse>;
getHyperliquidFlow(ctx: ServerContext, req: GetHyperliquidFlowRequest): Promise<GetHyperliquidFlowResponse>;
}
export function createMarketServiceRoutes(
@@ -1627,6 +1665,43 @@ export function createMarketServiceRoutes(
}
},
},
{
method: "GET",
path: "/api/market/v1/get-hyperliquid-flow",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const body = {} as GetHyperliquidFlowRequest;
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.getHyperliquidFlow(ctx, body);
return new Response(JSON.stringify(result as GetHyperliquidFlowResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err: unknown) {
if (err instanceof ValidationError) {
return new Response(JSON.stringify({ violations: err.violations }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (options?.onError) {
return options.onError(err, req);
}
const message = err instanceof Error ? err.message : String(err);
return new Response(JSON.stringify({ message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
},
},
];
}

View File

@@ -0,0 +1,358 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
ASSETS,
CANONICAL_KEY,
CACHE_TTL_SECONDS,
SPARK_MAX,
MIN_NOTIONAL_USD_24H,
STALE_SYMBOL_DROP_AFTER_POLLS,
WEIGHTS,
THRESHOLDS,
ALERT_THRESHOLD,
clamp,
scoreFunding,
scoreVolume,
scoreOi,
scoreBasis,
computeAsset,
validateUpstream,
indexBySymbol,
buildSnapshot,
validateFn,
} from '../scripts/seed-hyperliquid-flow.mjs';
const META_BTC = { symbol: 'BTC', class: 'crypto', display: 'BTC', group: 'crypto' };
const META_OIL = { symbol: 'xyz:CL', class: 'commodity', display: 'WTI Crude', group: 'oil' };
function makeUniverse(extra = []) {
// Build a universe with at least 50 entries so validateUpstream passes.
const filler = Array.from({ length: 50 }, (_, i) => ({ name: `FILL${i}` }));
return [...ASSETS.map((a) => ({ name: a.symbol })), ...filler, ...extra];
}
function makeAssetCtxs(universe, overrides = {}) {
return universe.map((u) => overrides[u.name] || {
funding: '0',
openInterest: '0',
markPx: '0',
oraclePx: '0',
dayNtlVlm: '0',
});
}
describe('TTL constants', () => {
it('CACHE_TTL_SECONDS is at least 9× cron cadence (5 min)', () => {
assert.ok(CACHE_TTL_SECONDS >= 9 * 5 * 60, `expected >= 2700, got ${CACHE_TTL_SECONDS}`);
});
it('CANONICAL_KEY is the documented v1 key', () => {
assert.equal(CANONICAL_KEY, 'market:hyperliquid:flow:v1');
});
});
describe('weights', () => {
it('sum to 1.0', () => {
const sum = WEIGHTS.funding + WEIGHTS.volume + WEIGHTS.oi + WEIGHTS.basis;
assert.ok(Math.abs(sum - 1.0) < 1e-9, `weights sum=${sum}`);
});
});
describe('clamp', () => {
it('bounds to [0,100] by default', () => {
assert.equal(clamp(150), 100);
assert.equal(clamp(-5), 0);
assert.equal(clamp(50), 50);
});
it('returns 0 for non-finite', () => {
assert.equal(clamp(NaN), 0);
assert.equal(clamp(Infinity), 0);
});
});
describe('scoreFunding (parity with risk.py)', () => {
it('|rate|/threshold * 100 clamped', () => {
assert.equal(scoreFunding(0.0005, 0.001), 50);
assert.equal(scoreFunding(-0.0005, 0.001), 50);
assert.equal(scoreFunding(0.002, 0.001), 100);
assert.equal(scoreFunding(0, 0.001), 0);
});
it('returns 0 on zero/negative threshold', () => {
assert.equal(scoreFunding(0.001, 0), 0);
});
});
describe('scoreVolume', () => {
it('ratio / threshold * 100', () => {
assert.equal(scoreVolume(2_000_000, 1_000_000, 5), 40);
assert.equal(scoreVolume(10_000_000, 1_000_000, 5), 100);
});
it('returns 0 if avg is 0', () => {
assert.equal(scoreVolume(1_000_000, 0, 5), 0);
});
});
describe('scoreOi', () => {
it('|delta|/prev / threshold * 100', () => {
assert.equal(scoreOi(120, 100, 0.20), 100); // 20% change vs 20% threshold → score 100
assert.equal(scoreOi(110, 100, 0.20), 50); // 10% change → half of threshold
});
it('returns 0 if prevOi <= 0', () => {
assert.equal(scoreOi(100, 0, 0.20), 0);
});
});
describe('scoreBasis', () => {
it('|mark-oracle|/oracle / threshold * 100', () => {
assert.equal(scoreBasis(105, 100, 0.05), 100); // exactly threshold
assert.equal(Math.round(scoreBasis(102.5, 100, 0.05)), 50);
});
});
describe('computeAsset min-notional guard', () => {
it('volumeScore = 0 when dayNotional below MIN_NOTIONAL_USD_24H, even with prior history', () => {
const prev = {
symbol: 'xyz:CL',
sparkVol: Array(12).fill(100_000),
sparkFunding: [],
sparkOi: [],
sparkScore: [],
openInterest: 1_000,
};
const ctx = { funding: '0', openInterest: '1000', markPx: '0', oraclePx: '0', dayNtlVlm: String(MIN_NOTIONAL_USD_24H - 1) };
const out = computeAsset(META_OIL, ctx, prev);
assert.equal(out.volumeScore, 0);
});
it('volumeScore > 0 when dayNotional above MIN_NOTIONAL with sufficient prior samples', () => {
const prev = {
symbol: 'xyz:CL',
sparkVol: Array(12).fill(MIN_NOTIONAL_USD_24H),
sparkFunding: [],
sparkOi: [],
sparkScore: [],
openInterest: 1_000,
};
const ctx = { funding: '0', openInterest: '1000', markPx: '0', oraclePx: '0', dayNtlVlm: String(MIN_NOTIONAL_USD_24H * 4) };
const out = computeAsset(META_OIL, ctx, prev);
assert.ok(out.volumeScore > 0, `expected >0, got ${out.volumeScore}`);
});
});
describe('computeAsset cold-start (no prev)', () => {
it('zeros volumeScore and oiScore on first run', () => {
const ctx = { funding: '0.0005', openInterest: '1000', markPx: '100', oraclePx: '100', dayNtlVlm: '5000000' };
const out = computeAsset(META_BTC, ctx, null, { coldStart: true });
assert.equal(out.oiScore, 0);
assert.equal(out.volumeScore, 0);
assert.ok(out.fundingScore > 0); // funding still computable
assert.equal(out.warmup, true);
});
});
describe('warmup persists until baseline is usable (not just first poll)', () => {
it('stays warmup=true after coldStart clears if volume baseline has <12 samples', () => {
// Second poll: coldStart=false, but only 1 prior vol sample.
const prev = {
symbol: 'BTC', openInterest: 1000,
sparkVol: [1_000_000],
sparkFunding: [], sparkOi: [1000], sparkScore: [],
};
const ctx = { funding: '0.0005', openInterest: '1010', markPx: '100', oraclePx: '100', dayNtlVlm: '5000000' };
const out = computeAsset(META_BTC, ctx, prev, { coldStart: false });
assert.equal(out.warmup, true, 'should stay warmup while baseline < 12 samples');
assert.equal(out.volumeScore, 0, 'volume scoring must wait for baseline');
});
it('clears warmup=false once baseline has >=12 samples AND prior OI exists', () => {
const prev = {
symbol: 'BTC', openInterest: 1000,
sparkVol: Array(12).fill(1_000_000),
sparkFunding: [], sparkOi: Array(12).fill(1000), sparkScore: [],
};
const ctx = { funding: '0.0001', openInterest: '1010', markPx: '100', oraclePx: '100', dayNtlVlm: '1000000' };
const out = computeAsset(META_BTC, ctx, prev, { coldStart: false });
assert.equal(out.warmup, false);
});
it('stays warmup=true when prior OI is missing even with full vol baseline', () => {
const prev = {
symbol: 'BTC', openInterest: null,
sparkVol: Array(12).fill(1_000_000),
sparkFunding: [], sparkOi: [], sparkScore: [],
};
const ctx = { funding: '0', openInterest: '1000', markPx: '100', oraclePx: '100', dayNtlVlm: '1000000' };
const out = computeAsset(META_BTC, ctx, prev, { coldStart: false });
assert.equal(out.warmup, true);
assert.equal(out.oiScore, 0);
});
});
describe('volume baseline uses the MOST RECENT window (slice(-12), not slice(0,12))', () => {
// Regression: sparkVol is newest-at-tail via shiftAndAppend. Using slice(0,12)
// anchors the baseline to the OLDEST window forever once len >= 12 + new samples
// keep appending. Verify the baseline tracks the newest 12 samples.
it('reflects recent-volume regime, not stale oldest-window baseline', () => {
// Tail = last 12 samples (recent baseline ~200k).
// Head = old samples (~1M). If we regress to slice(0,12), avg=1M and dayNotional=2M
// would score volume=~2/5=40. With correct slice(-12), avg=200k so 2M/200k=10x → score=100.
const sparkVol = [
...Array(20).fill(1_000_000), // oldest
...Array(12).fill(200_000), // newest (baseline)
];
const prev = {
symbol: 'BTC', openInterest: 1000,
sparkVol,
sparkFunding: [], sparkOi: Array(12).fill(1000), sparkScore: [],
};
const ctx = { funding: '0', openInterest: '1010', markPx: '100', oraclePx: '100', dayNtlVlm: '2000000' };
const out = computeAsset(META_BTC, ctx, prev, { coldStart: false });
// Recent-window baseline: 2M / 200k / 5 * 100 = 200 → clamp 100.
assert.equal(out.volumeScore, 100, `expected volume baseline to track recent window, got score=${out.volumeScore}`);
});
});
describe('validateUpstream', () => {
it('rejects non-tuple', () => {
assert.throws(() => validateUpstream({}), /tuple/);
});
it('rejects missing universe', () => {
assert.throws(() => validateUpstream([{}, []]), /universe/);
});
it('rejects too-small universe', () => {
const small = Array.from({ length: 10 }, (_, i) => ({ name: `X${i}` }));
assert.throws(() => validateUpstream([{ universe: small }, makeAssetCtxs(small)]), /suspiciously small/);
});
it('rejects mismatched assetCtxs length', () => {
const u = makeUniverse();
assert.throws(() => validateUpstream([{ universe: u }, []]), /length does not match/);
});
it('accepts well-formed tuple', () => {
const u = makeUniverse();
const ctxs = makeAssetCtxs(u);
const out = validateUpstream([{ universe: u }, ctxs]);
assert.equal(out.universe.length, u.length);
});
});
describe('buildSnapshot — first run', () => {
it('flags warmup and emits all whitelisted assets present in upstream', () => {
const u = makeUniverse();
const ctxs = makeAssetCtxs(u);
const snap = buildSnapshot([{ universe: u }, ctxs], null, { now: 1_700_000_000_000 });
assert.equal(snap.warmup, true);
assert.equal(snap.assets.length, ASSETS.length);
assert.ok(snap.assets.every((a) => a.warmup === true));
});
});
describe('buildSnapshot — missing-symbol carry-forward', () => {
it('carries forward a stale entry when whitelisted symbol absent from upstream', () => {
const u = makeUniverse().filter((m) => m.name !== 'BTC');
const ctxs = makeAssetCtxs(u);
const prevSnap = {
ts: 1_700_000_000_000 - 5 * 60_000, // 5min ago
assets: [{
symbol: 'BTC', display: 'BTC', class: 'crypto', group: 'crypto',
funding: 0.0001, openInterest: 1000, markPx: 65000, oraclePx: 65000, dayNotional: 1e9,
fundingScore: 10, volumeScore: 0, oiScore: 0, basisScore: 0, composite: 3,
sparkFunding: [0.0001], sparkOi: [1000], sparkScore: [3], sparkVol: [1e9],
stale: false, staleSince: null, missingPolls: 0, alerts: [], warmup: false,
}],
};
const snap = buildSnapshot([{ universe: u }, ctxs], prevSnap, { now: 1_700_000_000_000 });
const btc = snap.assets.find((a) => a.symbol === 'BTC');
assert.ok(btc, 'BTC should still appear');
assert.equal(btc.stale, true);
assert.equal(btc.missingPolls, 1);
});
it('drops a symbol after STALE_SYMBOL_DROP_AFTER_POLLS consecutive misses', () => {
const u = makeUniverse().filter((m) => m.name !== 'BTC');
const ctxs = makeAssetCtxs(u);
const prevSnap = {
ts: 1_700_000_000_000 - 5 * 60_000,
assets: [{
symbol: 'BTC', display: 'BTC', class: 'crypto', group: 'crypto',
funding: 0, openInterest: 1000, markPx: 0, oraclePx: 0, dayNotional: 0,
fundingScore: 0, volumeScore: 0, oiScore: 0, basisScore: 0, composite: 0,
sparkFunding: [], sparkOi: [], sparkScore: [], sparkVol: [],
stale: true, staleSince: 1_700_000_000_000 - 30 * 60_000,
missingPolls: STALE_SYMBOL_DROP_AFTER_POLLS - 1,
alerts: [], warmup: false,
}],
};
const snap = buildSnapshot([{ universe: u }, ctxs], prevSnap, { now: 1_700_000_000_000 });
assert.equal(snap.assets.find((a) => a.symbol === 'BTC'), undefined);
});
});
describe('buildSnapshot — post-outage cold start', () => {
it('zeroes deltas when prior snapshot is older than 900s', () => {
const u = makeUniverse();
const ctxs = makeAssetCtxs(u, {
BTC: { funding: '0.0005', openInterest: '2000', markPx: '65000', oraclePx: '65000', dayNtlVlm: '5000000' },
});
const prevSnap = {
ts: 1_700_000_000_000 - 60 * 60_000, // 1h ago — way past 900s threshold
assets: [{ symbol: 'BTC', openInterest: 1000, sparkVol: Array(12).fill(1e6) }],
};
const snap = buildSnapshot([{ universe: u }, ctxs], prevSnap, { now: 1_700_000_000_000 });
const btc = snap.assets.find((a) => a.symbol === 'BTC');
assert.equal(btc.warmup, true);
assert.equal(btc.oiScore, 0); // would be ~50 if prev OI was used
assert.equal(btc.volumeScore, 0); // would be >0 if prev vol samples were used
});
});
describe('sparkline arrays', () => {
it('cap at SPARK_MAX samples', () => {
const u = makeUniverse();
const ctxs = makeAssetCtxs(u, {
BTC: { funding: '0.0001', openInterest: '1000', markPx: '0', oraclePx: '0', dayNtlVlm: '0' },
});
const longArr = Array.from({ length: SPARK_MAX + 30 }, (_, i) => i);
const prevSnap = {
ts: 1_700_000_000_000 - 5 * 60_000,
assets: [{
symbol: 'BTC', sparkFunding: longArr, sparkOi: longArr, sparkScore: longArr, sparkVol: longArr,
openInterest: 1000,
}],
};
const snap = buildSnapshot([{ universe: u }, ctxs], prevSnap, { now: 1_700_000_000_000 });
const btc = snap.assets.find((a) => a.symbol === 'BTC');
assert.ok(btc.sparkFunding.length <= SPARK_MAX);
assert.ok(btc.sparkOi.length <= SPARK_MAX);
assert.ok(btc.sparkScore.length <= SPARK_MAX);
});
});
describe('validateFn (runSeed gate)', () => {
it('rejects empty / fewer than 12 assets', () => {
assert.equal(validateFn(null), false);
assert.equal(validateFn({ assets: [] }), false);
assert.equal(validateFn({ assets: Array(11).fill({}) }), false);
});
it('accepts >=12 assets', () => {
assert.equal(validateFn({ assets: Array(12).fill({}) }), true);
});
});
describe('alert threshold', () => {
it('emits HIGH RISK alert at composite >= 60', () => {
// Funding=100% × 0.30 + Basis=100% × 0.20 = 50; bump volume to push >60
const prev = {
symbol: 'BTC',
sparkVol: Array(12).fill(1_000_000),
sparkFunding: [], sparkOi: [], sparkScore: [],
openInterest: 1000,
};
const ctx = {
funding: '0.002', // 2× threshold → score 100
openInterest: '1500', // 50% delta vs 1000 → 250 → clamped to 100
markPx: '105', oraclePx: '100', // basis 5% = threshold → 100
dayNtlVlm: '10000000', // 10× avg → 200/5 → clamped 100
};
const out = computeAsset(META_BTC, ctx, prev);
assert.ok(out.composite >= ALERT_THRESHOLD, `composite=${out.composite}`);
assert.ok(out.alerts.some((a) => a.includes('HIGH RISK')));
});
});