From 97ba36454b4b78d4ea5c4fd70ca329743515864b Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Wed, 17 Jun 2026 21:33:01 +0200 Subject: [PATCH] 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 --- backend/routers/options_vol.py | 18 +++++++++++ backend/services/iv_engine.py | 52 +++++++++++++++++++++++++++++++ frontend/src/pages/OptionsLab.tsx | 41 +++++++++++++++++++++++- 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/backend/routers/options_vol.py b/backend/routers/options_vol.py index fcb66df..ac1293e 100644 --- a/backend/routers/options_vol.py +++ b/backend/routers/options_vol.py @@ -142,6 +142,24 @@ def refresh_watchlist(background_tasks: BackgroundTasks): 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): """ diff --git a/backend/services/iv_engine.py b/backend/services/iv_engine.py index 2a0a057..ebf8502 100644 --- a/backend/services/iv_engine.py +++ b/backend/services/iv_engine.py @@ -418,6 +418,58 @@ def get_full_iv_snapshot(ticker: str) -> Dict[str, Any]: } +def bootstrap_iv_history(tickers: Optional[List[str]] = None, days: int = 365, min_existing: int = 30) -> Dict[str, Any]: + """ + Bootstrap iv_history with 1 year of realized volatility (rolling 30d annualized). + Used when the system is new and has no meaningful IV Rank history. + Only fills dates not already present (safe to re-run). + Returns a summary dict {ticker: rows_inserted}. + """ + import math + from services.database import save_iv_snapshot, get_iv_history + + targets = [_resolve_ticker(t) for t in (tickers or IV_WATCHLIST)] + results: Dict[str, Any] = {} + + for ticker in targets: + existing = get_iv_history(ticker, days=days) + if len(existing) >= min_existing: + results[ticker] = {"skipped": True, "existing_rows": len(existing)} + continue + + try: + t = yf.Ticker(ticker) + hist = t.history(period="1y", interval="1d") + if hist.empty or len(hist) < 32: + results[ticker] = {"error": "insufficient price history"} + continue + + closes = hist["Close"].dropna() + log_returns = [math.log(closes.iloc[i] / closes.iloc[i - 1]) for i in range(1, len(closes))] + + inserted = 0 + for i in range(30, len(log_returns)): + window = log_returns[i - 30:i] + mean = sum(window) / 30 + variance = sum((r - mean) ** 2 for r in window) / 29 + realized_vol = math.sqrt(variance * 252) + + snap_date = closes.index[i + 1].date().isoformat() + try: + save_iv_snapshot(ticker, snap_date, realized_vol, None, None, None) + inserted += 1 + except Exception: + pass # UNIQUE constraint = date already exists, skip + + results[ticker] = {"inserted": inserted} + logger.info(f"[IV Bootstrap] {ticker}: {inserted} rows inserted") + except Exception as e: + results[ticker] = {"error": str(e)} + logger.warning(f"[IV Bootstrap] {ticker}: {e}") + + return results + + def get_iv_context_for_prompt(tickers: List[str]) -> str: """ Build a compact IV context string to inject into GPT-4o scoring prompts. diff --git a/frontend/src/pages/OptionsLab.tsx b/frontend/src/pages/OptionsLab.tsx index 51e151e..9bceb21 100644 --- a/frontend/src/pages/OptionsLab.tsx +++ b/frontend/src/pages/OptionsLab.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useIvWatchlist, useIvSnapshot, useIvHistory } from '../hooks/useApi' -import { Activity, TrendingUp, TrendingDown, Minus, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react' +import { Activity, TrendingUp, TrendingDown, Minus, RefreshCw, ChevronDown, ChevronUp, Database } from 'lucide-react' +import { api } from '../hooks/useApi' import clsx from 'clsx' // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -249,6 +250,8 @@ function WatchlistRow({ item }: { item: any }) { // ── Main page ───────────────────────────────────────────────────────────────── export default function OptionsLab() { const { data, isLoading, refetch, isFetching } = useIvWatchlist() + const [bootstrapping, setBootstrapping] = useState(false) + const [bootstrapMsg, setBootstrapMsg] = useState(null) const items: any[] = data?.items || [] const sellVol = items.filter(i => (i.iv_rank ?? 50) >= 80) @@ -256,8 +259,44 @@ export default function OptionsLab() { const neutral = items.filter(i => i.iv_rank != null && i.iv_rank >= 20 && i.iv_rank < 80) const noData = items.filter(i => i.iv_rank == null) + const handleBootstrap = async () => { + setBootstrapping(true) + setBootstrapMsg(null) + try { + await api.post('/options-vol/bootstrap-history') + setBootstrapMsg('Bootstrap lancé (~60s) — actualise dans 1 minute pour voir les IV Rank mis à jour.') + setTimeout(() => refetch(), 70_000) + } catch { + setBootstrapMsg('Erreur lors du bootstrap.') + } finally { + setBootstrapping(false) + } + } + + // Show bootstrap banner if most items have no meaningful IV Rank (stuck at 50 or null) + const needsBootstrap = items.length > 0 && items.filter(i => i.iv_rank == null || i.iv_rank === 50).length > items.length * 0.6 + return (
+ {needsBootstrap && !bootstrapMsg && ( +
+ ⚠️ Historique IV insuffisant — IV Rank bloqué à 50%. Bootstrappe 1 an de données réalisées pour calibrer le Rank. + +
+ )} + {bootstrapMsg && ( +
+ ℹ️ {bootstrapMsg} +
+ )} +