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.
|
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 logging
|
||||||
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional, Tuple
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
# (base_ticker, label, asset_class, months_spread)
|
||||||
_CURVE_SPECS = [
|
_CURVE_SPECS: List[Tuple[str, str, str, int]] = [
|
||||||
("CL", "WTI Crude", "energy", 3),
|
("CL", "WTI Crude", "energy", 3),
|
||||||
("NG", "Natural Gas", "energy", 3),
|
("NG", "Natural Gas", "energy", 3),
|
||||||
("GC", "Gold", "metals", 3),
|
("GC", "Gold", "metals", 3),
|
||||||
@@ -23,51 +25,104 @@ _CURVE_SPECS = [
|
|||||||
("ZS", "Soybeans", "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",
|
||||||
|
}
|
||||||
|
|
||||||
def _ticker_for_month(base: str, months_ahead: int) -> str:
|
# Exchange suffixes required by Yahoo Finance for specific monthly contracts
|
||||||
target = datetime.now() + timedelta(days=months_ahead * 30)
|
_EXCHANGE_SUFFIX = {
|
||||||
code = _MONTH_CODES[target.month]
|
"CL": ".NYM", "NG": ".NYM",
|
||||||
year = str(target.year)[-2:]
|
"GC": ".CMX", "SI": ".CMX", "HG": ".CMX",
|
||||||
return f"{base}{code}{year}"
|
"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:
|
try:
|
||||||
import yfinance as yf
|
data = yf.download(
|
||||||
data = yf.download(ticker, period="3d", progress=False, auto_adjust=True)
|
tickers,
|
||||||
if data.empty:
|
period="5d",
|
||||||
return None
|
progress=False,
|
||||||
close = data["Close"].dropna()
|
auto_adjust=True,
|
||||||
if close.empty:
|
group_by="ticker",
|
||||||
return None
|
threads=False,
|
||||||
val = float(close.iloc[-1])
|
)
|
||||||
# Handle multi-index DataFrames
|
result: Dict[str, Optional[float]] = {}
|
||||||
if hasattr(val, '__iter__'):
|
for t in tickers:
|
||||||
val = list(val)[0]
|
try:
|
||||||
return round(val, 4) if val and val > 0 else None
|
if len(tickers) == 1:
|
||||||
except Exception:
|
col = data["Close"]
|
||||||
return None
|
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]]:
|
def fetch_forward_curves() -> List[Dict[str, Any]]:
|
||||||
"""Fetch front-month and far-month prices to compute curve slope."""
|
"""Fetch front-month prices and attempt M+3 deferred prices for 8 commodities.
|
||||||
results = []
|
|
||||||
|
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:
|
for base, label, ac, spread in _CURVE_SPECS:
|
||||||
front_ticker = f"{base}=F"
|
front_ticker = f"{base}=F"
|
||||||
front_price = _get_price(front_ticker)
|
front_price = front_prices.get(front_ticker)
|
||||||
|
|
||||||
if not front_price:
|
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
|
continue
|
||||||
|
|
||||||
# Try M+spread, M+spread-1, M+spread+1 (some contract months are illiquid)
|
# Pick first available deferred price from candidates
|
||||||
far_price = None
|
far_price: Optional[float] = None
|
||||||
for offset in [0, -1, 1, -2, 2]:
|
for t in _deferred_tickers(base, spread):
|
||||||
far_ticker = _ticker_for_month(base, spread + offset)
|
p = deferred_prices.get(t)
|
||||||
far_price = _get_price(far_ticker)
|
if p and p > 0:
|
||||||
if far_price:
|
far_price = p
|
||||||
break
|
break
|
||||||
|
|
||||||
slope_pct = None
|
slope_pct: Optional[float] = None
|
||||||
structure = "unknown"
|
structure = "unknown"
|
||||||
if far_price:
|
if far_price:
|
||||||
slope_pct = round((far_price - front_price) / front_price * 100, 2)
|
slope_pct = round((far_price - front_price) / front_price * 100, 2)
|
||||||
|
|||||||
Reference in New Issue
Block a user