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>
This commit is contained in:
OpenSquared
2026-06-24 08:30:15 +02:00
parent a31f6e21b1
commit 281b7e30ba

View File

@@ -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)