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