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>
151 lines
5.5 KiB
Python
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
|