""" Forward Curve Service — contango / backwardation per commodity. Fetches front-month continuous futures prices from Yahoo Finance in a single batch request to avoid rate limiting. The "far" price uses a secondary batch attempt for deferred contracts; if unavailable the structure is reported as "unknown" rather than generating ERROR logs that pollute the system. """ import logging import time from datetime import datetime, timedelta from typing import List, Dict, Any, Optional, Tuple logger = logging.getLogger(__name__) # (base_ticker, label, asset_class, months_spread) _CURVE_SPECS: List[Tuple[str, str, str, int]] = [ ("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), ] # 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", } # Exchange suffixes required by Yahoo Finance for specific monthly contracts _EXCHANGE_SUFFIX = { "CL": ".NYM", "NG": ".NYM", "GC": ".CMX", "SI": ".CMX", "HG": ".CMX", "ZC": ".CBT", "ZW": ".CBT", "ZS": ".CBT", } def _deferred_tickers(base: str, months_ahead: int) -> List[str]: """Return candidate deferred-contract tickers to try (with exchange suffix).""" candidates = [] suffix = _EXCHANGE_SUFFIX.get(base, "") for offset in [0, 1, -1, 2, -2]: target = datetime.now() + timedelta(days=(months_ahead + offset) * 30) code = _MONTH_CODES[target.month] year = str(target.year)[-2:] candidates.append(f"{base}{code}{year}{suffix}") return candidates def _batch_prices(tickers: List[str]) -> Dict[str, Optional[float]]: """Download a list of tickers in a single yfinance call. Returns {ticker: price}.""" import yfinance as yf if not tickers: return {} try: data = yf.download( tickers, period="5d", progress=False, auto_adjust=True, group_by="ticker", threads=False, ) result: Dict[str, Optional[float]] = {} for t in tickers: try: if len(tickers) == 1: col = data["Close"] else: col = data[t]["Close"] if t in data.columns.get_level_values(0) else None if col is None or col.dropna().empty: result[t] = None else: result[t] = round(float(col.dropna().iloc[-1]), 4) except Exception: result[t] = None return result except Exception as e: logger.debug(f"Forward curve batch download failed: {e}") return {t: None for t in tickers} def fetch_forward_curves() -> List[Dict[str, Any]]: """Fetch front-month prices and attempt M+3 deferred prices for 8 commodities. Uses two batch downloads: one for all front-month contracts, one for candidate deferred tickers. Yahoo Finance does not reliably expose specific monthly commodity contracts, so deferred prices are treated as optional; missing data is reported as structure='unknown' rather than raising ERROR logs. """ front_tickers = [f"{base}=F" for base, *_ in _CURVE_SPECS] # ── Batch 1: front-month prices ────────────────────────────────────────── front_prices = _batch_prices(front_tickers) time.sleep(1) # brief pause before second batch # ── Batch 2: deferred candidates (best-effort) ─────────────────────────── deferred_candidates: List[str] = [] for base, _, _, spread in _CURVE_SPECS: deferred_candidates.extend(_deferred_tickers(base, spread)) deferred_prices = _batch_prices(deferred_candidates) results: List[Dict[str, Any]] = [] for base, label, ac, spread in _CURVE_SPECS: front_ticker = f"{base}=F" front_price = front_prices.get(front_ticker) if not front_price: logger.debug(f"Forward curve: no front-month price for {label} ({front_ticker})") continue # Pick first available deferred price from candidates far_price: Optional[float] = None for t in _deferred_tickers(base, spread): p = deferred_prices.get(t) if p and p > 0: far_price = p break slope_pct: Optional[float] = 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