Files
OpenFin/backend/services/forward_curve.py
OpenSquared 281b7e30ba fix: forward curve — stop 40+ yfinance errors per cycle, batch downloads
Two root causes in the logs:
1. fetch_forward_curves() tried 5 offsets × 8 commodities = 40 individual
   yfinance requests for monthly contracts (CLN26, GCQ26, etc.) that Yahoo
   Finance does not support — generating ERROR storm and triggering hard
   rate limiting that cascades onto front-month CL=F/GC=F calls used by
   the main cycle.
2. Ticker format lacked exchange suffix (.NYM/.CMX/.CBT).

Fix: replace the per-ticker loop with two batch yfinance.download() calls
(one for all 8 front-months, one for all deferred candidates). Failed
deferred lookups are logged at DEBUG level and reported as structure='unknown'
rather than ERROR, since Yahoo Finance does not expose monthly commodity
contracts reliably. Added 1s sleep between batches to avoid rate spiking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 08:30:15 +02:00

151 lines
5.5 KiB
Python

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