feat(gold): Gold Intelligence v2 — positioning depth, returns, drivers (#3034)

* feat(gold): richer Gold Intelligence panel with positioning, returns, drivers

* fix(gold): restore leveragedFunds fields and derive P/S netPct in legacy fallback

Review catch on PR #3034:

1. seed-cot.mjs stopped emitting leveragedFundsLong/Short during the v2
   refactor, which would zero out the Leveraged Funds bars in the existing
   CotPositioningPanel on the next seed run. Re-read lev_money_* from the
   TFF rows and keep the fields on the output (commodity rows don't have
   this breakdown, stay at 0).
2. get-gold-intelligence legacy fallback hardcoded producerSwap.netPct to 0,
   meaning a pre-v2 COT snapshot rendered a neutral 0% Producer/Swap bar
   on deploy until seed-cot reran. Derive netPct from dealerLong/dealerShort
   (same formula as the v2 seeder). OI share stays 0 because open_interest
   wasn't captured pre-migration; clearly documented now.

Tests: added two regression guards (leveragedFunds preserved for TFF,
commodity rows emit 0 for those fields).

* fix(gold): make enrichment layer monitored and honest about freshness

Review catch on PR #3034:

- seed-commodity-quotes now writes seed-meta:market:gold-extended via
  writeExtraKeyWithMeta on every successful run. Partial / failed fetches
  skip BOTH the data write and the meta bump, so health correctly reports
  STALE_SEED instead of masking a broken Yahoo fetch with a green check.
- Require both gold (core) AND at least one driver/silver before writing,
  so a half-successful run doesn't overwrite healthy prior data with a
  degraded payload.
- Handler no longer stamps updatedAt with new Date() when the enrichment
  key is missing. Emits empty string so the panel's freshness indicator
  shows "Updated —" with a dim dot, matching reality — enrichment is
  missing, not fresh.
- Health: goldExtended entry in STANDALONE_KEYS + SEED_META (maxStaleMin
  30, matching commodity quotes), and seed-health.js advertises the
  domain so upstream monitors pick it up.

The panel already gates session/returns/drivers sections on presence, so
legacy panels without the enrichment layer stay fully functional.
This commit is contained in:
Elie Habib
2026-04-12 22:53:32 +04:00
committed by GitHub
parent d19b32708c
commit ee66b6b5c2
12 changed files with 966 additions and 158 deletions

View File

@@ -169,6 +169,7 @@ const STANDALONE_KEYS = {
recoveryExternalDebt: 'resilience:recovery:external-debt:v1',
recoveryImportHhi: 'resilience:recovery:import-hhi:v1',
recoveryFuelStocks: 'resilience:recovery:fuel-stocks:v1',
goldExtended: 'market:gold-extended:v1',
};
const SEED_META = {
@@ -195,6 +196,7 @@ const SEED_META = {
newsInsights: { key: 'seed-meta:news:insights', maxStaleMin: 30 },
marketQuotes: { key: 'seed-meta:market:stocks', maxStaleMin: 30 },
commodityQuotes: { key: 'seed-meta:market:commodities', maxStaleMin: 30 },
goldExtended: { key: 'seed-meta:market:gold-extended', maxStaleMin: 30 },
// RPC/warm-ping keys — seed-meta written by relay loops or handlers
// serviceStatuses: moved to ON_DEMAND — RPC-populated, no dedicated seed, goes stale when no users visit
cableHealth: { key: 'seed-meta:cable-health', maxStaleMin: 90 }, // ais-relay warm-ping runs every 30min; 90min = 3× interval catches missed pings without false positives

View File

@@ -30,6 +30,7 @@ const SEED_DOMAINS = {
// Aligned with health.js SEED_META (intervalMin = maxStaleMin / 2)
'market:stocks': { key: 'seed-meta:market:stocks', intervalMin: 15 },
'market:commodities': { key: 'seed-meta:market:commodities', intervalMin: 15 },
'market:gold-extended': { key: 'seed-meta:market:gold-extended', intervalMin: 15 },
'market:sectors': { key: 'seed-meta:market:sectors', intervalMin: 15 },
'aviation:faa': { key: 'seed-meta:aviation:faa', intervalMin: 45 },
'news:insights': { key: 'seed-meta:news:insights', intervalMin: 15 },

File diff suppressed because one or more lines are too long

View File

@@ -1939,6 +1939,16 @@ components:
type: string
unavailable:
type: boolean
session:
$ref: '#/components/schemas/GoldSessionRange'
returns:
$ref: '#/components/schemas/GoldReturns'
range52w:
$ref: '#/components/schemas/GoldRange52w'
drivers:
type: array
items:
$ref: '#/components/schemas/GoldDriver'
GoldCrossCurrencyPrice:
type: object
properties:
@@ -1954,18 +1964,85 @@ components:
properties:
reportDate:
type: string
managedMoneyLong:
type: number
format: double
managedMoneyShort:
type: number
format: double
nextReleaseDate:
type: string
openInterest:
type: string
format: int64
managedMoney:
$ref: '#/components/schemas/GoldCotCategory'
producerSwap:
$ref: '#/components/schemas/GoldCotCategory'
GoldCotCategory:
type: object
properties:
longPositions:
type: string
format: int64
shortPositions:
type: string
format: int64
netPct:
type: number
format: double
dealerLong:
oiSharePct:
type: number
format: double
dealerShort:
wowNetDelta:
type: string
format: int64
GoldSessionRange:
type: object
properties:
dayHigh:
type: number
format: double
dayLow:
type: number
format: double
prevClose:
type: number
format: double
GoldReturns:
type: object
properties:
w1:
type: number
format: double
m1:
type: number
format: double
ytd:
type: number
format: double
y1:
type: number
format: double
GoldRange52w:
type: object
properties:
hi:
type: number
format: double
lo:
type: number
format: double
positionPct:
type: number
format: double
GoldDriver:
type: object
properties:
symbol:
type: string
label:
type: string
value:
type: number
format: double
changePct:
type: number
format: double
correlation30d:
type: number
format: double

View File

@@ -10,13 +10,47 @@ message GoldCrossCurrencyPrice {
double price = 3;
}
message GoldSessionRange {
double day_high = 1;
double day_low = 2;
double prev_close = 3;
}
message GoldReturns {
double w1 = 1;
double m1 = 2;
double ytd = 3;
double y1 = 4;
}
message GoldRange52w {
double hi = 1;
double lo = 2;
double position_pct = 3;
}
message GoldCotCategory {
int64 long_positions = 1;
int64 short_positions = 2;
double net_pct = 3;
double oi_share_pct = 4;
int64 wow_net_delta = 5;
}
message GoldCotPositioning {
string report_date = 1;
double managed_money_long = 2;
double managed_money_short = 3;
double net_pct = 4;
double dealer_long = 5;
double dealer_short = 6;
string next_release_date = 2;
int64 open_interest = 3;
GoldCotCategory managed_money = 4;
GoldCotCategory producer_swap = 5;
}
message GoldDriver {
string symbol = 1;
string label = 2;
double value = 3;
double change_pct = 4;
double correlation_30d = 5;
}
message GetGoldIntelligenceRequest {}
@@ -34,4 +68,8 @@ message GetGoldIntelligenceResponse {
GoldCotPositioning cot = 10;
string updated_at = 11;
bool unavailable = 12;
GoldSessionRange session = 13;
GoldReturns returns = 14;
GoldRange52w range_52w = 15;
repeated GoldDriver drivers = 16;
}

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { loadEnvFile, loadSharedConfig, sleep, runSeed, parseYahooChart, writeExtraKey } from './_seed-utils.mjs';
import { loadEnvFile, loadSharedConfig, sleep, runSeed, parseYahooChart, writeExtraKey, writeExtraKeyWithMeta, CHROME_UA } from './_seed-utils.mjs';
import { AV_PHYSICAL_MAP, fetchAvPhysicalCommodity, fetchAvBulkQuotes } from './_shared-av.mjs';
const commodityConfig = loadSharedConfig('commodities.json');
@@ -8,9 +8,151 @@ const commodityConfig = loadSharedConfig('commodities.json');
loadEnvFile(import.meta.url);
const CANONICAL_KEY = 'market:commodities-bootstrap:v1';
const GOLD_EXTENDED_KEY = 'market:gold-extended:v1';
const CACHE_TTL = 1800;
const YAHOO_DELAY_MS = 200;
const GOLD_HISTORY_SYMBOLS = ['GC=F', 'SI=F'];
const GOLD_DRIVER_SYMBOLS = [
{ symbol: '^TNX', label: 'US 10Y Yield' },
{ symbol: 'DX-Y.NYB', label: 'DXY' },
];
async function fetchYahooChart1y(symbol) {
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?range=1y&interval=1d`;
try {
const resp = await fetch(url, { headers: { 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(15_000) });
if (!resp.ok) return null;
const json = await resp.json();
const r = json?.chart?.result?.[0];
if (!r) return null;
const meta = r.meta;
const ts = r.timestamp || [];
const closes = r.indicators?.quote?.[0]?.close || [];
const history = ts.map((t, i) => ({ d: new Date(t * 1000).toISOString().slice(0, 10), c: closes[i] }))
.filter(p => p.c != null && Number.isFinite(p.c));
return {
symbol,
price: meta?.regularMarketPrice ?? null,
dayHigh: meta?.regularMarketDayHigh ?? null,
dayLow: meta?.regularMarketDayLow ?? null,
prevClose: meta?.chartPreviousClose ?? meta?.previousClose ?? null,
fiftyTwoWeekHigh: meta?.fiftyTwoWeekHigh ?? null,
fiftyTwoWeekLow: meta?.fiftyTwoWeekLow ?? null,
history,
};
} catch {
return null;
}
}
function computeReturns(history, currentPrice) {
if (!history.length || !Number.isFinite(currentPrice)) return { w1: 0, m1: 0, ytd: 0, y1: 0 };
const byAgo = (days) => {
const target = history[Math.max(0, history.length - 1 - days)];
return target?.c;
};
const firstOfYear = history.find(p => p.d.startsWith(new Date().getUTCFullYear().toString()))?.c
?? history[0].c;
const pct = (from) => from ? ((currentPrice - from) / from) * 100 : 0;
return {
w1: +pct(byAgo(5)).toFixed(2),
m1: +pct(byAgo(21)).toFixed(2),
ytd: +pct(firstOfYear).toFixed(2),
y1: +pct(history[0].c).toFixed(2),
};
}
function computeRange52w(history, currentPrice) {
if (!history.length) return { hi: 0, lo: 0, positionPct: 0 };
const closes = history.map(p => p.c);
const hi = Math.max(...closes);
const lo = Math.min(...closes);
const span = hi - lo;
const positionPct = span > 0 ? ((currentPrice - lo) / span) * 100 : 50;
return { hi: +hi.toFixed(2), lo: +lo.toFixed(2), positionPct: +positionPct.toFixed(1) };
}
// Pearson correlation over the last N aligned daily returns
function pearsonCorrelation(aReturns, bReturns) {
const n = Math.min(aReturns.length, bReturns.length);
if (n < 5) return 0;
const a = aReturns.slice(-n);
const b = bReturns.slice(-n);
const meanA = a.reduce((s, v) => s + v, 0) / n;
const meanB = b.reduce((s, v) => s + v, 0) / n;
let num = 0, denA = 0, denB = 0;
for (let i = 0; i < n; i++) {
const da = a[i] - meanA;
const db = b[i] - meanB;
num += da * db;
denA += da * da;
denB += db * db;
}
const denom = Math.sqrt(denA * denB);
return denom > 0 ? +(num / denom).toFixed(3) : 0;
}
function dailyReturns(history) {
const out = [];
for (let i = 1; i < history.length; i++) {
const prev = history[i - 1].c;
if (prev > 0) out.push((history[i].c - prev) / prev);
}
return out;
}
async function fetchGoldExtended() {
const goldHistory = {};
for (const sym of GOLD_HISTORY_SYMBOLS) {
await sleep(YAHOO_DELAY_MS);
const chart = await fetchYahooChart1y(sym);
if (chart) goldHistory[sym] = chart;
}
const drivers = [];
const goldReturns = goldHistory['GC=F'] ? dailyReturns(goldHistory['GC=F'].history) : [];
for (const cfg of GOLD_DRIVER_SYMBOLS) {
await sleep(YAHOO_DELAY_MS);
const chart = await fetchYahooChart1y(cfg.symbol);
if (!chart || chart.price == null) continue;
const changePct = chart.prevClose ? ((chart.price - chart.prevClose) / chart.prevClose) * 100 : 0;
const driverReturns = dailyReturns(chart.history).slice(-30);
const goldLast30 = goldReturns.slice(-30);
const correlation = pearsonCorrelation(goldLast30, driverReturns);
drivers.push({
symbol: cfg.symbol,
label: cfg.label,
value: +chart.price.toFixed(2),
changePct: +changePct.toFixed(2),
correlation30d: correlation,
});
}
const gold = goldHistory['GC=F'];
const silver = goldHistory['SI=F'];
const build = (chart) => {
if (!chart || chart.price == null) return null;
return {
price: chart.price,
dayHigh: chart.dayHigh ?? 0,
dayLow: chart.dayLow ?? 0,
prevClose: chart.prevClose ?? 0,
returns: computeReturns(chart.history, chart.price),
range52w: computeRange52w(chart.history, chart.price),
};
};
return {
updatedAt: new Date().toISOString(),
gold: build(gold),
silver: build(silver),
drivers,
};
}
async function fetchYahooWithRetry(url, label, maxAttempts = 4) {
for (let i = 0; i < maxAttempts; i++) {
const resp = await fetch(url, {
@@ -120,6 +262,25 @@ runSeed('market', 'commodities', CANONICAL_KEY, fetchAndStash, {
const quotesPayload = { ...seedData, finnhubSkipped: false, skipReason: '', rateLimited: false };
await writeExtraKey(commodityKey, seedData, CACHE_TTL);
await writeExtraKey(quotesKey, quotesPayload, CACHE_TTL);
try {
const extended = await fetchGoldExtended();
// Require gold (the core metal) AND at least one driver or silver. Writing a
// partial payload would overwrite a healthy prior key with degraded data and
// stamp seed-meta as fresh, masking a broken Yahoo fetch in health checks.
const hasCore = extended.gold != null;
const hasContext = extended.silver != null || extended.drivers.length > 0;
if (hasCore && hasContext) {
const recordCount = (extended.gold ? 1 : 0) + (extended.silver ? 1 : 0) + extended.drivers.length;
await writeExtraKeyWithMeta(GOLD_EXTENDED_KEY, extended, CACHE_TTL, recordCount, 'seed-meta:market:gold-extended');
console.log(` [Gold] extended: gold=${!!extended.gold} silver=${!!extended.silver} drivers=${extended.drivers.length}`);
} else {
// Preserve prior key (if any) and do NOT bump seed-meta — health will flag stale.
console.warn(` [Gold] extended: incomplete (gold=${!!extended.gold} silver=${!!extended.silver} drivers=${extended.drivers.length}) — skipping write, letting seed-meta go stale`);
}
} catch (e) {
console.warn(` [Gold] extended fetch error: ${e?.message || e} — skipping write, letting seed-meta go stale`);
}
}).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

@@ -6,8 +6,6 @@ loadEnvFile(import.meta.url);
const COT_KEY = 'market:cot:v1';
const COT_TTL = 604800;
// Financial futures: TFF Combined report (Socrata yw9f-hn96)
// Fields: dealer_positions_long_all, asset_mgr_positions_long, lev_money_positions_long
const FINANCIAL_INSTRUMENTS = [
{ name: 'S&P 500 E-Mini', code: 'ES', pattern: /E-MINI S&P 500 - CHICAGO/i },
{ name: 'Nasdaq 100 E-Mini', code: 'NQ', pattern: /^NASDAQ MINI - CHICAGO/i },
@@ -17,12 +15,9 @@ const FINANCIAL_INSTRUMENTS = [
{ name: 'USD/JPY', code: 'JY', pattern: /JAPANESE YEN - CHICAGO/i },
];
// Physical commodities: Disaggregated Combined report (Socrata rxbv-e226)
// Fields: swap_positions_long_all, m_money_positions_long_all (no lev_money equivalent)
// cftc_contract_market_code used for precise filtering — avoids fragile name matching
const COMMODITY_INSTRUMENTS = [
{ name: 'Gold', code: 'GC', contractCode: '088691' },
{ name: 'Crude Oil (WTI)', code: 'CL', contractCode: '067651' }, // WTI-PHYSICAL NYMEX
{ name: 'Crude Oil (WTI)', code: 'CL', contractCode: '067651' },
];
function parseDate(raw) {
@@ -39,6 +34,19 @@ function parseDate(raw) {
return s.slice(0, 10);
}
// CFTC releases COT every Friday ~3:30pm ET for Tuesday data. Given a reportDate
// (Tuesday), the NEXT release is the Friday of the same week (reportDate + 3 days).
// If today is already past that Friday, the next Tuesday's data releases the
// following Friday — but we only call this with the *latest* stored row, so the
// next release is always reportDate + 3 days.
export function computeNextCotRelease(reportDate) {
if (!reportDate) return '';
const d = new Date(`${reportDate}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return '';
d.setUTCDate(d.getUTCDate() + 3);
return d.toISOString().slice(0, 10);
}
async function fetchSocrata(datasetId, extraParams = '') {
const url =
`https://publicreporting.cftc.gov/resource/${datasetId}.json` +
@@ -51,30 +59,93 @@ async function fetchSocrata(datasetId, extraParams = '') {
return resp.json();
}
async function fetchCotData() {
export function buildInstrument(target, currentRow, priorRow, kind) {
const toNum = v => {
const n = parseInt(String(v ?? '').replace(/,/g, '').trim(), 10);
return isNaN(n) ? 0 : n;
return Number.isNaN(n) ? 0 : n;
};
let financialRows, commodityRows;
const reportDate = parseDate(currentRow.report_date_as_yyyy_mm_dd ?? '');
const openInterest = toNum(currentRow.open_interest_all);
let mmLong, mmShort, psLong, psShort, priorMmNet, priorPsNet;
let leveragedFundsLong = 0;
let leveragedFundsShort = 0;
if (kind === 'financial') {
mmLong = toNum(currentRow.asset_mgr_positions_long);
mmShort = toNum(currentRow.asset_mgr_positions_short);
psLong = toNum(currentRow.dealer_positions_long_all);
psShort = toNum(currentRow.dealer_positions_short_all);
// TFF report also exposes leveraged-funds positions — consumed by CotPositioningPanel.
leveragedFundsLong = toNum(currentRow.lev_money_positions_long);
leveragedFundsShort = toNum(currentRow.lev_money_positions_short);
if (priorRow) {
priorMmNet = toNum(priorRow.asset_mgr_positions_long) - toNum(priorRow.asset_mgr_positions_short);
priorPsNet = toNum(priorRow.dealer_positions_long_all) - toNum(priorRow.dealer_positions_short_all);
}
} else {
mmLong = toNum(currentRow.m_money_positions_long_all);
mmShort = toNum(currentRow.m_money_positions_short_all);
psLong = toNum(currentRow.swap_positions_long_all);
psShort = toNum(currentRow.swap__positions_short_all);
if (priorRow) {
priorMmNet = toNum(priorRow.m_money_positions_long_all) - toNum(priorRow.m_money_positions_short_all);
priorPsNet = toNum(priorRow.swap_positions_long_all) - toNum(priorRow.swap__positions_short_all);
}
}
const mkCategory = (long, short, priorNet) => {
const gross = Math.max(long + short, 1);
const netPct = ((long - short) / gross) * 100;
const oiSharePct = openInterest > 0 ? ((long + short) / openInterest) * 100 : 0;
const wowNetDelta = priorNet != null ? (long - short) - priorNet : 0;
return {
longPositions: long,
shortPositions: short,
netPct: parseFloat(netPct.toFixed(2)),
oiSharePct: parseFloat(oiSharePct.toFixed(2)),
wowNetDelta,
};
};
const managedMoney = mkCategory(mmLong, mmShort, priorMmNet);
const producerSwap = mkCategory(psLong, psShort, priorPsNet);
return {
name: target.name,
code: target.code,
reportDate,
nextReleaseDate: computeNextCotRelease(reportDate),
openInterest,
managedMoney,
producerSwap,
// legacy flat fields consumed by get-cot-positioning.ts / CotPositioningPanel
assetManagerLong: mmLong,
assetManagerShort: mmShort,
leveragedFundsLong,
leveragedFundsShort,
dealerLong: psLong,
dealerShort: psShort,
netPct: managedMoney.netPct,
};
}
async function fetchCotData() {
let financialRows = [];
let commodityRows = [];
try {
// yw9f-hn96: TFF Combined — financial futures (ES, NQ, ZN, ZT, EC, JY)
// Fields: dealer_positions_long_all, asset_mgr_positions_long, lev_money_positions_long
financialRows = await fetchSocrata('yw9f-hn96');
} catch (e) {
console.warn(` CFTC TFF fetch failed: ${e.message}`);
financialRows = [];
}
try {
// rxbv-e226: Disaggregated All Combined — physical commodities (GC, CL)
// Fields: swap_positions_long_all, m_money_positions_long_all
// Filter by contract code — more reliable than name pattern matching
const codeList = COMMODITY_INSTRUMENTS.map(i => `%27${i.contractCode}%27`).join('%2C');
commodityRows = await fetchSocrata('rxbv-e226', `%20AND%20cftc_contract_market_code%20IN%28${codeList}%29`);
} catch (e) {
console.warn(` CFTC Disaggregated fetch failed: ${e.message}`);
commodityRows = [];
}
if (!financialRows.length && !commodityRows.length) {
@@ -85,45 +156,34 @@ async function fetchCotData() {
const instruments = [];
let latestReportDate = '';
const pushInstrument = (target, row, amLong, amShort, levLong, levShort, dealerLong, dealerShort) => {
const reportDate = parseDate(row.report_date_as_yyyy_mm_dd ?? '');
if (reportDate && !latestReportDate) latestReportDate = reportDate;
const netPct = ((amLong - amShort) / Math.max(amLong + amShort, 1)) * 100;
instruments.push({
name: target.name, code: target.code, reportDate,
assetManagerLong: amLong, assetManagerShort: amShort,
leveragedFundsLong: levLong, leveragedFundsShort: levShort,
dealerLong, dealerShort,
netPct: parseFloat(netPct.toFixed(2)),
});
console.log(` ${target.code}: AM net ${netPct.toFixed(1)}% (${amLong}L / ${amShort}S), date=${reportDate}`);
const findPair = (rows, predicate) => {
const matches = rows.filter(predicate);
// Sorted DESC already; index 0 = current, index 1 = prior week
return [matches[0], matches[1]];
};
for (const target of FINANCIAL_INSTRUMENTS) {
const row = financialRows.find(r => target.pattern.test(r.market_and_exchange_names ?? ''));
if (!row) { console.warn(` CFTC: no row for ${target.name}`); continue; }
pushInstrument(target, row,
toNum(row.asset_mgr_positions_long), toNum(row.asset_mgr_positions_short),
toNum(row.lev_money_positions_long), toNum(row.lev_money_positions_short),
toNum(row.dealer_positions_long_all), toNum(row.dealer_positions_short_all),
);
const [current, prior] = findPair(financialRows, r => target.pattern.test(r.market_and_exchange_names ?? ''));
if (!current) { console.warn(` CFTC: no row for ${target.name}`); continue; }
const inst = buildInstrument(target, current, prior, 'financial');
if (inst.reportDate && !latestReportDate) latestReportDate = inst.reportDate;
instruments.push(inst);
console.log(` ${inst.code}: MM net ${inst.managedMoney.netPct}% Δ${inst.managedMoney.wowNetDelta}, OI ${inst.openInterest}, date=${inst.reportDate}`);
}
for (const target of COMMODITY_INSTRUMENTS) {
const row = commodityRows.find(r => r.cftc_contract_market_code === target.contractCode);
if (!row) { console.warn(` CFTC: no row for ${target.name}`); continue; }
// Physical commodity disaggregated: managed money → assetManager, swap dealers → dealer
pushInstrument(target, row,
toNum(row.m_money_positions_long_all), toNum(row.m_money_positions_short_all),
0, 0,
toNum(row.swap_positions_long_all), toNum(row.swap__positions_short_all),
);
const [current, prior] = findPair(commodityRows, r => r.cftc_contract_market_code === target.contractCode);
if (!current) { console.warn(` CFTC: no row for ${target.name}`); continue; }
const inst = buildInstrument(target, current, prior, 'commodity');
if (inst.reportDate && !latestReportDate) latestReportDate = inst.reportDate;
instruments.push(inst);
console.log(` ${inst.code}: MM net ${inst.managedMoney.netPct}% Δ${inst.managedMoney.wowNetDelta}, OI ${inst.openInterest}, date=${inst.reportDate}`);
}
return { instruments, reportDate: latestReportDate };
}
if (process.argv[1] && process.argv[1].endsWith('seed-cot.mjs')) {
if (process.argv[1]?.endsWith('seed-cot.mjs')) {
runSeed('market', 'cot', COT_KEY, fetchCotData, {
ttlSeconds: COT_TTL,
validateFn: data => Array.isArray(data?.instruments) && data.instruments.length > 0,

View File

@@ -4,11 +4,17 @@ import type {
GetGoldIntelligenceResponse,
GoldCrossCurrencyPrice,
GoldCotPositioning,
GoldCotCategory,
GoldSessionRange,
GoldReturns,
GoldRange52w,
GoldDriver,
} from '../../../../src/generated/server/worldmonitor/market/v1/service_server';
import { getCachedJson } from '../../../_shared/redis';
const COMMODITY_KEY = 'market:commodities-bootstrap:v1';
const COT_KEY = 'market:cot:v1';
const GOLD_EXTENDED_KEY = 'market:gold-extended:v1';
interface RawQuote {
symbol: string;
@@ -19,15 +25,52 @@ interface RawQuote {
sparkline?: number[];
}
interface RawCotCategory {
longPositions: number;
shortPositions: number;
netPct: number;
oiSharePct: number;
wowNetDelta: number;
}
interface RawCotInstrument {
name: string;
code: string;
reportDate: string;
assetManagerLong: number;
assetManagerShort: number;
dealerLong: number;
dealerShort: number;
netPct: number;
nextReleaseDate?: string;
openInterest?: number;
managedMoney?: RawCotCategory;
producerSwap?: RawCotCategory;
// legacy
assetManagerLong?: number;
assetManagerShort?: number;
dealerLong?: number;
dealerShort?: number;
netPct?: number;
}
interface GoldExtendedMetal {
price: number;
dayHigh: number;
dayLow: number;
prevClose: number;
returns: { w1: number; m1: number; ytd: number; y1: number };
range52w: { hi: number; lo: number; positionPct: number };
}
interface GoldExtendedDriver {
symbol: string;
label: string;
value: number;
changePct: number;
correlation30d: number;
}
interface GoldExtendedPayload {
updatedAt: string;
gold?: GoldExtendedMetal | null;
silver?: GoldExtendedMetal | null;
drivers?: GoldExtendedDriver[];
}
const XAU_FX = [
@@ -39,27 +82,91 @@ const XAU_FX = [
{ symbol: 'USDCHF=X', label: 'CHF', flag: '\u{1F1E8}\u{1F1ED}', multiply: false },
];
function emptyResponse(): GetGoldIntelligenceResponse {
return {
goldPrice: 0,
goldChangePct: 0,
goldSparkline: [],
silverPrice: 0,
platinumPrice: 0,
palladiumPrice: 0,
crossCurrencyPrices: [],
drivers: [],
updatedAt: '',
unavailable: true,
};
}
function mapCategory(c: RawCotCategory | undefined): GoldCotCategory | undefined {
if (!c) return undefined;
return {
longPositions: String(Math.round(c.longPositions ?? 0)),
shortPositions: String(Math.round(c.shortPositions ?? 0)),
netPct: Number(c.netPct ?? 0),
oiSharePct: Number(c.oiSharePct ?? 0),
wowNetDelta: String(Math.round(c.wowNetDelta ?? 0)),
};
}
function mapCot(raw: RawCotInstrument | undefined): GoldCotPositioning | undefined {
if (!raw) return undefined;
// Legacy fallback: derive v2 category fields from flat long/short so a
// pre-migration seed payload still renders the new panel correctly. OI share
// stays 0 because old payloads don't carry open_interest; WoW delta stays 0
// because the prior-week row wasn't captured before this migration.
const netPctFrom = (long: number, short: number) => {
const gross = Math.max(long + short, 1);
return ((long - short) / gross) * 100;
};
const mmLong = raw.assetManagerLong ?? 0;
const mmShort = raw.assetManagerShort ?? 0;
const psLong = raw.dealerLong ?? 0;
const psShort = raw.dealerShort ?? 0;
const managedMoney = raw.managedMoney
? mapCategory(raw.managedMoney)
: mapCategory({
longPositions: mmLong,
shortPositions: mmShort,
netPct: raw.netPct ?? netPctFrom(mmLong, mmShort),
oiSharePct: 0,
wowNetDelta: 0,
});
const producerSwap = raw.producerSwap
? mapCategory(raw.producerSwap)
: mapCategory({
longPositions: psLong,
shortPositions: psShort,
netPct: netPctFrom(psLong, psShort),
oiSharePct: 0,
wowNetDelta: 0,
});
return {
reportDate: String(raw.reportDate ?? ''),
nextReleaseDate: String(raw.nextReleaseDate ?? ''),
openInterest: String(Math.round(raw.openInterest ?? 0)),
managedMoney,
producerSwap,
};
}
export async function getGoldIntelligence(
_ctx: ServerContext,
_req: GetGoldIntelligenceRequest,
): Promise<GetGoldIntelligenceResponse> {
try {
const [rawPayload, rawCot] = await Promise.all([
const [rawPayload, rawCot, rawExtended] = await Promise.all([
getCachedJson(COMMODITY_KEY, true) as Promise<{ quotes?: RawQuote[] } | null>,
getCachedJson(COT_KEY, true) as Promise<{ instruments?: RawCotInstrument[]; reportDate?: string } | null>,
getCachedJson(GOLD_EXTENDED_KEY, true) as Promise<GoldExtendedPayload | null>,
]);
const rawQuotes = rawPayload?.quotes;
if (!rawQuotes || !Array.isArray(rawQuotes) || rawQuotes.length === 0) {
return { goldPrice: 0, goldChangePct: 0, goldSparkline: [], silverPrice: 0, platinumPrice: 0, palladiumPrice: 0, crossCurrencyPrices: [], updatedAt: '', unavailable: true };
}
if (!rawQuotes || !Array.isArray(rawQuotes) || rawQuotes.length === 0) return emptyResponse();
const quoteMap = new Map(rawQuotes.map(q => [q.symbol, q]));
const gold = quoteMap.get('GC=F');
if (!gold) {
return { goldPrice: 0, goldChangePct: 0, goldSparkline: [], silverPrice: 0, platinumPrice: 0, palladiumPrice: 0, crossCurrencyPrices: [], updatedAt: '', unavailable: true };
}
if (!gold) return emptyResponse();
const silver = quoteMap.get('SI=F');
const platinum = quoteMap.get('PL=F');
const palladium = quoteMap.get('PA=F');
@@ -70,7 +177,9 @@ export async function getGoldIntelligence(
const palladiumPrice = palladium?.price ?? 0;
const goldSilverRatio = (goldPrice > 0 && silverPrice > 0) ? goldPrice / silverPrice : undefined;
const goldPlatinumPremiumPct = (goldPrice > 0 && platinumPrice > 0) ? ((goldPrice - platinumPrice) / platinumPrice) * 100 : undefined;
const goldPlatinumPremiumPct = (goldPrice > 0 && platinumPrice > 0)
? ((goldPrice - platinumPrice) / platinumPrice) * 100
: undefined;
const crossCurrencyPrices: GoldCrossCurrencyPrice[] = [];
if (goldPrice > 0) {
@@ -83,20 +192,21 @@ export async function getGoldIntelligence(
}
}
let cot: GoldCotPositioning | undefined;
if (rawCot?.instruments) {
const gc = rawCot.instruments.find(i => i.code === 'GC');
if (gc) {
cot = {
reportDate: String(gc.reportDate ?? rawCot.reportDate ?? ''),
managedMoneyLong: Number(gc.assetManagerLong ?? 0),
managedMoneyShort: Number(gc.assetManagerShort ?? 0),
netPct: Number(gc.netPct ?? 0),
dealerLong: Number(gc.dealerLong ?? 0),
dealerShort: Number(gc.dealerShort ?? 0),
};
}
}
const cot = mapCot(rawCot?.instruments?.find(i => i.code === 'GC'));
const goldExt = rawExtended?.gold;
const session: GoldSessionRange | undefined = goldExt
? { dayHigh: goldExt.dayHigh, dayLow: goldExt.dayLow, prevClose: goldExt.prevClose }
: undefined;
const returns: GoldReturns | undefined = goldExt ? { ...goldExt.returns } : undefined;
const range52w: GoldRange52w | undefined = goldExt ? { ...goldExt.range52w } : undefined;
const drivers: GoldDriver[] = (rawExtended?.drivers ?? []).map(d => ({
symbol: d.symbol,
label: d.label,
value: d.value,
changePct: d.changePct,
correlation30d: d.correlation30d,
}));
return {
goldPrice,
@@ -109,10 +219,18 @@ export async function getGoldIntelligence(
goldPlatinumPremiumPct,
crossCurrencyPrices,
cot,
updatedAt: new Date().toISOString(),
session,
returns,
range52w,
drivers,
// updatedAt reflects the *enrichment* layer's freshness. If the extended
// key is missing we deliberately emit empty so the panel renders "Updated —"
// rather than a misleading "just now" stamp while session/returns/drivers
// are all absent.
updatedAt: rawExtended?.updatedAt ?? '',
unavailable: false,
};
} catch {
return { goldPrice: 0, goldChangePct: 0, goldSparkline: [], silverPrice: 0, platinumPrice: 0, palladiumPrice: 0, crossCurrencyPrices: [], updatedAt: '', unavailable: true };
return emptyResponse();
}
}

View File

@@ -4,20 +4,19 @@ import { escapeHtml } from '@/utils/sanitize';
import { toApiUrl } from '@/services/runtime';
import { miniSparkline } from '@/utils/sparkline';
interface CrossCurrencyPrice {
currency: string;
flag: string;
price: number;
}
interface CrossCurrencyPrice { currency: string; flag: string; price: number }
interface CotCategory { longPositions: string; shortPositions: string; netPct: number; oiSharePct: number; wowNetDelta: string }
interface CotData {
reportDate: string;
managedMoneyLong: number;
managedMoneyShort: number;
netPct: number;
dealerLong: number;
dealerShort: number;
nextReleaseDate: string;
openInterest: string;
managedMoney?: CotCategory;
producerSwap?: CotCategory;
}
interface SessionRange { dayHigh: number; dayLow: number; prevClose: number }
interface Returns { w1: number; m1: number; ytd: number; y1: number }
interface Range52w { hi: number; lo: number; positionPct: number }
interface Driver { symbol: string; label: string; value: number; changePct: number; correlation30d: number }
interface GoldIntelligenceData {
goldPrice: number;
@@ -30,6 +29,10 @@ interface GoldIntelligenceData {
goldPlatinumPremiumPct?: number;
crossCurrencyPrices: CrossCurrencyPrice[];
cot?: CotData;
session?: SessionRange;
returns?: Returns;
range52w?: Range52w;
drivers: Driver[];
updatedAt: string;
unavailable?: boolean;
}
@@ -39,16 +42,62 @@ function fmtPrice(v: number, decimals = 2): string {
return v >= 10000 ? Math.round(v).toLocaleString() : v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
}
function renderPositionBar(netPct: number, label: string): string {
function fmtInt(raw: string | number): string {
const n = typeof raw === 'string' ? parseInt(raw, 10) : raw;
if (!Number.isFinite(n)) return '--';
return Math.round(n).toLocaleString();
}
function fmtPct(v: number, decimals = 2): string {
if (!Number.isFinite(v)) return '--';
const sign = v >= 0 ? '+' : '';
return `${sign}${v.toFixed(decimals)}%`;
}
function fmtSignedInt(raw: string | number): string {
const n = typeof raw === 'string' ? parseInt(raw, 10) : raw;
if (!Number.isFinite(n)) return '--';
const sign = n >= 0 ? '+' : '';
return `${sign}${Math.round(n).toLocaleString()}`;
}
function freshnessLabel(iso: string): { text: string; dot: string } {
if (!iso) return { text: 'Updated —', dot: 'var(--text-dim)' };
const diffMs = Date.now() - new Date(iso).getTime();
if (!Number.isFinite(diffMs) || diffMs < 0) return { text: 'Updated now', dot: '#2ecc71' };
const mins = Math.floor(diffMs / 60000);
const dot = mins < 10 ? '#2ecc71' : mins < 30 ? '#f5a623' : '#e74c3c';
if (mins < 1) return { text: 'Updated just now', dot };
if (mins < 60) return { text: `Updated ${mins}m ago`, dot };
const hrs = Math.floor(mins / 60);
return { text: `Updated ${hrs}h ago`, dot };
}
function renderRangeBar(lo: number, hi: number, current: number, positionPct: number): string {
const clamped = Math.max(0, Math.min(100, positionPct));
return `
<div style="position:relative;height:8px;background:linear-gradient(90deg,rgba(231,76,60,0.25),rgba(245,166,35,0.25),rgba(46,204,113,0.25));border-radius:4px;margin:6px 0">
<div style="position:absolute;top:-3px;bottom:-3px;left:${clamped.toFixed(1)}%;width:3px;background:#fff;border-radius:1px;box-shadow:0 0 4px rgba(255,255,255,0.8);transform:translateX(-50%)"></div>
</div>
<div style="display:flex;justify-content:space-between;font-size:9px;color:var(--text-dim)">
<span>Low $${escapeHtml(fmtPrice(lo))}</span>
<span style="color:var(--text);font-weight:600">$${escapeHtml(fmtPrice(current))}${clamped.toFixed(0)}% of range</span>
<span>High $${escapeHtml(fmtPrice(hi))}</span>
</div>`;
}
function renderPositionBar(netPct: number, label: string, wow: string): string {
const clamped = Math.max(-100, Math.min(100, netPct));
const halfWidth = Math.abs(clamped) / 100 * 50;
const color = clamped >= 0 ? '#2ecc71' : '#e74c3c';
const leftPct = clamped >= 0 ? 50 : 50 - halfWidth;
const sign = clamped >= 0 ? '+' : '';
const wowN = parseInt(wow, 10);
const wowStr = Number.isFinite(wowN) && wowN !== 0 ? ` <span style="font-size:9px;color:${wowN >= 0 ? '#2ecc71' : '#e74c3c'};font-weight:500">Δ ${fmtSignedInt(wow)}</span>` : '';
return `
<div style="margin:3px 0">
<div style="display:flex;justify-content:space-between;font-size:9px;color:var(--text-dim);margin-bottom:2px">
<span>${escapeHtml(label)}</span>
<div style="margin:4px 0">
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--text-dim);margin-bottom:2px">
<span>${escapeHtml(label)}${wowStr}</span>
<span style="color:${color};font-weight:600">${sign}${clamped.toFixed(1)}%</span>
</div>
<div style="position:relative;height:8px;background:rgba(255,255,255,0.06);border-radius:2px">
@@ -64,6 +113,14 @@ function ratioLabel(ratio: number): { text: string; color: string } {
return { text: 'Neutral', color: 'var(--text-dim)' };
}
function returnChip(label: string, pct: number): string {
const color = pct >= 0 ? '#2ecc71' : '#e74c3c';
return `<div style="flex:1;text-align:center;padding:4px;background:rgba(255,255,255,0.03);border-radius:4px">
<div style="font-size:9px;color:var(--text-dim)">${escapeHtml(label)}</div>
<div style="font-size:11px;font-weight:600;color:${color}">${escapeHtml(fmtPct(pct, 1))}</div>
</div>`;
}
export class GoldIntelligencePanel extends Panel {
private _hasData = false;
@@ -96,16 +153,62 @@ export class GoldIntelligencePanel extends Panel {
}
}
private render(d: GoldIntelligenceData): void {
private renderHeader(d: GoldIntelligenceData): string {
const changePct = d.goldChangePct;
const changeColor = changePct >= 0 ? '#2ecc71' : '#e74c3c';
const changeSign = changePct >= 0 ? '+' : '';
const spark = miniSparkline(d.goldSparkline, changePct, 80, 20);
const fresh = freshnessLabel(d.updatedAt);
const sessionLine = d.session && d.session.dayHigh > 0
? `<div style="font-size:9px;color:var(--text-dim);margin-top:2px">
Session H $${escapeHtml(fmtPrice(d.session.dayHigh))} • L $${escapeHtml(fmtPrice(d.session.dayLow))} • Prev $${escapeHtml(fmtPrice(d.session.prevClose))}
</div>`
: '';
return `
<div class="energy-tape-section">
<div class="energy-section-title">Price &amp; Performance</div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
<span style="font-size:16px;font-weight:700">$${escapeHtml(fmtPrice(d.goldPrice))}</span>
<span style="font-size:11px;font-weight:600;color:${changeColor};padding:1px 6px;border-radius:3px;background:${changeColor}22">${fmtPct(changePct)}</span>
${spark}
</div>
<div style="display:flex;align-items:center;gap:6px;font-size:9px;color:var(--text-dim)">
<span style="width:6px;height:6px;border-radius:50%;background:${fresh.dot};display:inline-block"></span>
<span>${escapeHtml(fresh.text)} • GC=F front-month</span>
</div>
${sessionLine}
</div>`;
}
private renderReturns(d: GoldIntelligenceData): string {
if (!d.returns && !d.range52w) return '';
const chips = d.returns
? `<div style="display:flex;gap:4px;margin-top:6px">
${returnChip('1W', d.returns.w1)}
${returnChip('1M', d.returns.m1)}
${returnChip('YTD', d.returns.ytd)}
${returnChip('1Y', d.returns.y1)}
</div>`
: '';
const range = d.range52w && d.range52w.hi > 0
? `<div style="margin-top:8px">
<div style="font-size:9px;color:var(--text-dim)">52-week range</div>
${renderRangeBar(d.range52w.lo, d.range52w.hi, d.goldPrice, d.range52w.positionPct)}
</div>`
: '';
return `<div class="energy-tape-section" style="margin-top:10px">
<div class="energy-section-title">Returns</div>
${chips}
${range}
</div>`;
}
private renderMetals(d: GoldIntelligenceData): string {
const ratioHtml = d.goldSilverRatio != null && Number.isFinite(d.goldSilverRatio)
? (() => {
const rl = ratioLabel(d.goldSilverRatio!);
return `<div style="display:flex;justify-content:space-between;align-items:center;margin-top:6px">
return `<div style="display:flex;justify-content:space-between;align-items:center;margin-top:4px">
<span style="font-size:10px;color:var(--text-dim)">Gold/Silver Ratio</span>
<span style="font-size:11px;font-weight:600">${escapeHtml(d.goldSilverRatio!.toFixed(1))} <span style="font-size:9px;color:${rl.color};font-weight:400">${escapeHtml(rl.text)}</span></span>
</div>`;
@@ -115,65 +218,102 @@ export class GoldIntelligencePanel extends Panel {
const premiumHtml = d.goldPlatinumPremiumPct != null && Number.isFinite(d.goldPlatinumPremiumPct)
? `<div style="display:flex;justify-content:space-between;align-items:center;margin-top:4px">
<span style="font-size:10px;color:var(--text-dim)">Gold vs Platinum</span>
<span style="font-size:11px;font-weight:600">${d.goldPlatinumPremiumPct >= 0 ? '+' : ''}${escapeHtml(d.goldPlatinumPremiumPct.toFixed(1))}% premium</span>
<span style="font-size:11px;font-weight:600">${escapeHtml(fmtPct(d.goldPlatinumPremiumPct, 1))} premium</span>
</div>`
: '';
const metalCards = [
{ label: 'Silver', price: d.silverPrice, sym: 'SI=F' },
{ label: 'Platinum', price: d.platinumPrice, sym: 'PL=F' },
{ label: 'Palladium', price: d.palladiumPrice, sym: 'PA=F' },
const tiles = [
{ label: 'Silver', price: d.silverPrice },
{ label: 'Platinum', price: d.platinumPrice },
{ label: 'Palladium', price: d.palladiumPrice },
].map(m =>
`<div style="flex:1;text-align:center;padding:4px;background:rgba(255,255,255,0.03);border-radius:4px">
<div style="font-size:9px;color:var(--text-dim)">${escapeHtml(m.label)}</div>
<div style="font-size:11px;font-weight:600">$${escapeHtml(fmtPrice(m.price))}</div>
</div>`
).join('');
</div>`).join('');
const section1 = `
<div class="energy-tape-section">
<div class="energy-section-title">Price &amp; Performance</div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span style="font-size:16px;font-weight:700">$${escapeHtml(fmtPrice(d.goldPrice))}</span>
<span style="font-size:11px;font-weight:600;color:${changeColor};padding:1px 6px;border-radius:3px;background:${changeColor}22">${changeSign}${escapeHtml(changePct.toFixed(2))}%</span>
${spark}
</div>
return `<div class="energy-tape-section" style="margin-top:10px">
<div class="energy-section-title">Metals Complex</div>
${ratioHtml}
${premiumHtml}
<div style="display:flex;gap:6px;margin-top:8px">${metalCards}</div>
<div style="display:flex;gap:6px;margin-top:8px">${tiles}</div>
</div>`;
}
const fxRows = d.crossCurrencyPrices.map(c =>
private renderFx(d: GoldIntelligenceData): string {
if (!d.crossCurrencyPrices.length) return '';
const rows = d.crossCurrencyPrices.map(c =>
`<div style="text-align:center;padding:4px;background:rgba(255,255,255,0.03);border-radius:4px">
<div style="font-size:9px;color:var(--text-dim)">${escapeHtml(c.flag)} XAU/${escapeHtml(c.currency)}</div>
<div style="font-size:11px;font-weight:600">${escapeHtml(fmtPrice(c.price, 0))}</div>
</div>`
).join('');
const section2 = d.crossCurrencyPrices.length > 0
? `<div class="energy-tape-section" style="margin-top:10px">
</div>`).join('');
return `<div class="energy-tape-section" style="margin-top:10px">
<div class="energy-section-title">Gold in Major Currencies</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:6px">${fxRows}</div>
</div>`
: '';
let section3 = '';
if (d.cot) {
const c = d.cot;
const longStr = Math.round(c.managedMoneyLong).toLocaleString();
const shortStr = Math.round(c.managedMoneyShort).toLocaleString();
section3 = `
<div class="energy-tape-section" style="margin-top:10px">
<div class="energy-section-title">CFTC Positioning (Managed Money)</div>
${renderPositionBar(c.netPct, 'Net Position')}
<div style="display:flex;justify-content:space-between;font-size:9px;color:var(--text-dim);margin-top:4px">
<span>Long: ${escapeHtml(longStr)}</span>
<span>Short: ${escapeHtml(shortStr)}</span>
</div>
${c.reportDate ? `<div style="font-size:9px;color:var(--text-dim);margin-top:6px;text-align:right">Report: ${escapeHtml(c.reportDate)}</div>` : ''}
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:6px">${rows}</div>
</div>`;
}
this.setContent(`<div style="padding:10px 14px">${section1}${section2}${section3}</div>`);
private renderPositioning(d: GoldIntelligenceData): string {
const c = d.cot;
if (!c) return '';
const mm = c.managedMoney;
const ps = c.producerSwap;
const mmBar = mm ? renderPositionBar(mm.netPct, 'Managed Money (speculators)', mm.wowNetDelta) : '';
const psBar = ps ? renderPositionBar(ps.netPct, 'Producer/Swap (commercials)', ps.wowNetDelta) : '';
const detail = (cat: CotCategory | undefined, label: string) => cat
? `<div style="display:flex;justify-content:space-between;font-size:9px;color:var(--text-dim);padding:2px 0">
<span>${escapeHtml(label)}</span>
<span>L ${escapeHtml(fmtInt(cat.longPositions))} / S ${escapeHtml(fmtInt(cat.shortPositions))}${cat.oiSharePct.toFixed(1)}% OI</span>
</div>`
: '';
const releaseLine = c.reportDate
? `<div style="display:flex;justify-content:space-between;font-size:9px;color:var(--text-dim);margin-top:6px">
<span>As of ${escapeHtml(c.reportDate)}${c.nextReleaseDate ? ` • next release ${escapeHtml(c.nextReleaseDate)}` : ''}</span>
<span>OI ${escapeHtml(fmtInt(c.openInterest))}</span>
</div>`
: '';
return `<div class="energy-tape-section" style="margin-top:10px">
<div class="energy-section-title">CFTC Positioning</div>
${mmBar}
${detail(mm, 'MM breakdown')}
${psBar}
${detail(ps, 'P/S breakdown')}
${releaseLine}
</div>`;
}
private renderDrivers(d: GoldIntelligenceData): string {
if (!d.drivers?.length) return '';
const rows = d.drivers.map(dr => {
const color = dr.changePct >= 0 ? '#2ecc71' : '#e74c3c';
const corrColor = dr.correlation30d <= -0.3 ? '#2ecc71' : dr.correlation30d >= 0.3 ? '#e74c3c' : 'var(--text-dim)';
return `<div style="display:flex;justify-content:space-between;align-items:center;padding:3px 0;font-size:10px">
<span style="color:var(--text-dim)">${escapeHtml(dr.label)}</span>
<span>
<span style="font-weight:600">${escapeHtml(dr.value.toFixed(2))}</span>
<span style="color:${color};margin-left:4px">${escapeHtml(fmtPct(dr.changePct, 2))}</span>
<span style="color:${corrColor};margin-left:8px;font-size:9px">corr 30d ${dr.correlation30d >= 0 ? '+' : ''}${dr.correlation30d.toFixed(2)}</span>
</span>
</div>`;
}).join('');
return `<div class="energy-tape-section" style="margin-top:10px">
<div class="energy-section-title">Drivers</div>
${rows}
</div>`;
}
private render(d: GoldIntelligenceData): void {
const html = [
this.renderHeader(d),
this.renderReturns(d),
this.renderMetals(d),
this.renderFx(d),
this.renderPositioning(d),
this.renderDrivers(d),
].join('');
this.setContent(`<div style="padding:10px 14px">${html}</div>`);
}
}

View File

@@ -519,6 +519,10 @@ export interface GetGoldIntelligenceResponse {
cot?: GoldCotPositioning;
updatedAt: string;
unavailable: boolean;
session?: GoldSessionRange;
returns?: GoldReturns;
range52w?: GoldRange52w;
drivers: GoldDriver[];
}
export interface GoldCrossCurrencyPrice {
@@ -529,11 +533,45 @@ export interface GoldCrossCurrencyPrice {
export interface GoldCotPositioning {
reportDate: string;
managedMoneyLong: number;
managedMoneyShort: number;
nextReleaseDate: string;
openInterest: string;
managedMoney?: GoldCotCategory;
producerSwap?: GoldCotCategory;
}
export interface GoldCotCategory {
longPositions: string;
shortPositions: string;
netPct: number;
dealerLong: number;
dealerShort: number;
oiSharePct: number;
wowNetDelta: string;
}
export interface GoldSessionRange {
dayHigh: number;
dayLow: number;
prevClose: number;
}
export interface GoldReturns {
w1: number;
m1: number;
ytd: number;
y1: number;
}
export interface GoldRange52w {
hi: number;
lo: number;
positionPct: number;
}
export interface GoldDriver {
symbol: string;
label: string;
value: number;
changePct: number;
correlation30d: number;
}
export interface FieldViolation {

View File

@@ -519,6 +519,10 @@ export interface GetGoldIntelligenceResponse {
cot?: GoldCotPositioning;
updatedAt: string;
unavailable: boolean;
session?: GoldSessionRange;
returns?: GoldReturns;
range52w?: GoldRange52w;
drivers: GoldDriver[];
}
export interface GoldCrossCurrencyPrice {
@@ -529,11 +533,45 @@ export interface GoldCrossCurrencyPrice {
export interface GoldCotPositioning {
reportDate: string;
managedMoneyLong: number;
managedMoneyShort: number;
nextReleaseDate: string;
openInterest: string;
managedMoney?: GoldCotCategory;
producerSwap?: GoldCotCategory;
}
export interface GoldCotCategory {
longPositions: string;
shortPositions: string;
netPct: number;
dealerLong: number;
dealerShort: number;
oiSharePct: number;
wowNetDelta: string;
}
export interface GoldSessionRange {
dayHigh: number;
dayLow: number;
prevClose: number;
}
export interface GoldReturns {
w1: number;
m1: number;
ytd: number;
y1: number;
}
export interface GoldRange52w {
hi: number;
lo: number;
positionPct: number;
}
export interface GoldDriver {
symbol: string;
label: string;
value: number;
changePct: number;
correlation30d: number;
}
export interface FieldViolation {

View File

@@ -0,0 +1,135 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { buildInstrument, computeNextCotRelease } from '../scripts/seed-cot.mjs';
describe('seed-cot: computeNextCotRelease', () => {
it('returns report date + 3 days for a Tuesday report', () => {
// 2026-04-07 is a Tuesday; next Friday release is 2026-04-10
assert.equal(computeNextCotRelease('2026-04-07'), '2026-04-10');
});
it('handles month rollover', () => {
assert.equal(computeNextCotRelease('2026-03-31'), '2026-04-03');
});
it('returns empty for empty input', () => {
assert.equal(computeNextCotRelease(''), '');
});
it('returns empty for invalid date', () => {
assert.equal(computeNextCotRelease('not-a-date'), '');
});
});
describe('seed-cot: buildInstrument (commodity kind)', () => {
const gcTarget = { name: 'Gold', code: 'GC' };
it('computes managed money net % and OI share', () => {
const current = {
report_date_as_yyyy_mm_dd: '2026-04-07',
open_interest_all: '600000',
m_money_positions_long_all: '200000',
m_money_positions_short_all: '50000',
swap_positions_long_all: '30000',
swap__positions_short_all: '180000',
};
const inst = buildInstrument(gcTarget, current, null, 'commodity');
assert.equal(inst.code, 'GC');
assert.equal(inst.openInterest, 600000);
assert.equal(inst.nextReleaseDate, '2026-04-10');
// MM: (200000-50000)/(250000) = 60%
assert.equal(inst.managedMoney.netPct, 60);
// MM OI share: 250000/600000 = 41.67%
assert.ok(Math.abs(inst.managedMoney.oiSharePct - 41.67) < 0.05);
// Producer/Swap: (30000-180000)/(210000) ≈ -71.43%
assert.ok(Math.abs(inst.producerSwap.netPct - -71.43) < 0.05);
assert.equal(inst.managedMoney.wowNetDelta, 0);
});
it('computes WoW net delta from prior row', () => {
const current = {
report_date_as_yyyy_mm_dd: '2026-04-07',
open_interest_all: '600000',
m_money_positions_long_all: '200000',
m_money_positions_short_all: '50000',
swap_positions_long_all: '30000',
swap__positions_short_all: '180000',
};
const prior = {
report_date_as_yyyy_mm_dd: '2026-03-31',
m_money_positions_long_all: '180000',
m_money_positions_short_all: '60000',
swap_positions_long_all: '40000',
swap__positions_short_all: '170000',
};
const inst = buildInstrument(gcTarget, current, prior, 'commodity');
// Prior MM net = 180000-60000 = 120000; current = 200000-50000 = 150000; delta = +30000
assert.equal(inst.managedMoney.wowNetDelta, 30000);
// Prior P/S net = 40000-170000 = -130000; current = 30000-180000 = -150000; delta = -20000
assert.equal(inst.producerSwap.wowNetDelta, -20000);
});
it('builds financial instrument from TFF fields', () => {
const target = { name: '10-Year T-Note', code: 'ZN' };
const current = {
report_date_as_yyyy_mm_dd: '2026-04-07',
open_interest_all: '5000000',
asset_mgr_positions_long: '1500000',
asset_mgr_positions_short: '500000',
dealer_positions_long_all: '400000',
dealer_positions_short_all: '1600000',
};
const inst = buildInstrument(target, current, null, 'financial');
assert.equal(inst.managedMoney.longPositions, 1500000);
assert.equal(inst.producerSwap.longPositions, 400000);
assert.equal(inst.managedMoney.netPct, 50); // (1.5M-0.5M)/2M
});
it('preserves leveragedFunds fields for financial TFF consumers', () => {
const target = { name: '10-Year T-Note', code: 'ZN' };
const current = {
report_date_as_yyyy_mm_dd: '2026-04-07',
open_interest_all: '5000000',
asset_mgr_positions_long: '1500000',
asset_mgr_positions_short: '500000',
lev_money_positions_long: '750000',
lev_money_positions_short: '250000',
dealer_positions_long_all: '400000',
dealer_positions_short_all: '1600000',
};
const inst = buildInstrument(target, current, null, 'financial');
// Regression guard: CotPositioningPanel reads these for the Leveraged Funds bar.
assert.equal(inst.leveragedFundsLong, 750000);
assert.equal(inst.leveragedFundsShort, 250000);
});
it('commodity instruments emit leveragedFunds as 0 (no equivalent field in disaggregated report)', () => {
const current = {
report_date_as_yyyy_mm_dd: '2026-04-07',
open_interest_all: '100000',
m_money_positions_long_all: '10000',
m_money_positions_short_all: '5000',
swap_positions_long_all: '2000',
swap__positions_short_all: '8000',
};
const inst = buildInstrument(gcTarget, current, null, 'commodity');
assert.equal(inst.leveragedFundsLong, 0);
assert.equal(inst.leveragedFundsShort, 0);
});
it('preserves legacy flat fields for backward compat', () => {
const current = {
report_date_as_yyyy_mm_dd: '2026-04-07',
open_interest_all: '100000',
m_money_positions_long_all: '10000',
m_money_positions_short_all: '5000',
swap_positions_long_all: '2000',
swap__positions_short_all: '8000',
};
const inst = buildInstrument(gcTarget, current, null, 'commodity');
assert.equal(inst.assetManagerLong, 10000);
assert.equal(inst.dealerShort, 8000);
assert.equal(inst.netPct, inst.managedMoney.netPct);
});
});