mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 & 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 & 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>`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
135
tests/gold-intelligence-seed.test.mjs
Normal file
135
tests/gold-intelligence-seed.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user