COTexplorer/app/api/routes/commodities.py
Greg 90c2ae3f35 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>
2026-03-22 18:22:16 +01:00

75 lines
2.9 KiB
Python

from fastapi import APIRouter, HTTPException, Query
from typing import Optional
from app.db import get_db
from app.api.models import CommodityMeta, ExchangeInfo
router = APIRouter(prefix="/api", tags=["commodities"])
@router.get("/exchanges", response_model=list[ExchangeInfo])
def get_exchanges():
with get_db() as conn:
rows = conn.execute(
"""
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()
return [ExchangeInfo(**dict(r)) for r in rows]
@router.get("/commodities", response_model=list[CommodityMeta])
def get_commodities(exchange: Optional[str] = Query(None)):
sql = """
SELECT c.cftc_code, c.name, c.exchange, c.exchange_abbr, c.contract_unit,
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
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()
return [CommodityMeta(**dict(r)) for r in rows]
@router.get("/commodities/{cftc_code}", response_model=CommodityMeta)
def get_commodity(cftc_code: str):
with get_db() as conn:
row = conn.execute(
"""
SELECT c.cftc_code, c.name, c.exchange, c.exchange_abbr, c.contract_unit,
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
""",
(cftc_code,),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail=f"Commodity {cftc_code} not found")
return CommodityMeta(**dict(row))