diff --git a/app/api/main.py b/app/api/main.py index 69be45c..c5b53a5 100644 --- a/app/api/main.py +++ b/app/api/main.py @@ -3,7 +3,7 @@ from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from pathlib import Path -from app.api.routes import commodities, positions, analytics, reports +from app.api.routes import commodities, positions, analytics, reports, disagg app = FastAPI( title="CFTC COT Explorer", @@ -15,6 +15,7 @@ app.include_router(commodities.router) app.include_router(positions.router) app.include_router(analytics.router) app.include_router(reports.router) +app.include_router(disagg.router) FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend" diff --git a/app/api/models.py b/app/api/models.py index 685af51..8ccfe15 100644 --- a/app/api/models.py +++ b/app/api/models.py @@ -11,6 +11,7 @@ class CommodityMeta(BaseModel): first_date: Optional[str] last_date: Optional[str] week_count: int + has_disagg: bool = False class ExchangeInfo(BaseModel): @@ -113,6 +114,65 @@ class PercentileResponse(BaseModel): period_max: Optional[int] +class DisaggPositionPoint(BaseModel): + report_date: str + open_interest: Optional[int] + prod_merc_long: Optional[int] + prod_merc_short: Optional[int] + prod_merc_net: Optional[int] + swap_long: Optional[int] + swap_short: Optional[int] + swap_spread: Optional[int] + swap_net: Optional[int] + m_money_long: Optional[int] + m_money_short: Optional[int] + m_money_spread: Optional[int] + m_money_net: Optional[int] + other_rept_long: Optional[int] + other_rept_short: Optional[int] + other_rept_net: Optional[int] + nonrept_long: Optional[int] + nonrept_short: Optional[int] + nonrept_net: Optional[int] + chg_open_interest: Optional[int] + chg_m_money_long: Optional[int] + chg_m_money_short: Optional[int] + chg_prod_merc_long: Optional[int] + chg_prod_merc_short: Optional[int] + chg_swap_long: Optional[int] + chg_swap_short: Optional[int] + pct_open_interest: Optional[float] + pct_m_money_long: Optional[float] + pct_m_money_short: Optional[float] + pct_prod_merc_long: Optional[float] + pct_prod_merc_short: Optional[float] + pct_swap_long: Optional[float] + pct_swap_short: Optional[float] + traders_total: Optional[int] + traders_m_money_long: Optional[int] + traders_m_money_short: Optional[int] + traders_prod_merc_long: Optional[int] + traders_prod_merc_short: Optional[int] + + +class DisaggHistoryResponse(BaseModel): + commodity: CommodityMeta + row_type: str + data: list[DisaggPositionPoint] + + +class DisaggScreenerRow(BaseModel): + cftc_code: str + commodity: str + exchange: str + latest_date: str + m_money_net: Optional[int] + open_interest: Optional[int] + pct_rank: Optional[float] + chg_m_money_long: Optional[int] + chg_m_money_short: Optional[int] + + class ReportDateInfo(BaseModel): date: str commodity_count: int diff --git a/app/api/routes/commodities.py b/app/api/routes/commodities.py index 8142b01..de37acd 100644 --- a/app/api/routes/commodities.py +++ b/app/api/routes/commodities.py @@ -12,10 +12,12 @@ def get_exchanges(): with get_db() as conn: rows = conn.execute( """ - SELECT exchange_abbr, exchange, - COUNT(*) AS commodity_count - FROM commodities - GROUP BY exchange_abbr + SELECT c.exchange_abbr, c.exchange, + COUNT(DISTINCT c.id) AS commodity_count + FROM commodities c + WHERE EXISTS (SELECT 1 FROM reports r WHERE r.commodity_id = c.id) + OR EXISTS (SELECT 1 FROM disagg_reports dr WHERE dr.commodity_id = c.id) + GROUP BY c.exchange_abbr ORDER BY commodity_count DESC """ ).fetchall() @@ -26,17 +28,23 @@ def get_exchanges(): def get_commodities(exchange: Optional[str] = Query(None)): sql = """ SELECT c.cftc_code, c.name, c.exchange, c.exchange_abbr, c.contract_unit, - MIN(r.report_date) AS first_date, - MAX(r.report_date) AS last_date, - COUNT(DISTINCT r.report_date) AS week_count + COALESCE(MIN(r.report_date), MIN(dr.report_date)) AS first_date, + COALESCE(MAX(r.report_date), MAX(dr.report_date)) AS last_date, + COUNT(DISTINCT r.report_date) AS week_count, + CASE WHEN COUNT(DISTINCT dr.report_date) > 0 THEN 1 ELSE 0 END AS has_disagg FROM commodities c LEFT JOIN reports r ON r.commodity_id = c.id + LEFT JOIN disagg_reports dr ON dr.commodity_id = c.id """ params = [] if exchange: sql += " WHERE c.exchange_abbr = ?" params.append(exchange) - sql += " GROUP BY c.id ORDER BY c.exchange_abbr, c.name" + sql += """ + GROUP BY c.id + HAVING COUNT(DISTINCT r.report_date) > 0 OR COUNT(DISTINCT dr.report_date) > 0 + ORDER BY c.exchange_abbr, c.name + """ with get_db() as conn: rows = conn.execute(sql, params).fetchall() @@ -49,11 +57,13 @@ def get_commodity(cftc_code: str): row = conn.execute( """ SELECT c.cftc_code, c.name, c.exchange, c.exchange_abbr, c.contract_unit, - MIN(r.report_date) AS first_date, - MAX(r.report_date) AS last_date, - COUNT(DISTINCT r.report_date) AS week_count + COALESCE(MIN(r.report_date), MIN(dr.report_date)) AS first_date, + COALESCE(MAX(r.report_date), MAX(dr.report_date)) AS last_date, + COUNT(DISTINCT r.report_date) AS week_count, + CASE WHEN COUNT(DISTINCT dr.report_date) > 0 THEN 1 ELSE 0 END AS has_disagg FROM commodities c LEFT JOIN reports r ON r.commodity_id = c.id + LEFT JOIN disagg_reports dr ON dr.commodity_id = c.id WHERE c.cftc_code = ? GROUP BY c.id """, diff --git a/app/api/routes/disagg.py b/app/api/routes/disagg.py new file mode 100644 index 0000000..8dd9d38 --- /dev/null +++ b/app/api/routes/disagg.py @@ -0,0 +1,323 @@ +from fastapi import APIRouter, HTTPException, Query +from typing import Optional + +from app.db import get_db +from app.api.models import ( + CommodityMeta, + DisaggPositionPoint, + DisaggHistoryResponse, + DisaggScreenerRow, + CompareResponse, + ComparePoint, +) + +router = APIRouter(prefix="/api/disagg", tags=["disaggregated"]) + + +def _disagg_commodity_meta(conn, cftc_code: str) -> dict: + row = conn.execute( + """ + SELECT c.cftc_code, c.name, c.exchange, c.exchange_abbr, c.contract_unit, + MIN(dr.report_date) AS first_date, + MAX(dr.report_date) AS last_date, + COUNT(DISTINCT dr.report_date) AS week_count, + 1 AS has_disagg + FROM commodities c + JOIN disagg_reports dr ON dr.commodity_id = c.id + WHERE c.cftc_code = ? + GROUP BY c.id + """, + (cftc_code,), + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail=f"No disaggregated data for {cftc_code}") + return dict(row) + + +def _row_to_disagg_point(row) -> DisaggPositionPoint: + d = dict(row) + d['prod_merc_net'] = ( + (d['prod_merc_long'] or 0) - (d['prod_merc_short'] or 0) + if d.get('prod_merc_long') is not None and d.get('prod_merc_short') is not None + else None + ) + d['swap_net'] = ( + (d['swap_long'] or 0) - (d['swap_short'] or 0) + if d.get('swap_long') is not None and d.get('swap_short') is not None + else None + ) + d['m_money_net'] = ( + (d['m_money_long'] or 0) - (d['m_money_short'] or 0) + if d.get('m_money_long') is not None and d.get('m_money_short') is not None + else None + ) + d['other_rept_net'] = ( + (d['other_rept_long'] or 0) - (d['other_rept_short'] or 0) + if d.get('other_rept_long') is not None and d.get('other_rept_short') is not None + else None + ) + d['nonrept_net'] = ( + (d['nonrept_long'] or 0) - (d['nonrept_short'] or 0) + if d.get('nonrept_long') is not None and d.get('nonrept_short') is not None + else None + ) + return DisaggPositionPoint(**{k: d.get(k) for k in DisaggPositionPoint.model_fields}) + + +@router.get("/{cftc_code}/history", response_model=DisaggHistoryResponse) +def get_disagg_history( + cftc_code: str, + from_date: Optional[str] = Query(None), + to_date: Optional[str] = Query(None), + row_type: str = Query("All", pattern="^(All|Old|Other)$"), +): + with get_db() as conn: + meta = _disagg_commodity_meta(conn, cftc_code) + + sql = """ + SELECT dr.report_date, + dp.open_interest, + dp.prod_merc_long, dp.prod_merc_short, + dp.swap_long, dp.swap_short, dp.swap_spread, + dp.m_money_long, dp.m_money_short, dp.m_money_spread, + dp.other_rept_long, dp.other_rept_short, + dp.nonrept_long, dp.nonrept_short, + dp.chg_open_interest, + dp.chg_m_money_long, dp.chg_m_money_short, + dp.chg_prod_merc_long, dp.chg_prod_merc_short, + dp.chg_swap_long, dp.chg_swap_short, + dp.pct_open_interest, + dp.pct_m_money_long, dp.pct_m_money_short, + dp.pct_prod_merc_long, dp.pct_prod_merc_short, + dp.pct_swap_long, dp.pct_swap_short, + dp.traders_total, + dp.traders_m_money_long, dp.traders_m_money_short, + dp.traders_prod_merc_long, dp.traders_prod_merc_short + FROM disagg_positions dp + JOIN disagg_reports dr ON dr.id = dp.report_id + JOIN commodities c ON c.id = dr.commodity_id + WHERE c.cftc_code = ? AND dp.row_type = ? + """ + params: list = [cftc_code, row_type] + if from_date: + sql += " AND dr.report_date >= ?" + params.append(from_date) + if to_date: + sql += " AND dr.report_date <= ?" + params.append(to_date) + sql += " ORDER BY dr.report_date ASC" + + rows = conn.execute(sql, params).fetchall() + + data = [_row_to_disagg_point(r) for r in rows] + return DisaggHistoryResponse( + commodity=CommodityMeta(**meta), + row_type=row_type, + data=data, + ) + + +@router.get("/screener", response_model=list[DisaggScreenerRow]) +def disagg_screener( + exchange: Optional[str] = Query(None), + lookback_weeks: int = Query(156, ge=4, le=1560), + top_n: int = Query(500, ge=1, le=1000), + direction: Optional[str] = Query(None, pattern="^(long|short)$"), +): + """ + Return markets ranked by current Managed Money net position + relative to the historical distribution (percentile rank). + """ + exchange_filter = "AND c.exchange_abbr = ?" if exchange else "" + exchange_params = [exchange] if exchange else [] + + with get_db() as conn: + rows = conn.execute( + f""" + WITH latest AS ( + SELECT c.cftc_code, c.name AS commodity, c.exchange_abbr AS exchange, + MAX(dr.report_date) AS latest_date + FROM commodities c + JOIN disagg_reports dr ON dr.commodity_id = c.id + {exchange_filter} + GROUP BY c.cftc_code + ), + latest_pos AS ( + SELECT l.cftc_code, l.commodity, l.exchange, l.latest_date, + dp.open_interest, + (dp.m_money_long - dp.m_money_short) AS m_money_net, + dp.chg_m_money_long, dp.chg_m_money_short + FROM latest l + JOIN commodities c ON c.cftc_code = l.cftc_code + JOIN disagg_reports dr ON dr.commodity_id = c.id AND dr.report_date = l.latest_date + JOIN disagg_positions dp ON dp.report_id = dr.id AND dp.row_type = 'All' + ), + lookback AS ( + SELECT c.cftc_code, + (dp.m_money_long - dp.m_money_short) AS net, + ROW_NUMBER() OVER (PARTITION BY c.cftc_code ORDER BY dr.report_date DESC) AS rn + FROM commodities c + JOIN disagg_reports dr ON dr.commodity_id = c.id + JOIN disagg_positions dp ON dp.report_id = dr.id AND dp.row_type = 'All' + ), + pct AS ( + SELECT lp.cftc_code, lp.commodity, lp.exchange, lp.latest_date, + lp.open_interest, lp.m_money_net, + lp.chg_m_money_long, lp.chg_m_money_short, + CAST( + (SELECT COUNT(*) FROM lookback lb2 + WHERE lb2.cftc_code = lp.cftc_code + AND lb2.rn <= ? AND lb2.net < lp.m_money_net) + AS REAL + ) / NULLIF( + (SELECT COUNT(*) FROM lookback lb3 + WHERE lb3.cftc_code = lp.cftc_code AND lb3.rn <= ?), + 0 + ) * 100.0 AS pct_rank + FROM latest_pos lp + ) + SELECT cftc_code, commodity, exchange, latest_date, + m_money_net, open_interest, pct_rank, + chg_m_money_long, chg_m_money_short + FROM pct + ORDER BY pct_rank DESC + LIMIT ? + """, + exchange_params + [lookback_weeks, lookback_weeks, top_n], + ).fetchall() + + result = [DisaggScreenerRow(**dict(r)) for r in rows] + if direction == 'long': + result = [r for r in result if r.pct_rank is not None and r.pct_rank >= 50] + elif direction == 'short': + result = [r for r in result if r.pct_rank is not None and r.pct_rank < 50] + return result + + +@router.get("/{cftc_code}/net-position-percentile") +def disagg_net_position_percentile( + cftc_code: str, + lookback_weeks: int = Query(156, ge=4, le=1560), +): + """ + Where does the current Managed Money net position sit in the + historical distribution over the last N weeks? + """ + with get_db() as conn: + row = conn.execute( + "SELECT id, name FROM commodities WHERE cftc_code = ?", (cftc_code,) + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail=f"Commodity {cftc_code} not found") + commodity_name = row['name'] + + history = conn.execute( + """ + SELECT (dp.m_money_long - dp.m_money_short) AS net + FROM disagg_positions dp + JOIN disagg_reports dr ON dr.id = dp.report_id + JOIN commodities c ON c.id = dr.commodity_id + WHERE c.cftc_code = ? AND dp.row_type = 'All' + ORDER BY dr.report_date DESC + LIMIT ? + """, + (cftc_code, lookback_weeks), + ).fetchall() + + if not history: + raise HTTPException(status_code=404, detail="No disaggregated data found") + + nets = [r[0] for r in history if r[0] is not None] + if not nets: + return { + "cftc_code": cftc_code, "commodity": commodity_name, + "current_net": None, "percentile": None, "z_score": None, + "lookback_weeks": lookback_weeks, "period_min": None, "period_max": None, + } + + current = nets[0] + n = len(nets) + below = sum(1 for v in nets[1:] if v < current) + percentile = round(below / max(n - 1, 1) * 100, 1) + + mean = sum(nets) / n + variance = sum((v - mean) ** 2 for v in nets) / n + std = variance ** 0.5 + z_score = round((current - mean) / std, 2) if std > 0 else 0.0 + + return { + "cftc_code": cftc_code, + "commodity": commodity_name, + "current_net": current, + "percentile": percentile, + "z_score": z_score, + "lookback_weeks": n, + "period_min": min(nets), + "period_max": max(nets), + } + + +@router.get("/compare", response_model=CompareResponse) +def disagg_compare( + codes: str = Query(..., description="Comma-separated CFTC codes, max 8"), + metric: str = Query("m_money_net"), + from_date: Optional[str] = Query(None), + to_date: Optional[str] = Query(None), + row_type: str = Query("All", pattern="^(All|Old|Other)$"), +): + code_list = [c.strip() for c in codes.split(",")][:8] + + COMPUTED = {"m_money_net", "prod_merc_net", "swap_net", "other_rept_net", "nonrept_net"} + DB_FIELDS = { + "open_interest", + "m_money_long", "m_money_short", "m_money_spread", + "prod_merc_long", "prod_merc_short", + "swap_long", "swap_short", "swap_spread", + "pct_m_money_long", "pct_m_money_short", + "traders_total", + } + + if metric not in COMPUTED and metric not in DB_FIELDS: + raise HTTPException(status_code=400, detail=f"Unknown metric: {metric}") + + commodities = [] + series: dict[str, list[ComparePoint]] = {} + + _EXPR = { + "m_money_net": "(dp.m_money_long - dp.m_money_short)", + "prod_merc_net": "(dp.prod_merc_long - dp.prod_merc_short)", + "swap_net": "(dp.swap_long - dp.swap_short)", + "other_rept_net": "(dp.other_rept_long - dp.other_rept_short)", + "nonrept_net": "(dp.nonrept_long - dp.nonrept_short)", + } + + with get_db() as conn: + for code in code_list: + try: + meta = _disagg_commodity_meta(conn, code) + commodities.append(CommodityMeta(**meta)) + except HTTPException: + continue + + select_expr = _EXPR.get(metric, f"dp.{metric}") + + sql = f""" + SELECT dr.report_date, {select_expr} AS value + FROM disagg_positions dp + JOIN disagg_reports dr ON dr.id = dp.report_id + JOIN commodities c ON c.id = dr.commodity_id + WHERE c.cftc_code = ? AND dp.row_type = ? + """ + params: list = [code, row_type] + if from_date: + sql += " AND dr.report_date >= ?" + params.append(from_date) + if to_date: + sql += " AND dr.report_date <= ?" + params.append(to_date) + sql += " ORDER BY dr.report_date ASC" + + rows = conn.execute(sql, params).fetchall() + series[code] = [ComparePoint(report_date=r[0], value=r[1]) for r in rows] + + return CompareResponse(metric=metric, commodities=commodities, series=series) diff --git a/frontend/app.js b/frontend/app.js index 515b493..867fcaf 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -2,18 +2,25 @@ const state = { view: 'detail', selectedCode: null, + disaggMode: false, // true when current market uses disaggregated data dateRange: '3Y', rowType: 'All', - metric: 'noncomm_net', + metric: 'm_money_net', overlayOI: true, exchange: '', - compareMarkets: [], // [{cftc_code, name, exchange_abbr, color}] - compareMetric: 'noncomm_net', + 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); @@ -28,19 +35,27 @@ const API = { if (toDate) p.append('to_date', toDate); return API.get(`/api/positions/${code}/history?${p}`); }, - extremes: (code) => API.get(`/api/positions/${code}/extremes`), + 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: 200 }); + 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/analytics/screener?${p}`); + 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(`/api/positions/compare?${p}`); + return API.get(`${endpoint}?${p}`); }, }; @@ -93,11 +108,15 @@ function buildDetailChart(data, metric, overlayOI) { 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 (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)); + if (precomputed.has(metric)) { + values = data.map(d => d[metric] ?? null); } else { values = data.map(d => d[metric] ?? null); } @@ -271,6 +290,33 @@ function buildMarketTree(commodities, filter = '') { } // ── 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 => { @@ -286,6 +332,7 @@ async function selectMarket(code) { 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(); @@ -296,10 +343,14 @@ async function refreshDetailChart() { 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 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; @@ -308,22 +359,19 @@ async function refreshDetailChart() { // 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 metricValues = data.map(d => d[state.metric]).filter(v => v !== null && v !== undefined); - const currentVal = metricValues[metricValues.length - 1]; - const maxVal = Math.max(...metricValues); - const minVal = Math.min(...metricValues); + 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); + 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); @@ -331,15 +379,28 @@ async function refreshDetailChart() { 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) { + // 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); } @@ -351,7 +412,6 @@ async function loadScreener() { 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'; @@ -359,11 +419,15 @@ async function loadScreener() { const tbody = document.getElementById('screenerBody'); tbody.innerHTML = '