mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
2
api/bootstrap.js
vendored
2
api/bootstrap.js
vendored
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
45
proto/worldmonitor/market/v1/get_hyperliquid_flow.proto
Normal file
45
proto/worldmonitor/market/v1/get_hyperliquid_flow.proto
Normal 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
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
333
scripts/seed-hyperliquid-flow.mjs
Normal file
333
scripts/seed-hyperliquid-flow.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
113
server/worldmonitor/market/v1/get-hyperliquid-flow.ts
Normal file
113
server/worldmonitor/market/v1/get-hyperliquid-flow.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
11
src/App.ts
11
src/App.ts
@@ -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(),
|
||||
|
||||
@@ -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 5–10 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 0–100 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 => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
358
tests/hyperliquid-flow-seed.test.mjs
Normal file
358
tests/hyperliquid-flow-seed.test.mjs
Normal 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')));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user