Expose disaggregated COT data in the UI (wheat focus)

The disagg dataset (2019–2026, 468 markets) was in the DB but invisible.
This wires it into every layer of the app:

Backend:
- models.py: add has_disagg to CommodityMeta; add DisaggPositionPoint,
  DisaggHistoryResponse, DisaggScreenerRow models
- commodities.py: join disagg_reports to populate has_disagg flag and
  correct first/last dates; HAVING filter removes markets with no data
- disagg.py (new): /api/disagg/{code}/history, /api/disagg/screener,
  /api/disagg/{code}/net-position-percentile, /api/disagg/compare
- main.py: register disagg router

Frontend:
- Metric selector shows Disaggregated optgroup (Managed Money, Prod/Merchant,
  Swap Dealer, Other Rept) when market has has_disagg=true, hides Legacy group
- Detail view auto-switches to disagg endpoint and defaults to m_money_net
  for disagg markets; shows green "Disaggregated" badge
- Screener always uses disagg endpoint (Managed Money percentile rank)
- Compare uses /api/disagg/compare for disagg metrics
- style.css: add .badge-disagg green variant

Result: wheat markets (SRW, HRW, HRSpring, Black Sea) now show 7 years of
disaggregated positioning data with Managed Money as the default metric.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Greg 2026-03-22 18:22:16 +01:00
parent 2c28ac3b0a
commit 90c2ae3f35
7 changed files with 550 additions and 63 deletions

View File

@ -3,7 +3,7 @@ from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pathlib import Path 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( app = FastAPI(
title="CFTC COT Explorer", title="CFTC COT Explorer",
@ -15,6 +15,7 @@ app.include_router(commodities.router)
app.include_router(positions.router) app.include_router(positions.router)
app.include_router(analytics.router) app.include_router(analytics.router)
app.include_router(reports.router) app.include_router(reports.router)
app.include_router(disagg.router)
FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend" FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend"

View File

@ -11,6 +11,7 @@ class CommodityMeta(BaseModel):
first_date: Optional[str] first_date: Optional[str]
last_date: Optional[str] last_date: Optional[str]
week_count: int week_count: int
has_disagg: bool = False
class ExchangeInfo(BaseModel): class ExchangeInfo(BaseModel):
@ -113,6 +114,65 @@ class PercentileResponse(BaseModel):
period_max: Optional[int] 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): class ReportDateInfo(BaseModel):
date: str date: str
commodity_count: int commodity_count: int

View File

@ -12,10 +12,12 @@ def get_exchanges():
with get_db() as conn: with get_db() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT exchange_abbr, exchange, SELECT c.exchange_abbr, c.exchange,
COUNT(*) AS commodity_count COUNT(DISTINCT c.id) AS commodity_count
FROM commodities FROM commodities c
GROUP BY exchange_abbr 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 ORDER BY commodity_count DESC
""" """
).fetchall() ).fetchall()
@ -26,17 +28,23 @@ def get_exchanges():
def get_commodities(exchange: Optional[str] = Query(None)): def get_commodities(exchange: Optional[str] = Query(None)):
sql = """ sql = """
SELECT c.cftc_code, c.name, c.exchange, c.exchange_abbr, c.contract_unit, SELECT c.cftc_code, c.name, c.exchange, c.exchange_abbr, c.contract_unit,
MIN(r.report_date) AS first_date, COALESCE(MIN(r.report_date), MIN(dr.report_date)) AS first_date,
MAX(r.report_date) AS last_date, COALESCE(MAX(r.report_date), MAX(dr.report_date)) AS last_date,
COUNT(DISTINCT r.report_date) AS week_count 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 FROM commodities c
LEFT JOIN reports r ON r.commodity_id = c.id LEFT JOIN reports r ON r.commodity_id = c.id
LEFT JOIN disagg_reports dr ON dr.commodity_id = c.id
""" """
params = [] params = []
if exchange: if exchange:
sql += " WHERE c.exchange_abbr = ?" sql += " WHERE c.exchange_abbr = ?"
params.append(exchange) 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: with get_db() as conn:
rows = conn.execute(sql, params).fetchall() rows = conn.execute(sql, params).fetchall()
@ -49,11 +57,13 @@ def get_commodity(cftc_code: str):
row = conn.execute( row = conn.execute(
""" """
SELECT c.cftc_code, c.name, c.exchange, c.exchange_abbr, c.contract_unit, SELECT c.cftc_code, c.name, c.exchange, c.exchange_abbr, c.contract_unit,
MIN(r.report_date) AS first_date, COALESCE(MIN(r.report_date), MIN(dr.report_date)) AS first_date,
MAX(r.report_date) AS last_date, COALESCE(MAX(r.report_date), MAX(dr.report_date)) AS last_date,
COUNT(DISTINCT r.report_date) AS week_count 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 FROM commodities c
LEFT JOIN reports r ON r.commodity_id = c.id 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 = ? WHERE c.cftc_code = ?
GROUP BY c.id GROUP BY c.id
""", """,

323
app/api/routes/disagg.py Normal file
View File

@ -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)

View File

@ -2,18 +2,25 @@
const state = { const state = {
view: 'detail', view: 'detail',
selectedCode: null, selectedCode: null,
disaggMode: false, // true when current market uses disaggregated data
dateRange: '3Y', dateRange: '3Y',
rowType: 'All', rowType: 'All',
metric: 'noncomm_net', metric: 'm_money_net',
overlayOI: true, overlayOI: true,
exchange: '', exchange: '',
compareMarkets: [], // [{cftc_code, name, exchange_abbr, color}] compareMarkets: [], // [{cftc_code, name, exchange_abbr, color, has_disagg}]
compareMetric: 'noncomm_net', compareMetric: 'm_money_net',
compareRange: '3Y', compareRange: '3Y',
allCommodities: [], // full list from API allCommodities: [], // full list from API
}; };
// ── 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 = { const API = {
async get(path) { async get(path) {
const r = await fetch(path); const r = await fetch(path);
@ -28,19 +35,27 @@ const API = {
if (toDate) p.append('to_date', toDate); if (toDate) p.append('to_date', toDate);
return API.get(`/api/positions/${code}/history?${p}`); 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}`), 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) => { 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 (exchange) p.append('exchange', exchange);
if (direction) p.append('direction', direction === 'long' ? 'long' : 'short'); 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) => { 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' }); const p = new URLSearchParams({ codes: codes.join(','), metric, row_type: 'All' });
if (fromDate) p.append('from_date', fromDate); if (fromDate) p.append('from_date', fromDate);
if (toDate) p.append('to_date', toDate); 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 labels = data.map(d => d.report_date);
const metricLabel = document.getElementById('metricSelect').selectedOptions[0]?.text || metric; 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; let values;
if (metric === 'noncomm_net') { if (precomputed.has(metric)) {
values = data.map(d => (d.noncomm_long ?? 0) - (d.noncomm_short ?? 0)); values = data.map(d => d[metric] ?? null);
} else if (metric === 'comm_net') {
values = data.map(d => (d.comm_long ?? 0) - (d.comm_short ?? 0));
} else { } else {
values = data.map(d => d[metric] ?? null); values = data.map(d => d[metric] ?? null);
} }
@ -271,6 +290,33 @@ function buildMarketTree(commodities, filter = '') {
} }
// ── Detail view logic ────────────────────────────────────────────────────── // ── 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) { async function selectMarket(code) {
state.selectedCode = code; state.selectedCode = code;
document.querySelectorAll('.market-item').forEach(el => { document.querySelectorAll('.market-item').forEach(el => {
@ -286,6 +332,7 @@ async function selectMarket(code) {
document.getElementById('detail-title').textContent = meta.name; document.getElementById('detail-title').textContent = meta.name;
document.getElementById('detail-exchange').textContent = meta.exchange_abbr; document.getElementById('detail-exchange').textContent = meta.exchange_abbr;
document.getElementById('detail-unit').textContent = meta.contract_unit || ''; document.getElementById('detail-unit').textContent = meta.contract_unit || '';
_applyDisaggMode(!!meta.has_disagg);
} }
await refreshDetailChart(); await refreshDetailChart();
@ -296,10 +343,14 @@ async function refreshDetailChart() {
const fromDate = dateRangeToFrom(state.dateRange); const fromDate = dateRangeToFrom(state.dateRange);
try { try {
const [hist, pctileData] = await Promise.all([ const histPromise = state.disaggMode
API.history(state.selectedCode, fromDate, null, state.rowType), ? API.disaggHistory(state.selectedCode, fromDate, null, state.rowType)
API.percentile(state.selectedCode, 156).catch(() => null), : 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; const data = hist.data;
if (!data.length) return; if (!data.length) return;
@ -308,12 +359,9 @@ async function refreshDetailChart() {
// Stats bar // Stats bar
const latest = data[data.length - 1]; const latest = data[data.length - 1];
const metricValues = data.map(d => { const metricValues = data.map(d => d[state.metric]).filter(v => v !== null && v !== undefined);
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);
if (metricValues.length) {
const currentVal = metricValues[metricValues.length - 1]; const currentVal = metricValues[metricValues.length - 1];
const maxVal = Math.max(...metricValues); const maxVal = Math.max(...metricValues);
const minVal = Math.min(...metricValues); const minVal = Math.min(...metricValues);
@ -321,9 +369,9 @@ async function refreshDetailChart() {
const statCurrent = document.getElementById('statCurrent'); const statCurrent = document.getElementById('statCurrent');
statCurrent.textContent = fmt(currentVal); statCurrent.textContent = fmt(currentVal);
statCurrent.className = 'stat-value' + (currentVal > 0 ? ' positive' : currentVal < 0 ? ' negative' : ''); statCurrent.className = 'stat-value' + (currentVal > 0 ? ' positive' : currentVal < 0 ? ' negative' : '');
document.getElementById('statMax').textContent = fmt(maxVal); document.getElementById('statMax').textContent = fmt(maxVal);
document.getElementById('statMin').textContent = fmt(minVal); document.getElementById('statMin').textContent = fmt(minVal);
}
if (pctileData?.percentile !== null && pctileData?.percentile !== undefined) { if (pctileData?.percentile !== null && pctileData?.percentile !== undefined) {
document.getElementById('statPctile').textContent = fmtPct(pctileData.percentile); document.getElementById('statPctile').textContent = fmtPct(pctileData.percentile);
@ -331,15 +379,28 @@ async function refreshDetailChart() {
document.getElementById('statPctile').textContent = '—'; document.getElementById('statPctile').textContent = '—';
} }
// Week change for non-comm net // Week change — use the primary net metric's change columns
const chgLong = latest.chg_noncomm_long; const [chgLong, chgShort] = state.disaggMode
const chgShort = latest.chg_noncomm_short; ? [latest.chg_m_money_long, latest.chg_m_money_short]
if (chgLong !== null && chgShort !== null) { : [latest.chg_noncomm_long, latest.chg_noncomm_short];
if (chgLong != null && chgShort != null) {
const netChg = chgLong - chgShort; const netChg = chgLong - chgShort;
const el = document.getElementById('statChange'); const el = document.getElementById('statChange');
el.textContent = fmtChange(netChg) + ' net'; el.textContent = fmtChange(netChg) + ' net';
el.className = 'stat-value' + (netChg > 0 ? ' positive' : netChg < 0 ? ' negative' : ''); 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) { } catch (e) {
console.error('Failed to load chart data:', e); console.error('Failed to load chart data:', e);
} }
@ -351,7 +412,6 @@ async function loadScreener() {
const dirSel = document.getElementById('screenerDirection').value; const dirSel = document.getElementById('screenerDirection').value;
const lookback = document.getElementById('screenerLookback').value; const lookback = document.getElementById('screenerLookback').value;
// Map UI direction to API direction
let direction = ''; let direction = '';
if (dirSel === 'long') direction = 'long'; if (dirSel === 'long') direction = 'long';
else if (dirSel === 'short') direction = 'short'; else if (dirSel === 'short') direction = 'short';
@ -359,11 +419,15 @@ async function loadScreener() {
const tbody = document.getElementById('screenerBody'); const tbody = document.getElementById('screenerBody');
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:20px;color:#6b7280">Loading...</td></tr>'; tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:20px;color:#6b7280">Loading...</td></tr>';
// Update header to reflect Managed Money
const thead = document.querySelector('#screenerTable thead tr');
thead.cells[2].textContent = 'Mgd Money Net';
thead.cells[5].textContent = 'Wk Chg Long';
thead.cells[6].textContent = 'Wk Chg Short';
try { try {
let rows = await API.screener(exchange, direction, lookback); 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); if (dirSel === 'long') rows = rows.filter(r => r.pct_rank >= 70);
else if (dirSel === 'short') rows = rows.filter(r => r.pct_rank <= 30); else if (dirSel === 'short') rows = rows.filter(r => r.pct_rank <= 30);
@ -371,20 +435,19 @@ async function loadScreener() {
for (const row of rows) { for (const row of rows) {
const pct = row.pct_rank; const pct = row.pct_rank;
const pctClass = pct >= 70 ? 'extreme-long' : pct <= 30 ? 'extreme-short' : 'neutral'; 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'); const tr = document.createElement('tr');
tr.innerHTML = ` tr.innerHTML = `
<td><strong>${row.commodity}</strong></td> <td><strong>${row.commodity}</strong></td>
<td><span class="badge">${row.exchange}</span></td> <td><span class="badge">${row.exchange}</span></td>
<td class="num">${fmt(row.noncomm_net)}</td> <td class="num">${fmt(row.m_money_net)}</td>
<td class="num">${fmt(row.open_interest)}</td> <td class="num">${fmt(row.open_interest)}</td>
<td class="num pctile-cell ${pctClass}"> <td class="num pctile-cell ${pctClass}">
<span class="pctile-bar" style="width:${Math.round((pct || 0) * 0.6)}px"></span> <span class="pctile-bar" style="width:${Math.round((pct || 0) * 0.6)}px"></span>
${pct !== null ? fmtPct(pct) : '—'} ${pct !== null ? fmtPct(pct) : '—'}
</td> </td>
<td class="num ${row.chg_noncomm_long > 0 ? 'extreme-long' : row.chg_noncomm_long < 0 ? 'extreme-short' : ''}">${fmtChange(row.chg_noncomm_long)}</td> <td class="num ${row.chg_m_money_long > 0 ? 'extreme-long' : row.chg_m_money_long < 0 ? 'extreme-short' : ''}">${fmtChange(row.chg_m_money_long)}</td>
<td class="num ${row.chg_noncomm_short < 0 ? 'extreme-long' : row.chg_noncomm_short > 0 ? 'extreme-short' : ''}">${fmtChange(row.chg_noncomm_short)}</td> <td class="num ${row.chg_m_money_short < 0 ? 'extreme-long' : row.chg_m_money_short > 0 ? 'extreme-short' : ''}">${fmtChange(row.chg_m_money_short)}</td>
`; `;
tr.addEventListener('click', () => { tr.addEventListener('click', () => {
switchView('detail'); switchView('detail');

View File

@ -37,6 +37,7 @@
<div id="detail-header"> <div id="detail-header">
<h2 id="detail-title"></h2> <h2 id="detail-title"></h2>
<span id="detail-exchange" class="badge"></span> <span id="detail-exchange" class="badge"></span>
<span id="detail-report-type" class="badge badge-disagg" style="display:none">Disaggregated</span>
<span id="detail-unit" class="unit"></span> <span id="detail-unit" class="unit"></span>
</div> </div>
<div class="chart-controls"> <div class="chart-controls">
@ -60,6 +61,18 @@
<div class="control-group"> <div class="control-group">
<label>Metric</label> <label>Metric</label>
<select id="metricSelect"> <select id="metricSelect">
<optgroup label="Disaggregated" id="metricDisaggGroup">
<option value="m_money_net">Managed Money Net</option>
<option value="m_money_long">Mgd Money Long</option>
<option value="m_money_short">Mgd Money Short</option>
<option value="prod_merc_net">Prod/Merchant Net</option>
<option value="swap_net">Swap Dealer Net</option>
<option value="other_rept_net">Other Rept Net</option>
<option value="open_interest">Open Interest</option>
<option value="pct_m_money_long">% Mgd Money Long</option>
<option value="pct_m_money_short">% Mgd Money Short</option>
</optgroup>
<optgroup label="Legacy COT" id="metricLegacyGroup">
<option value="noncomm_net">Non-Comm Net</option> <option value="noncomm_net">Non-Comm Net</option>
<option value="noncomm_long">Non-Comm Long</option> <option value="noncomm_long">Non-Comm Long</option>
<option value="noncomm_short">Non-Comm Short</option> <option value="noncomm_short">Non-Comm Short</option>
@ -67,6 +80,7 @@
<option value="open_interest">Open Interest</option> <option value="open_interest">Open Interest</option>
<option value="pct_noncomm_long">% Non-Comm Long</option> <option value="pct_noncomm_long">% Non-Comm Long</option>
<option value="pct_noncomm_short">% Non-Comm Short</option> <option value="pct_noncomm_short">% Non-Comm Short</option>
</optgroup>
</select> </select>
</div> </div>
<div class="control-group"> <div class="control-group">
@ -100,6 +114,10 @@
<span class="stat-label">Week Change</span> <span class="stat-label">Week Change</span>
<span class="stat-value" id="statChange"></span> <span class="stat-value" id="statChange"></span>
</div> </div>
<div class="stat-item">
<span class="stat-label" id="statNetLabel">Net</span>
<span class="stat-value" id="statNet"></span>
</div>
</div> </div>
</div> </div>
</section> </section>
@ -156,10 +174,17 @@
<div class="control-group"> <div class="control-group">
<label>Metric</label> <label>Metric</label>
<select id="compareMetric"> <select id="compareMetric">
<option value="noncomm_net">Non-Comm Net</option> <optgroup label="Disaggregated">
<option value="m_money_net">Managed Money Net</option>
<option value="prod_merc_net">Prod/Merchant Net</option>
<option value="swap_net">Swap Dealer Net</option>
<option value="open_interest">Open Interest</option> <option value="open_interest">Open Interest</option>
</optgroup>
<optgroup label="Legacy COT">
<option value="noncomm_net">Non-Comm Net</option>
<option value="comm_net">Commercial Net</option> <option value="comm_net">Commercial Net</option>
<option value="pct_noncomm_long">% Non-Comm Long</option> <option value="pct_noncomm_long">% Non-Comm Long</option>
</optgroup>
</select> </select>
</div> </div>
<div class="control-group"> <div class="control-group">

View File

@ -189,6 +189,11 @@ main { flex: 1; overflow: hidden; position: relative; }
font-weight: 600; font-weight: 600;
color: var(--text-muted); color: var(--text-muted);
} }
.badge-disagg {
background: rgba(34, 197, 94, 0.12);
border-color: rgba(34, 197, 94, 0.35);
color: #22c55e;
}
.unit { font-size: 12px; color: var(--text-muted); } .unit { font-size: 12px; color: var(--text-muted); }