// ── State ────────────────────────────────────────────────────────────────── const state = { view: 'detail', selectedCode: null, disaggMode: false, // true when current market uses disaggregated data dateRange: '3Y', rowType: 'All', metric: 'm_money_net', overlayOI: true, exchange: '', compareMarkets: [], // [{cftc_code, name, exchange_abbr, color, has_disagg}] compareMetric: 'm_money_net', compareRange: '3Y', allCommodities: [], // full list from API }; // ── API ──────────────────────────────────────────────────────────────────── const DISAGG_METRICS = new Set([ 'm_money_net', 'm_money_long', 'm_money_short', 'prod_merc_net', 'swap_net', 'other_rept_net', 'pct_m_money_long', 'pct_m_money_short', ]); const API = { async get(path) { const r = await fetch(path); if (!r.ok) throw new Error(`${r.status} ${r.statusText}`); return r.json(); }, exchanges: () => API.get('/api/exchanges'), commodities: (exchange) => API.get('/api/commodities' + (exchange ? `?exchange=${exchange}` : '')), history: (code, fromDate, toDate, rowType) => { const p = new URLSearchParams({ row_type: rowType }); if (fromDate) p.append('from_date', fromDate); if (toDate) p.append('to_date', toDate); return API.get(`/api/positions/${code}/history?${p}`); }, disaggHistory: (code, fromDate, toDate, rowType) => { const p = new URLSearchParams({ row_type: rowType }); if (fromDate) p.append('from_date', fromDate); if (toDate) p.append('to_date', toDate); return API.get(`/api/disagg/${code}/history?${p}`); }, percentile: (code, weeks) => API.get(`/api/analytics/${code}/net-position-percentile?lookback_weeks=${weeks}`), disaggPercentile: (code, weeks) => API.get(`/api/disagg/${code}/net-position-percentile?lookback_weeks=${weeks}`), screener: (exchange, direction, lookback) => { const p = new URLSearchParams({ lookback_weeks: lookback, top_n: 500 }); if (exchange) p.append('exchange', exchange); if (direction) p.append('direction', direction === 'long' ? 'long' : 'short'); return API.get(`/api/disagg/screener?${p}`); }, compare: (codes, metric, fromDate, toDate) => { const isDisagg = DISAGG_METRICS.has(metric) || metric === 'open_interest'; const endpoint = isDisagg ? '/api/disagg/compare' : '/api/positions/compare'; const p = new URLSearchParams({ codes: codes.join(','), metric, row_type: 'All' }); if (fromDate) p.append('from_date', fromDate); if (toDate) p.append('to_date', toDate); return API.get(`${endpoint}?${p}`); }, }; // ── Utilities ────────────────────────────────────────────────────────────── function fmt(v) { if (v === null || v === undefined) return '—'; if (typeof v === 'number') { if (Math.abs(v) >= 1000000) return (v / 1000000).toFixed(2) + 'M'; if (Math.abs(v) >= 1000) return (v / 1000).toFixed(1) + 'K'; return v.toLocaleString(); } return v; } function fmtPct(v) { if (v === null || v === undefined) return '—'; return v.toFixed(1) + '%'; } function fmtChange(v) { if (v === null || v === undefined) return '—'; const s = (v >= 0 ? '+' : '') + fmt(v); return s; } function dateRangeToFrom(range) { if (range === 'Max') return null; const d = new Date(); const years = { '1Y': 1, '3Y': 3, '5Y': 5 }[range] || 3; d.setFullYear(d.getFullYear() - years); return d.toISOString().split('T')[0]; } const CHART_COLORS = [ '#3b82f6', '#22c55e', '#f97316', '#a855f7', '#ec4899', '#14b8a6', '#eab308', '#ef4444', ]; // ── Charts ───────────────────────────────────────────────────────────────── let detailChart = null; let compareChart = null; function destroyChart(c) { if (c) { c.destroy(); } return null; } function buildDetailChart(data, metric, overlayOI) { detailChart = destroyChart(detailChart); const canvas = document.getElementById('detailChart'); const ctx = canvas.getContext('2d'); const labels = data.map(d => d.report_date); const metricLabel = document.getElementById('metricSelect').selectedOptions[0]?.text || metric; // Disagg and legacy metrics that are pre-computed by the API const precomputed = new Set([ 'm_money_net', 'prod_merc_net', 'swap_net', 'other_rept_net', 'nonrept_net', 'noncomm_net', 'comm_net', ]); let values; if (precomputed.has(metric)) { values = data.map(d => d[metric] ?? null); } else { values = data.map(d => d[metric] ?? null); } const datasets = [{ type: 'line', label: metricLabel, data: values, borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,0.08)', fill: true, tension: 0.2, pointRadius: data.length < 60 ? 3 : 0, pointHoverRadius: 5, borderWidth: 2, yAxisID: 'y', order: 1, }]; const scales = { x: { ticks: { color: '#6b7280', maxTicksLimit: 8, maxRotation: 0 }, grid: { color: '#2d3148' }, }, y: { position: 'left', ticks: { color: '#6b7280', callback: v => fmt(v) }, grid: { color: '#2d3148' }, }, }; if (overlayOI) { datasets.push({ type: 'bar', label: 'Open Interest', data: data.map(d => d.open_interest ?? null), backgroundColor: 'rgba(156,163,175,0.15)', borderColor: 'rgba(156,163,175,0.3)', borderWidth: 1, yAxisID: 'y1', order: 2, }); scales.y1 = { position: 'right', ticks: { color: '#4b5563', callback: v => fmt(v) }, grid: { drawOnChartArea: false }, }; } detailChart = new Chart(ctx, { type: 'bar', data: { labels, datasets }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { labels: { color: '#9ca3af', boxWidth: 12 } }, tooltip: { backgroundColor: '#1a1d27', borderColor: '#2d3148', borderWidth: 1, titleColor: '#e2e8f0', bodyColor: '#9ca3af', callbacks: { label: (ctx) => ` ${ctx.dataset.label}: ${fmt(ctx.parsed.y)}`, }, }, }, scales, }, }); } function buildCompareChart(seriesData, commodities, metric) { compareChart = destroyChart(compareChart); const canvas = document.getElementById('compareChart'); const ctx = canvas.getContext('2d'); const allDates = [...new Set( Object.values(seriesData).flatMap(pts => pts.map(p => p.report_date)) )].sort(); const datasets = state.compareMarkets.map((m, i) => { const pts = seriesData[m.cftc_code] || []; const byDate = Object.fromEntries(pts.map(p => [p.report_date, p.value])); return { label: m.name, data: allDates.map(d => byDate[d] ?? null), borderColor: m.color, backgroundColor: 'transparent', tension: 0.2, pointRadius: allDates.length < 60 ? 3 : 0, pointHoverRadius: 5, borderWidth: 2, spanGaps: false, }; }); compareChart = new Chart(ctx, { type: 'line', data: { labels: allDates, datasets }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { labels: { color: '#9ca3af', boxWidth: 12 } }, tooltip: { backgroundColor: '#1a1d27', borderColor: '#2d3148', borderWidth: 1, titleColor: '#e2e8f0', bodyColor: '#9ca3af', callbacks: { label: (ctx) => ` ${ctx.dataset.label}: ${fmt(ctx.parsed.y)}`, }, }, }, scales: { x: { ticks: { color: '#6b7280', maxTicksLimit: 8, maxRotation: 0 }, grid: { color: '#2d3148' } }, y: { ticks: { color: '#6b7280', callback: v => fmt(v) }, grid: { color: '#2d3148' } }, }, }, }); } // ── Market sidebar ───────────────────────────────────────────────────────── function buildMarketTree(commodities, filter = '') { const tree = document.getElementById('market-tree'); const groups = {}; const q = filter.toLowerCase(); for (const c of commodities) { const matches = !q || c.name.toLowerCase().includes(q) || c.exchange_abbr.toLowerCase().includes(q); if (!matches) continue; if (!groups[c.exchange_abbr]) groups[c.exchange_abbr] = []; groups[c.exchange_abbr].push(c); } tree.innerHTML = ''; for (const [exch, list] of Object.entries(groups)) { const grp = document.createElement('div'); grp.className = 'exchange-group'; const lbl = document.createElement('div'); lbl.className = 'exchange-label'; lbl.textContent = exch; lbl.addEventListener('click', () => { lbl.classList.toggle('collapsed'); ul.classList.toggle('hidden'); }); const ul = document.createElement('div'); ul.className = 'market-list'; for (const c of list) { const item = document.createElement('div'); item.className = 'market-item' + (c.cftc_code === state.selectedCode ? ' active' : ''); item.textContent = c.name; item.title = c.name; item.dataset.code = c.cftc_code; item.addEventListener('click', () => selectMarket(c.cftc_code)); ul.appendChild(item); } grp.appendChild(lbl); grp.appendChild(ul); tree.appendChild(grp); } } // ── Detail view logic ────────────────────────────────────────────────────── function _applyDisaggMode(disagg) { state.disaggMode = disagg; const disaggGroup = document.getElementById('metricDisaggGroup'); const legacyGroup = document.getElementById('metricLegacyGroup'); const reportBadge = document.getElementById('detail-report-type'); if (disagg) { disaggGroup.style.display = ''; legacyGroup.style.display = 'none'; reportBadge.style.display = ''; // Default to m_money_net when entering disagg mode if (!DISAGG_METRICS.has(state.metric) && state.metric !== 'open_interest') { state.metric = 'm_money_net'; document.getElementById('metricSelect').value = 'm_money_net'; } } else { disaggGroup.style.display = 'none'; legacyGroup.style.display = ''; reportBadge.style.display = 'none'; // Default to noncomm_net when entering legacy mode if (DISAGG_METRICS.has(state.metric)) { state.metric = 'noncomm_net'; document.getElementById('metricSelect').value = 'noncomm_net'; } } } async function selectMarket(code) { state.selectedCode = code; document.querySelectorAll('.market-item').forEach(el => { el.classList.toggle('active', el.dataset.code === code); }); document.getElementById('detail-placeholder').style.display = 'none'; document.getElementById('detail-content').style.display = 'flex'; document.getElementById('detail-content').style.flexDirection = 'column'; document.getElementById('detail-content').style.gap = '12px'; const meta = state.allCommodities.find(c => c.cftc_code === code); if (meta) { document.getElementById('detail-title').textContent = meta.name; document.getElementById('detail-exchange').textContent = meta.exchange_abbr; document.getElementById('detail-unit').textContent = meta.contract_unit || ''; _applyDisaggMode(!!meta.has_disagg); } await refreshDetailChart(); } async function refreshDetailChart() { if (!state.selectedCode) return; const fromDate = dateRangeToFrom(state.dateRange); try { const histPromise = state.disaggMode ? API.disaggHistory(state.selectedCode, fromDate, null, state.rowType) : API.history(state.selectedCode, fromDate, null, state.rowType); const pctilePromise = state.disaggMode ? API.disaggPercentile(state.selectedCode, 156).catch(() => null) : API.percentile(state.selectedCode, 156).catch(() => null); const [hist, pctileData] = await Promise.all([histPromise, pctilePromise]); const data = hist.data; if (!data.length) return; buildDetailChart(data, state.metric, state.overlayOI); // Stats bar const latest = data[data.length - 1]; const metricValues = data.map(d => d[state.metric]).filter(v => v !== null && v !== undefined); if (metricValues.length) { const currentVal = metricValues[metricValues.length - 1]; const maxVal = Math.max(...metricValues); const minVal = Math.min(...metricValues); const statCurrent = document.getElementById('statCurrent'); statCurrent.textContent = fmt(currentVal); statCurrent.className = 'stat-value' + (currentVal > 0 ? ' positive' : currentVal < 0 ? ' negative' : ''); document.getElementById('statMax').textContent = fmt(maxVal); document.getElementById('statMin').textContent = fmt(minVal); } if (pctileData?.percentile !== null && pctileData?.percentile !== undefined) { document.getElementById('statPctile').textContent = fmtPct(pctileData.percentile); } else { document.getElementById('statPctile').textContent = '—'; } // Week change — use the primary net metric's change columns const [chgLong, chgShort] = state.disaggMode ? [latest.chg_m_money_long, latest.chg_m_money_short] : [latest.chg_noncomm_long, latest.chg_noncomm_short]; if (chgLong != null && chgShort != null) { const netChg = chgLong - chgShort; const el = document.getElementById('statChange'); el.textContent = fmtChange(netChg) + ' net'; el.className = 'stat-value' + (netChg > 0 ? ' positive' : netChg < 0 ? ' negative' : ''); } // Extra net stat for disagg (show Managed Money net explicitly) const netEl = document.getElementById('statNet'); const netLabel = document.getElementById('statNetLabel'); if (state.disaggMode && latest.m_money_net != null) { netLabel.textContent = 'M-Money Net'; netEl.textContent = fmt(latest.m_money_net); netEl.className = 'stat-value' + (latest.m_money_net > 0 ? ' positive' : latest.m_money_net < 0 ? ' negative' : ''); netEl.parentElement.style.display = ''; } else { netEl.parentElement.style.display = 'none'; } } catch (e) { console.error('Failed to load chart data:', e); } } // ── Screener view ────────────────────────────────────────────────────────── async function loadScreener() { const exchange = document.getElementById('screenerExchange').value; const dirSel = document.getElementById('screenerDirection').value; const lookback = document.getElementById('screenerLookback').value; let direction = ''; if (dirSel === 'long') direction = 'long'; else if (dirSel === 'short') direction = 'short'; const tbody = document.getElementById('screenerBody'); tbody.innerHTML = '