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:
parent
2c28ac3b0a
commit
90c2ae3f35
@ -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"
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
323
app/api/routes/disagg.py
Normal 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)
|
||||
143
frontend/app.js
143
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 = '<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');
|
||||
|
||||
@ -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,13 +61,26 @@
|
||||
<div class="control-group">
|
||||
<label>Metric</label>
|
||||
<select id="metricSelect">
|
||||
<option value="noncomm_net">Non-Comm Net</option>
|
||||
<option value="noncomm_long">Non-Comm Long</option>
|
||||
<option value="noncomm_short">Non-Comm Short</option>
|
||||
<option value="comm_net">Commercial Net</option>
|
||||
<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 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>
|
||||
<option value="comm_net">Commercial Net</option>
|
||||
<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>
|
||||
<option value="open_interest">Open Interest</option>
|
||||
<option value="comm_net">Commercial Net</option>
|
||||
<option value="pct_noncomm_long">% Non-Comm Long</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">
|
||||
|
||||
@ -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); }
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user