diff --git a/backend/services/forward_curve.py b/backend/services/forward_curve.py index 13eb4e3..c8b39fa 100644 --- a/backend/services/forward_curve.py +++ b/backend/services/forward_curve.py @@ -1,18 +1,20 @@ """ Forward Curve Service — contango / backwardation per commodity. -Uses yfinance to compare front-month vs near-dated futures contracts. + +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 +from typing import List, Dict, Any, Optional, Tuple 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 = [ +_CURVE_SPECS: List[Tuple[str, str, str, int]] = [ ("CL", "WTI Crude", "energy", 3), ("NG", "Natural Gas", "energy", 3), ("GC", "Gold", "metals", 3), @@ -23,51 +25,104 @@ _CURVE_SPECS = [ ("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", +} -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}" +# 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 _get_price(ticker: str) -> Optional[float]: +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: - 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 + 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 and far-month prices to compute curve slope.""" - results = [] + """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 = _get_price(front_ticker) + front_price = front_prices.get(front_ticker) + if not front_price: - logger.warning(f"Forward curve: no front-month price for {label} ({front_ticker})") + logger.debug(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: + # 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 = None + slope_pct: Optional[float] = None structure = "unknown" if far_price: slope_pct = round((far_price - front_price) / front_price * 100, 2)