Files
OpenFin/backend/services/forward_curve.py
OpenSquared 3b7fa35456 feat: Specialist Desks v2 — COT, Forward Curves, Surprise Index, Hawk/Dove scorer
- COT Positioning: CFTC disaggregated + financial futures (19 markets) via Socrata free API
  net MM position % OI + weekly change stored in cot_data table
- Forward Curves: yfinance front-month vs +3M slope (8 commodities)
  contango/backwardation/flat stored in forward_curve_data table
- Surprise Index: consensus_estimate + actual_value on specialist_reports
  auto-computes surprise_score = actual - consensus on save
- Hawk/Dove Text Scorer: GPT-4o-mini endpoint for CB statements
  score -1..+1, label, summary, key_phrases (forex/bonds: hawk/dove; commodities: bull/bear)
- AI context injection: COT net positioning, forward curve structure,
  surprise scores, upcoming consensus estimates injected into all desk blocks
- Frontend: COT panel (net% bars), Forward Curves panel, SurpriseInput
  on report cards, Hawk/Dove scorer in forex/bonds config tab
- auto_cycle.py: non-blocking COT + curve refresh before each cycle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 18:00:46 +02:00

96 lines
3.2 KiB
Python

"""
Forward Curve Service — contango / backwardation per commodity.
Uses yfinance to compare front-month vs near-dated futures contracts.
"""
import logging
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
logger = logging.getLogger(__name__)
# Month codes for futures tickers (standard CME/NYMEX/CBOT convention)
_MONTH_CODES = {1:'F',2:'G',3:'H',4:'J',5:'K',6:'M',7:'N',8:'Q',9:'U',10:'V',11:'X',12:'Z'}
# (base_ticker, label, asset_class, months_spread)
_CURVE_SPECS = [
("CL", "WTI Crude", "energy", 3),
("NG", "Natural Gas", "energy", 3),
("GC", "Gold", "metals", 3),
("SI", "Silver", "metals", 3),
("HG", "Copper", "metals", 3),
("ZC", "Corn", "agri", 3),
("ZW", "Wheat", "agri", 3),
("ZS", "Soybeans", "agri", 3),
]
def _ticker_for_month(base: str, months_ahead: int) -> str:
target = datetime.now() + timedelta(days=months_ahead * 30)
code = _MONTH_CODES[target.month]
year = str(target.year)[-2:]
return f"{base}{code}{year}"
def _get_price(ticker: str) -> Optional[float]:
try:
import yfinance as yf
data = yf.download(ticker, period="3d", progress=False, auto_adjust=True)
if data.empty:
return None
close = data["Close"].dropna()
if close.empty:
return None
val = float(close.iloc[-1])
# Handle multi-index DataFrames
if hasattr(val, '__iter__'):
val = list(val)[0]
return round(val, 4) if val and val > 0 else None
except Exception:
return None
def fetch_forward_curves() -> List[Dict[str, Any]]:
"""Fetch front-month and far-month prices to compute curve slope."""
results = []
for base, label, ac, spread in _CURVE_SPECS:
front_ticker = f"{base}=F"
front_price = _get_price(front_ticker)
if not front_price:
logger.warning(f"Forward curve: no front-month price for {label} ({front_ticker})")
continue
# Try M+spread, M+spread-1, M+spread+1 (some contract months are illiquid)
far_price = None
for offset in [0, -1, 1, -2, 2]:
far_ticker = _ticker_for_month(base, spread + offset)
far_price = _get_price(far_ticker)
if far_price:
break
slope_pct = None
structure = "unknown"
if far_price:
slope_pct = round((far_price - front_price) / front_price * 100, 2)
if slope_pct > 0.15:
structure = "contango"
elif slope_pct < -0.15:
structure = "backwardation"
else:
structure = "flat"
results.append({
"asset": label,
"asset_class": ac,
"front_price": round(front_price, 2),
"far_price": round(far_price, 2) if far_price else None,
"slope_pct": slope_pct,
"structure": structure,
"months_spread": spread,
})
logger.info(
f"Forward curve {label}: front={front_price:.2f} "
f"far={far_price} slope={slope_pct}% -> {structure}"
)
return results