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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user