New fetchers (no API keys required): - earnings_fetcher.py: yfinance EPS calendar + surprise tracking for 23 geo-relevant tickers - vx_fetcher.py: VIX term structure (^VIX/^VXV/^VXMT) + CBOE delayed futures, regime detection - central_bank_fetcher.py: Fed + ECB RSS feeds, keyword-based hawkish/dovish classification - sentiment_fetcher.py: CNN Fear & Greed (primary) + NAAIM + AAII (optional fallbacks) Wiring: - institutional_scheduler.py: all 4 now scheduled daily (≥08:00 UTC), deduplicated per day - institutional.py /refresh: all 6 types handled with _run() helper - ai_analyzer.py build_institutional_block(): limit 6→12, generic header text - InstitutionalReports.tsx: 6-type color map, individual refresh buttons, expanded filters Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
189 lines
6.5 KiB
Python
189 lines
6.5 KiB
Python
"""
|
|
VIX term structure fetcher — VX futures curve, contango/backwardation regime.
|
|
Uses yfinance (^VIX, ^VXMT, ^VXV) + CBOE delayed quotes API as secondary.
|
|
No API key required.
|
|
"""
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import requests
|
|
import yfinance as yf
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
CBOE_VX_URL = "https://cdn.cboe.com/api/global/delayed_quotes/futures/VX.json"
|
|
|
|
VIX_TICKERS = ["^VIX", "^VXV", "^VXMT"]
|
|
|
|
|
|
def _detect_regime(vix_spot: float, slope_1m_3m: float) -> str:
|
|
if vix_spot > 30:
|
|
return "crisis"
|
|
elif slope_1m_3m < -2:
|
|
return "backwardation_strong"
|
|
elif slope_1m_3m < 0:
|
|
return "backwardation_mild"
|
|
elif slope_1m_3m > 5:
|
|
return "contango_steep"
|
|
return "contango_mild"
|
|
|
|
|
|
def _fetch_cboe_vx_futures() -> Optional[List[Dict]]:
|
|
try:
|
|
resp = requests.get(CBOE_VX_URL, timeout=15)
|
|
resp.raise_for_status()
|
|
payload = resp.json()
|
|
data = payload.get("data", [])
|
|
contracts = []
|
|
for item in data[:5]:
|
|
contracts.append({
|
|
"symbol": item.get("symbol"),
|
|
"expiration": item.get("expiration"),
|
|
"last": item.get("last"),
|
|
"bid": item.get("bid"),
|
|
"ask": item.get("ask"),
|
|
})
|
|
return contracts if contracts else None
|
|
except Exception as e:
|
|
logger.warning(f"[VX] CBOE API unavailable: {e}")
|
|
return None
|
|
|
|
|
|
def fetch_vx_report() -> Optional[Dict]:
|
|
"""Fetch VIX term structure and detect contango/backwardation regime. Returns None if yfinance fails."""
|
|
report_date = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
|
|
try:
|
|
data = yf.download(VIX_TICKERS, period="5d", interval="1d", progress=False, auto_adjust=True)
|
|
if data is None or data.empty:
|
|
logger.warning("[VX] yfinance returned empty data")
|
|
return None
|
|
|
|
close = data["Close"] if "Close" in data.columns else data
|
|
|
|
def latest_close(ticker: str) -> Optional[float]:
|
|
try:
|
|
col = close[ticker].dropna()
|
|
if col.empty:
|
|
return None
|
|
return float(col.iloc[-1])
|
|
except Exception:
|
|
return None
|
|
|
|
vix_spot = latest_close("^VIX")
|
|
vix_3m = latest_close("^VXV")
|
|
vix_6m = latest_close("^VXMT")
|
|
|
|
if vix_spot is None:
|
|
logger.warning("[VX] ^VIX data unavailable")
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.warning(f"[VX] yfinance download failed: {e}")
|
|
return None
|
|
|
|
slope_1m_3m = (vix_3m - vix_spot) if vix_3m is not None else None
|
|
slope_3m_6m = (vix_6m - vix_3m) if (vix_6m is not None and vix_3m is not None) else None
|
|
contango_pct = ((vix_3m - vix_spot) / vix_spot * 100) if vix_3m is not None else None
|
|
|
|
regime = _detect_regime(vix_spot, slope_1m_3m if slope_1m_3m is not None else 0.0)
|
|
|
|
cboe_contracts = _fetch_cboe_vx_futures()
|
|
|
|
raw_data: Dict[str, Any] = {
|
|
"vix_spot": round(vix_spot, 2),
|
|
"vix_3m": round(vix_3m, 2) if vix_3m is not None else None,
|
|
"vix_6m": round(vix_6m, 2) if vix_6m is not None else None,
|
|
"slope_1m_3m": round(slope_1m_3m, 2) if slope_1m_3m is not None else None,
|
|
"slope_3m_6m": round(slope_3m_6m, 2) if slope_3m_6m is not None else None,
|
|
"contango_pct": round(contango_pct, 2) if contango_pct is not None else None,
|
|
"regime": regime,
|
|
"cboe_futures": cboe_contracts,
|
|
}
|
|
|
|
key_points: List[str] = []
|
|
|
|
vix_3m_str = f"{vix_3m:.1f}" if vix_3m is not None else "N/A"
|
|
vix_6m_str = f"{vix_6m:.1f}" if vix_6m is not None else "N/A"
|
|
key_points.append(f"VIX spot: {vix_spot:.1f} | 3M: {vix_3m_str} | 6M: {vix_6m_str}")
|
|
|
|
if slope_1m_3m is not None:
|
|
key_points.append(
|
|
f"Term structure slope (1M→3M): {slope_1m_3m:+.1f}pt → {regime}"
|
|
)
|
|
|
|
if regime in ("backwardation_strong", "backwardation_mild"):
|
|
key_points.append("BACKWARDATION: front > back → elevated fear, options expensive")
|
|
|
|
if contango_pct is not None and contango_pct > 10:
|
|
monthly_decay = round(contango_pct / 12, 1)
|
|
key_points.append(
|
|
f"Steep contango: VXX roll decay ~{monthly_decay}%/month → premium selling environment"
|
|
)
|
|
|
|
if contango_pct is not None:
|
|
key_points.append(f"Contango: {contango_pct:+.1f}%")
|
|
|
|
if regime in ("crisis", "backwardation_strong"):
|
|
signal_indices = "bearish"
|
|
elif regime == "contango_steep":
|
|
signal_indices = "bullish"
|
|
else:
|
|
signal_indices = "neutral"
|
|
|
|
implications: List[str] = []
|
|
|
|
if regime in ("backwardation_strong", "backwardation_mild"):
|
|
implications.append("VXX/UVXY positive carry — consider long vol as hedge")
|
|
elif regime == "contango_steep":
|
|
implications.append(
|
|
"High roll decay in VXX — premium selling favored, spreads over naked buys"
|
|
)
|
|
elif regime == "crisis":
|
|
implications.append(
|
|
"Extreme fear — tail protection is expensive, wait for calmer entry"
|
|
)
|
|
|
|
if vix_spot > 20:
|
|
implications.append(
|
|
f"Elevated VIX ({vix_spot:.1f}) — implied vol rich, consider selling premium with defined risk"
|
|
)
|
|
elif vix_spot < 14:
|
|
implications.append(
|
|
f"Low VIX ({vix_spot:.1f}) — vol cheap, consider buying tail protection"
|
|
)
|
|
|
|
if not implications:
|
|
implications = ["VIX term structure neutral — no extreme regime detected"]
|
|
|
|
abs_slope = abs(slope_1m_3m) if slope_1m_3m is not None else 0.0
|
|
importance = 3 if (regime == "crisis" or abs_slope > 5) else 2
|
|
|
|
slope_str = f"{slope_1m_3m:+.1f}pt" if slope_1m_3m is not None else "N/A"
|
|
ai_summary = (
|
|
f"VIX term structure ({report_date}). "
|
|
f"Spot {vix_spot:.1f} | 3M {vix_3m_str} | 6M {vix_6m_str}. "
|
|
f"Slope 1M→3M: {slope_str}. "
|
|
f"Regime: {regime.upper()}. "
|
|
f"Signal: {signal_indices.upper()}. "
|
|
+ implications[0]
|
|
)
|
|
|
|
return {
|
|
"report_type": "vx_curve",
|
|
"report_date": report_date,
|
|
"title": f"VIX Term Structure — {report_date}",
|
|
"source": "yfinance + CBOE delayed API",
|
|
"importance": importance,
|
|
"category": "volatility",
|
|
"raw_data": raw_data,
|
|
"key_points": key_points,
|
|
"trading_implications": " | ".join(implications),
|
|
"signal_energy": "neutral",
|
|
"signal_metals": "neutral",
|
|
"signal_indices": signal_indices,
|
|
"signal_forex": "neutral",
|
|
"ai_summary": ai_summary,
|
|
}
|