// ── State ────────────────────────────────────────────────────────────────── const state = { view: 'detail', selectedCode: null, dateRange: '3Y', rowType: 'All', metric: 'noncomm_net', overlayOI: true, exchange: '', compareMarkets: [], // [{cftc_code, name, exchange_abbr, color}] compareMetric: 'noncomm_net', compareRange: '3Y', allCommodities: [], // full list from API }; // ── API ──────────────────────────────────────────────────────────────────── 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}`); }, extremes: (code) => API.get(`/api/positions/${code}/extremes`), percentile: (code, weeks) => API.get(`/api/analytics/${code}/net-position-percentile?lookback_weeks=${weeks}`), screener: (exchange, direction, lookback) => { const p = new URLSearchParams({ lookback_weeks: lookback, top_n: 200 }); if (exchange) p.append('exchange', exchange); if (direction) p.append('direction', direction === 'long' ? 'long' : 'short'); return API.get(`/api/analytics/screener?${p}`); }, compare: (codes, metric, fromDate, toDate) => { 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(`/api/positions/compare?${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; let values; if (metric === 'noncomm_net') { values = data.map(d => (d.noncomm_long ?? 0) - (d.noncomm_short ?? 0)); } else if (metric === 'comm_net') { values = data.map(d => (d.comm_long ?? 0) - (d.comm_short ?? 0)); } 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 ────────────────────────────────────────────────────── 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 || ''; } await refreshDetailChart(); } async function refreshDetailChart() { if (!state.selectedCode) return; const fromDate = dateRangeToFrom(state.dateRange); try { const [hist, pctileData] = await Promise.all([ API.history(state.selectedCode, fromDate, null, state.rowType), API.percentile(state.selectedCode, 156).catch(() => null), ]); 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 => { if (state.metric === 'noncomm_net') return (d.noncomm_long ?? 0) - (d.noncomm_short ?? 0); if (state.metric === 'comm_net') return (d.comm_long ?? 0) - (d.comm_short ?? 0); return d[state.metric]; }).filter(v => v !== null); 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 for non-comm net const chgLong = latest.chg_noncomm_long; const chgShort = 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' : ''); } } 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; // Map UI direction to API direction let direction = ''; if (dirSel === 'long') direction = 'long'; else if (dirSel === 'short') direction = 'short'; const tbody = document.getElementById('screenerBody'); tbody.innerHTML = 'Loading...'; try { let rows = await API.screener(exchange, direction, lookback); // Client-side direction filter (the API `direction` param filters by >=50 or <50, // but UI wants >=70 and <=30 for "extreme") if (dirSel === 'long') rows = rows.filter(r => r.pct_rank >= 70); else if (dirSel === 'short') rows = rows.filter(r => r.pct_rank <= 30); tbody.innerHTML = ''; for (const row of rows) { const pct = row.pct_rank; const pctClass = pct >= 70 ? 'extreme-long' : pct <= 30 ? 'extreme-short' : 'neutral'; const netChg = (row.chg_noncomm_long ?? 0) - (row.chg_noncomm_short ?? 0); const tr = document.createElement('tr'); tr.innerHTML = ` ${row.commodity} ${row.exchange} ${fmt(row.noncomm_net)} ${fmt(row.open_interest)} ${pct !== null ? fmtPct(pct) : '—'} ${fmtChange(row.chg_noncomm_long)} ${fmtChange(row.chg_noncomm_short)} `; tr.addEventListener('click', () => { switchView('detail'); selectMarket(row.cftc_code); }); tbody.appendChild(tr); } if (!rows.length) { tbody.innerHTML = 'No markets match the current filters.'; } } catch (e) { tbody.innerHTML = `Error: ${e.message}`; } } // ── Compare view ─────────────────────────────────────────────────────────── function renderCompareTags() { const container = document.getElementById('compareTags'); container.innerHTML = ''; state.compareMarkets.forEach((m, i) => { const tag = document.createElement('div'); tag.className = 'compare-tag'; tag.style.background = m.color; tag.innerHTML = `${m.name} `; tag.querySelector('button').addEventListener('click', (e) => { e.stopPropagation(); removeCompareMarket(m.cftc_code); }); container.appendChild(tag); }); } function addCompareMarket(commodity) { if (state.compareMarkets.find(m => m.cftc_code === commodity.cftc_code)) return; if (state.compareMarkets.length >= 8) return; state.compareMarkets.push({ ...commodity, color: CHART_COLORS[state.compareMarkets.length % CHART_COLORS.length], }); renderCompareTags(); loadCompareChart(); } function removeCompareMarket(code) { state.compareMarkets = state.compareMarkets.filter(m => m.cftc_code !== code); // Reassign colors state.compareMarkets.forEach((m, i) => { m.color = CHART_COLORS[i % CHART_COLORS.length]; }); renderCompareTags(); loadCompareChart(); } async function loadCompareChart() { const placeholder = document.getElementById('comparePlaceholder'); const canvas = document.getElementById('compareChart'); if (!state.compareMarkets.length) { placeholder.style.display = 'flex'; canvas.style.display = 'none'; compareChart = destroyChart(compareChart); return; } placeholder.style.display = 'none'; canvas.style.display = 'block'; const fromDate = dateRangeToFrom(state.compareRange); const codes = state.compareMarkets.map(m => m.cftc_code); try { const resp = await API.compare(codes, state.compareMetric, fromDate, null); buildCompareChart(resp.series, resp.commodities, state.compareMetric); } catch (e) { console.error('Compare chart error:', e); } } // ── Compare autocomplete ─────────────────────────────────────────────────── function setupCompareSearch() { const input = document.getElementById('compareSearch'); const dropdown = document.getElementById('compareDropdown'); input.addEventListener('input', () => { const q = input.value.trim().toLowerCase(); if (!q) { dropdown.style.display = 'none'; return; } const matches = state.allCommodities .filter(c => c.name.toLowerCase().includes(q) || c.exchange_abbr.toLowerCase().includes(q)) .slice(0, 10); dropdown.innerHTML = ''; if (!matches.length) { dropdown.style.display = 'none'; return; } matches.forEach(c => { const item = document.createElement('div'); item.className = 'autocomplete-item'; item.innerHTML = `${c.name} ${c.exchange_abbr}`; item.addEventListener('click', () => { addCompareMarket(c); input.value = ''; dropdown.style.display = 'none'; }); dropdown.appendChild(item); }); dropdown.style.display = 'block'; }); document.addEventListener('click', (e) => { if (!input.contains(e.target) && !dropdown.contains(e.target)) { dropdown.style.display = 'none'; } }); } // ── View switching ───────────────────────────────────────────────────────── function switchView(view) { state.view = view; document.querySelectorAll('.view').forEach(el => { el.style.display = 'none'; }); document.querySelectorAll('.tab-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.view === view); }); document.getElementById(`view-${view}`).style.display = view === 'detail' ? 'flex' : 'flex'; document.getElementById(`view-${view}`).style.flexDirection = view === 'detail' ? 'row' : 'column'; if (view === 'screener') loadScreener(); if (view === 'compare') renderCompareTags(); } // ── Populate exchange dropdowns ──────────────────────────────────────────── async function populateExchangeDropdowns(exchanges) { ['exchangeFilter', 'screenerExchange'].forEach(id => { const sel = document.getElementById(id); exchanges.forEach(e => { const opt = document.createElement('option'); opt.value = e.exchange_abbr; opt.textContent = `${e.exchange_abbr} (${e.commodity_count})`; sel.appendChild(opt); }); }); } // ── Init ─────────────────────────────────────────────────────────────────── async function init() { try { const [exchanges, commodities] = await Promise.all([ API.exchanges(), API.commodities(), ]); state.allCommodities = commodities; await populateExchangeDropdowns(exchanges); buildMarketTree(commodities); // Select first commodity by default if (commodities.length > 0) { selectMarket(commodities[0].cftc_code); } } catch (e) { console.error('Init failed:', e); } } // ── Event listeners ──────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { // Tab switching document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => switchView(btn.dataset.view)); }); // Exchange filter (sidebar) document.getElementById('exchangeFilter').addEventListener('change', async (e) => { state.exchange = e.target.value; const filtered = state.allCommodities.filter(c => !state.exchange || c.exchange_abbr === state.exchange ); buildMarketTree(filtered); }); // Market search document.getElementById('marketSearch').addEventListener('input', (e) => { const q = e.target.value; const filtered = state.allCommodities.filter(c => !state.exchange || c.exchange_abbr === state.exchange ); buildMarketTree(filtered, q); }); // Date range buttons document.querySelectorAll('.range-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); state.dateRange = btn.dataset.range; refreshDetailChart(); }); }); // Row type buttons document.querySelectorAll('.rt-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.rt-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); state.rowType = btn.dataset.rt; refreshDetailChart(); }); }); // Metric select document.getElementById('metricSelect').addEventListener('change', (e) => { state.metric = e.target.value; refreshDetailChart(); }); // OI overlay checkbox document.getElementById('overlayOI').addEventListener('change', (e) => { state.overlayOI = e.target.checked; refreshDetailChart(); }); // Screener document.getElementById('screenerRefresh').addEventListener('click', loadScreener); document.getElementById('screenerDirection').addEventListener('change', loadScreener); document.getElementById('screenerLookback').addEventListener('change', loadScreener); document.getElementById('screenerExchange').addEventListener('change', loadScreener); // Compare document.getElementById('compareMetric').addEventListener('change', (e) => { state.compareMetric = e.target.value; loadCompareChart(); }); document.getElementById('compareRange').addEventListener('change', (e) => { state.compareRange = e.target.value; loadCompareChart(); }); setupCompareSearch(); init(); });