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 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"

View File

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

View File

@ -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
""",

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 = {
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,12 +359,9 @@ 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);
if (metricValues.length) {
const currentVal = metricValues[metricValues.length - 1];
const maxVal = Math.max(...metricValues);
const minVal = Math.min(...metricValues);
@ -321,9 +369,9 @@ async function refreshDetailChart() {
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 = '<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 {
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);
@ -371,20 +435,19 @@ async function loadScreener() {
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 = `
<td><strong>${row.commodity}</strong></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 pctile-cell ${pctClass}">
<span class="pctile-bar" style="width:${Math.round((pct || 0) * 0.6)}px"></span>
${pct !== null ? fmtPct(pct) : '—'}
</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_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_long > 0 ? 'extreme-long' : row.chg_m_money_long < 0 ? 'extreme-short' : ''}">${fmtChange(row.chg_m_money_long)}</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', () => {
switchView('detail');

View File

@ -37,6 +37,7 @@
<div id="detail-header">
<h2 id="detail-title"></h2>
<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>
</div>
<div class="chart-controls">
@ -60,6 +61,18 @@
<div class="control-group">
<label>Metric</label>
<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_long">Non-Comm Long</option>
<option value="noncomm_short">Non-Comm Short</option>
@ -67,6 +80,7 @@
<option value="open_interest">Open Interest</option>
<option value="pct_noncomm_long">% Non-Comm Long</option>
<option value="pct_noncomm_short">% Non-Comm Short</option>
</optgroup>
</select>
</div>
<div class="control-group">
@ -100,6 +114,10 @@
<span class="stat-label">Week Change</span>
<span class="stat-value" id="statChange"></span>
</div>
<div class="stat-item">
<span class="stat-label" id="statNetLabel">Net</span>
<span class="stat-value" id="statNet"></span>
</div>
</div>
</div>
</section>
@ -156,10 +174,17 @@
<div class="control-group">
<label>Metric</label>
<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>
</optgroup>
<optgroup label="Legacy COT">
<option value="noncomm_net">Non-Comm Net</option>
<option value="comm_net">Commercial Net</option>
<option value="pct_noncomm_long">% Non-Comm Long</option>
</optgroup>
</select>
</div>
<div class="control-group">

View File

@ -189,6 +189,11 @@ main { flex: 1; overflow: hidden; position: relative; }
font-weight: 600;
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); }