Files
OpenFin/backend/routers/options_vol.py
OpenSquared 97ba36454b fix: bootstrap IV history with 1y realized vol to unblock IV Rank from 50
IV Rank was stuck at 50 for all tickers because the formula returns 50.0
when iv_max == iv_min (not enough historical snapshots). Added:
- bootstrap_iv_history(): downloads 1y of closes per ticker, computes
  30d rolling realized vol (annualized), saves each day to iv_history
- POST /api/options-vol/bootstrap-history endpoint (runs in background)
- OptionsLab: auto-detect when IV Rank needs bootstrapping and show
  an amber banner with one-click "Initialiser historique" button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 21:33:01 +02:00

176 lines
6.1 KiB
Python

"""
Options volatility endpoints — IV Rank, Term Structure, Skew, Options Flow.
"""
from fastapi import APIRouter, BackgroundTasks, HTTPException
from typing import List, Optional
import logging
router = APIRouter(prefix="/api/options-vol", tags=["options-vol"])
logger = logging.getLogger(__name__)
# Simple in-memory cache to avoid hammering yfinance on every page load
_iv_cache: dict = {}
_CACHE_TTL_SECONDS = 3600 # 1h
def _is_stale(key: str) -> bool:
import time
entry = _iv_cache.get(key)
if not entry:
return True
return (time.time() - entry["ts"]) > _CACHE_TTL_SECONDS
def _set_cache(key: str, data: dict):
import time
_iv_cache[key] = {"data": data, "ts": time.time()}
@router.get("/snapshot/{ticker}")
def get_iv_snapshot(ticker: str, force: bool = False):
"""Full IV snapshot for one ticker: IV, Rank, Percentile, Term Structure, Skew, Flow."""
key = f"snapshot:{ticker.upper()}"
if not force and not _is_stale(key):
return _iv_cache[key]["data"]
from services.iv_engine import get_full_iv_snapshot
data = get_full_iv_snapshot(ticker)
_set_cache(key, data)
return data
@router.get("/batch")
def get_iv_batch(tickers: str = "SPY,QQQ,GLD,USO,UNG,XLE,TLT"):
"""
IV snapshot for multiple tickers (comma-separated).
Returns a dict {ticker: snapshot}.
Cached 1h per ticker.
"""
ticker_list = [t.strip().upper() for t in tickers.split(",") if t.strip()][:12]
result = {}
from services.iv_engine import get_full_iv_snapshot
for ticker in ticker_list:
key = f"snapshot:{ticker}"
if not _is_stale(key):
result[ticker] = _iv_cache[key]["data"]
else:
snap = get_full_iv_snapshot(ticker)
_set_cache(key, snap)
result[ticker] = snap
return {"snapshots": result, "count": len(result)}
@router.get("/watchlist")
def get_iv_watchlist():
"""
IV for the core IV watchlist (portfolio-relevant tickers).
Returns a summary list sorted by IV Rank descending.
"""
from services.iv_engine import IV_WATCHLIST, get_atm_iv, _resolve_ticker
from services.database import get_iv_rank_percentile, save_iv_snapshot
from datetime import date
results = []
today = date.today().isoformat()
for ticker in IV_WATCHLIST:
key = f"watchlist:{ticker}"
if not _is_stale(key):
results.append(_iv_cache[key]["data"])
continue
iv = get_atm_iv(ticker, 30)
if not iv:
continue
proxy = _resolve_ticker(ticker)
save_iv_snapshot(proxy, today, iv)
rank_data = get_iv_rank_percentile(proxy, iv)
item = {
"ticker": ticker,
"iv_current_pct": round(iv * 100, 1),
"iv_rank": rank_data.get("iv_rank"),
"iv_percentile": rank_data.get("iv_percentile"),
"history_days": rank_data.get("history_days", 0),
"signal": (
"sell_vol" if (rank_data.get("iv_rank") or 0) > 80
else "buy_vol" if (rank_data.get("iv_rank") or 100) < 20
else "neutral"
),
}
_set_cache(key, item)
results.append(item)
results.sort(key=lambda x: -(x.get("iv_rank") or 0))
return {"items": results, "count": len(results)}
@router.get("/history/{ticker}")
def get_iv_history_endpoint(ticker: str, days: int = 90):
"""Historical IV for a ticker (used to render IV chart)."""
from services.database import get_iv_history
from services.iv_engine import _resolve_ticker
proxy = _resolve_ticker(ticker)
history = get_iv_history(proxy, days)
return {"ticker": ticker, "proxy": proxy, "history": history, "count": len(history)}
@router.post("/refresh-watchlist")
def refresh_watchlist(background_tasks: BackgroundTasks):
"""
Trigger a background refresh of IV data for all watchlist tickers.
Call this once daily to build up the 52-week IV history.
"""
def _refresh():
from services.iv_engine import IV_WATCHLIST, get_full_iv_snapshot
logger.info(f"[IVRefresh] Refreshing IV for {len(IV_WATCHLIST)} tickers")
for ticker in IV_WATCHLIST:
try:
snap = get_full_iv_snapshot(ticker)
key = f"snapshot:{ticker}"
import time
_iv_cache[key] = {"data": snap, "ts": time.time()}
logger.debug(f"[IVRefresh] {ticker}: IV={snap.get('iv_current_pct')}% IVR={snap.get('iv_rank')}")
except Exception as e:
logger.warning(f"[IVRefresh] {ticker} failed: {e}")
logger.info("[IVRefresh] Done")
background_tasks.add_task(_refresh)
return {"status": "refresh started", "tickers": len(__import__("services.iv_engine", fromlist=["IV_WATCHLIST"]).IV_WATCHLIST)}
@router.post("/bootstrap-history")
def bootstrap_iv_history_endpoint(background_tasks: BackgroundTasks, min_existing: int = 30):
"""
Bootstrap iv_history with 1 year of realized volatility for all watchlist tickers.
Only fills missing dates — safe to call multiple times.
Runs in background (takes ~30-60s for 18 tickers).
"""
def _run():
from services.iv_engine import bootstrap_iv_history, IV_WATCHLIST
logger.info(f"[IV Bootstrap] Starting for {len(IV_WATCHLIST)} tickers")
result = bootstrap_iv_history(tickers=IV_WATCHLIST, min_existing=min_existing)
total = sum(v.get("inserted", 0) for v in result.values() if isinstance(v, dict))
logger.info(f"[IV Bootstrap] Done — {total} rows inserted across {len(result)} tickers")
background_tasks.add_task(_run)
return {"status": "bootstrap started", "message": "Chargement historique 1 an réalisé vol — vérifier les logs dans ~60s"}
@router.get("/for-trade/{underlying}")
def get_iv_for_trade(underlying: str):
"""
IV snapshot specifically for a trade's underlying.
Used by JournalDeBord to show the IV context at trade time.
"""
from services.iv_engine import get_full_iv_snapshot
key = f"snapshot:{underlying.upper()}"
if not _is_stale(key):
return _iv_cache[key]["data"]
data = get_full_iv_snapshot(underlying)
_set_cache(key, data)
return data