Files
worldmonitor/api/bootstrap.js
Elie Habib e32d9b631c feat(market): Hyperliquid perp positioning flow as leading indicator (#3074)
* feat(market): Hyperliquid perp positioning flow as leading indicator

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

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

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

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

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

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

Correctness fixes:

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

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

Review nits:

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

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

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

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

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

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

269 lines
11 KiB
JavaScript

import { getCorsHeaders, getPublicCorsHeaders, isDisallowedOrigin } from './_cors.js';
import { validateApiKey } from './_api-key.js';
import { jsonResponse } from './_json-response.js';
// @ts-expect-error — JS module, no declaration file
import { redisPipeline } from './_upstash-json.js';
export const config = { runtime: 'edge' };
const BOOTSTRAP_CACHE_KEYS = {
earthquakes: 'seismology:earthquakes:v1',
outages: 'infra:outages:v1',
serviceStatuses: 'infra:service-statuses:v1',
ddosAttacks: 'cf:radar:ddos:v1',
trafficAnomalies: 'cf:radar:traffic-anomalies:v1',
marketQuotes: 'market:stocks-bootstrap:v1',
commodityQuotes: 'market:commodities-bootstrap:v1',
sectors: 'market:sectors:v2',
etfFlows: 'market:etf-flows:v1',
macroSignals: 'economic:macro-signals:v1',
bisPolicy: 'economic:bis:policy:v1',
bisExchange: 'economic:bis:eer:v1',
bisCredit: 'economic:bis:credit:v1',
bisDsr: 'economic:bis:dsr:v1',
bisPropertyResidential: 'economic:bis:property-residential:v1',
bisPropertyCommercial: 'economic:bis:property-commercial:v1',
imfMacro: 'economic:imf:macro:v2',
imfGrowth: 'economic:imf:growth:v1',
imfLabor: 'economic:imf:labor:v1',
imfExternal: 'economic:imf:external:v1',
shippingRates: 'supply_chain:shipping:v2',
chokepoints: 'supply_chain:chokepoints:v4',
minerals: 'supply_chain:minerals:v2',
giving: 'giving:summary:v1',
climateAnomalies: 'climate:anomalies:v2',
climateDisasters: 'climate:disasters:v1',
co2Monitoring: 'climate:co2-monitoring:v1',
oceanIce: 'climate:ocean-ice:v1',
climateNews: 'climate:news-intelligence:v1',
radiationWatch: 'radiation:observations:v1',
thermalEscalation: 'thermal:escalation:v1',
crossSourceSignals: 'intelligence:cross-source-signals:v1',
wildfires: 'wildfire:fires:v1',
cyberThreats: 'cyber:threats-bootstrap:v2',
techReadiness: 'economic:worldbank-techreadiness:v1',
progressData: 'economic:worldbank-progress:v1',
renewableEnergy: 'economic:worldbank-renewable:v1',
positiveGeoEvents: 'positive_events:geo-bootstrap:v1',
theaterPosture: 'theater_posture:sebuf:stale:v1',
riskScores: 'risk:scores:sebuf:stale:v1',
naturalEvents: 'natural:events:v1',
flightDelays: 'aviation:delays-bootstrap:v1',
insights: 'news:insights:v1',
predictions: 'prediction:markets-bootstrap:v1',
cryptoQuotes: 'market:crypto:v1',
cryptoSectors: 'market:crypto-sectors:v1',
defiTokens: 'market:defi-tokens:v1',
aiTokens: 'market:ai-tokens:v1',
otherTokens: 'market:other-tokens:v1',
gulfQuotes: 'market:gulf-quotes:v1',
stablecoinMarkets: 'market:stablecoins:v1',
unrestEvents: 'unrest:events:v1',
iranEvents: 'conflict:iran-events:v1',
ucdpEvents: 'conflict:ucdp-events:v1',
temporalAnomalies: 'temporal:anomalies:v1',
weatherAlerts: 'weather:alerts:v1',
spending: 'economic:spending:v1',
techEvents: 'research:tech-events-bootstrap:v1',
gdeltIntel: 'intelligence:gdelt-intel:v1',
correlationCards: 'correlation:cards-bootstrap:v1',
forecasts: 'forecast:predictions:v2',
securityAdvisories: 'intelligence:advisories-bootstrap:v1',
customsRevenue: 'trade:customs-revenue:v1',
sanctionsPressure: 'sanctions:pressure:v1',
consumerPricesOverview: 'consumer-prices:overview:ae',
consumerPricesCategories: 'consumer-prices:categories:ae:30d',
consumerPricesMovers: 'consumer-prices:movers:ae:30d',
consumerPricesSpread: 'consumer-prices:retailer-spread:ae:essentials-ae',
groceryBasket: 'economic:grocery-basket:v1',
bigmac: 'economic:bigmac:v1',
fuelPrices: 'economic:fuel-prices:v1',
faoFoodPriceIndex: 'economic:fao-ffpi:v1',
nationalDebt: 'economic:national-debt:v1',
euGasStorage: 'economic:eu-gas-storage:v1',
eurostatCountryData: 'economic:eurostat-country-data:v1',
eurostatHousePrices: 'economic:eurostat:house-prices:v1',
eurostatGovDebtQ: 'economic:eurostat:gov-debt-q:v1',
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',
euFsi: 'economic:fsi-eu:v1',
shippingStress: 'supply_chain:shipping_stress:v1',
socialVelocity: 'intelligence:social:reddit:v1',
wsbTickers: 'intelligence:wsb-tickers:v1',
pizzint: 'intelligence:pizzint:seed:v1',
diseaseOutbreaks: 'health:disease-outbreaks:v1',
economicStress: 'economic:stress-index:v1',
electricityPrices: 'energy:electricity:v1:index',
jodiOil: 'energy:jodi-oil:v1:_countries',
chokepointBaselines: 'energy:chokepoint-baselines:v1',
portwatchChokepointsRef: 'portwatch:chokepoints:ref:v1',
portwatchPortActivity: 'supply_chain:portwatch-ports:v1:_countries',
oilStocksAnalysis: 'energy:oil-stocks-analysis:v1',
lngVulnerability: 'energy:lng-vulnerability:v1',
sprPolicies: 'energy:spr-policies:v1',
energyCrisisPolicies: 'energy:crisis-policies:v1',
aaiiSentiment: 'market:aaii-sentiment:v1',
breadthHistory: 'market:breadth-history:v1',
};
const SLOW_KEYS = new Set([
'bisPolicy', 'bisExchange', 'bisCredit',
'bisDsr', 'bisPropertyResidential', 'bisPropertyCommercial',
'imfMacro', 'imfGrowth', 'imfLabor', 'imfExternal', 'minerals', 'giving',
'sectors', 'etfFlows', 'wildfires', 'climateAnomalies', 'climateDisasters', 'co2Monitoring', 'oceanIce', 'climateNews',
'radiationWatch', 'thermalEscalation', 'crossSourceSignals',
'cyberThreats', 'techReadiness', 'progressData', 'renewableEnergy',
'naturalEvents',
'cryptoQuotes', 'cryptoSectors', 'defiTokens', 'aiTokens', 'otherTokens',
'gulfQuotes', 'stablecoinMarkets', 'unrestEvents', 'ucdpEvents',
'techEvents',
'securityAdvisories',
'customsRevenue',
'sanctionsPressure',
'consumerPricesOverview', 'consumerPricesCategories', 'consumerPricesMovers', 'consumerPricesSpread',
'groceryBasket',
'bigmac',
'fuelPrices',
'faoFoodPriceIndex',
'nationalDebt',
'euGasStorage',
'eurostatCountryData',
'eurostatHousePrices',
'eurostatGovDebtQ',
'eurostatIndProd',
'marketImplications',
'fearGreedIndex',
'hyperliquidFlow',
'crudeInventories',
'natGasStorage',
'ecbFxRates',
'euFsi',
'diseaseOutbreaks',
'economicStress',
'pizzint',
'electricityPrices',
'jodiOil',
'chokepointBaselines',
'portwatchChokepointsRef',
'portwatchPortActivity',
'oilStocksAnalysis',
'lngVulnerability',
'sprPolicies',
'energyCrisisPolicies',
'aaiiSentiment',
'breadthHistory',
]);
const FAST_KEYS = new Set([
'earthquakes', 'outages', 'serviceStatuses', 'ddosAttacks', 'trafficAnomalies', 'macroSignals', 'chokepoints',
'marketQuotes', 'commodityQuotes', 'positiveGeoEvents', 'riskScores', 'flightDelays','insights', 'predictions',
'iranEvents', 'temporalAnomalies', 'weatherAlerts', 'spending', 'theaterPosture', 'gdeltIntel',
'correlationCards', 'forecasts', 'shippingRates', 'shippingStress', 'socialVelocity', 'wsbTickers',
]);
// No public/s-maxage: CF (in front of api.worldmonitor.app) ignores Vary: Origin and would
// pin ACAO: worldmonitor.app on cached responses, breaking CORS for preview deployments.
// Vercel CDN caching is handled by TIER_CDN_CACHE via CDN-Cache-Control below.
const TIER_CACHE = {
slow: 'max-age=300, stale-while-revalidate=600, stale-if-error=3600',
fast: 'max-age=60, stale-while-revalidate=120, stale-if-error=900',
};
const TIER_CDN_CACHE = {
slow: 'public, s-maxage=7200, stale-while-revalidate=1800, stale-if-error=7200',
fast: 'public, s-maxage=600, stale-while-revalidate=120, stale-if-error=900',
};
const NEG_SENTINEL = '__WM_NEG__';
async function getCachedJsonBatch(keys) {
const result = new Map();
if (keys.length === 0) return result;
// Always read unprefixed keys — bootstrap is a read-only consumer of
// production cache data. Preview/branch deploys don't run handlers that
// populate prefixed keys, so prefixing would always miss.
const pipeline = keys.map((k) => ['GET', k]);
const data = await redisPipeline(pipeline, 3000);
if (!data) return result;
for (let i = 0; i < keys.length; i++) {
const raw = data[i]?.result;
if (raw) {
try {
const parsed = JSON.parse(raw);
if (parsed !== NEG_SENTINEL) result.set(keys[i], parsed);
} catch { /* skip malformed */ }
}
}
return result;
}
export default async function handler(req) {
if (isDisallowedOrigin(req))
return new Response('Forbidden', { status: 403 });
const cors = getCorsHeaders(req);
if (req.method === 'OPTIONS')
return new Response(null, { status: 204, headers: cors });
const apiKeyResult = validateApiKey(req);
if (apiKeyResult.required && !apiKeyResult.valid)
return jsonResponse({ error: apiKeyResult.error }, 401, cors);
const url = new URL(req.url);
const tier = url.searchParams.get('tier');
let registry;
if (tier === 'slow' || tier === 'fast') {
const tierSet = tier === 'slow' ? SLOW_KEYS : FAST_KEYS;
registry = Object.fromEntries(Object.entries(BOOTSTRAP_CACHE_KEYS).filter(([k]) => tierSet.has(k)));
} else {
const requested = url.searchParams.get('keys')?.split(',').filter(Boolean).sort();
registry = requested
? Object.fromEntries(Object.entries(BOOTSTRAP_CACHE_KEYS).filter(([k]) => requested.includes(k)))
: BOOTSTRAP_CACHE_KEYS;
}
const keys = Object.values(registry);
const names = Object.keys(registry);
let cached;
try {
cached = await getCachedJsonBatch(keys);
} catch {
return jsonResponse({ data: {}, missing: names }, 200, { ...cors, 'Cache-Control': 'no-cache' });
}
const data = {};
const missing = [];
for (let i = 0; i < names.length; i++) {
const val = cached.get(keys[i]);
if (val !== undefined) {
// Strip seed-internal metadata not intended for API clients
if (names[i] === 'forecasts' && val != null && 'enrichmentMeta' in val) {
const { enrichmentMeta: _stripped, ...rest } = val;
data[names[i]] = rest;
} else {
data[names[i]] = val;
}
} else {
missing.push(names[i]);
}
}
const cacheControl = (tier && TIER_CACHE[tier]) || 'public, s-maxage=600, stale-while-revalidate=120, stale-if-error=900';
// Bootstrap data is fully public (world events, market prices, seismic data).
// Use ACAO: * so CF caches one entry valid for all origins, including Vercel
// preview deployments. Per-origin ACAO with Vary: Origin causes CF to pin the
// first origin's ACAO on the cached response, breaking CORS for other origins.
return jsonResponse({ data, missing }, 200, {
...getPublicCorsHeaders(),
'Cache-Control': cacheControl,
'CDN-Cache-Control': (tier && TIER_CDN_CACHE[tier]) || TIER_CDN_CACHE.fast,
});
}