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>
This commit is contained in:
@@ -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)}
|
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}")
|
@router.get("/for-trade/{underlying}")
|
||||||
def get_iv_for_trade(underlying: str):
|
def get_iv_for_trade(underlying: str):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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:
|
def get_iv_context_for_prompt(tickers: List[str]) -> str:
|
||||||
"""
|
"""
|
||||||
Build a compact IV context string to inject into GPT-4o scoring prompts.
|
Build a compact IV context string to inject into GPT-4o scoring prompts.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useIvWatchlist, useIvSnapshot, useIvHistory } from '../hooks/useApi'
|
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'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
@@ -249,6 +250,8 @@ function WatchlistRow({ item }: { item: any }) {
|
|||||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
export default function OptionsLab() {
|
export default function OptionsLab() {
|
||||||
const { data, isLoading, refetch, isFetching } = useIvWatchlist()
|
const { data, isLoading, refetch, isFetching } = useIvWatchlist()
|
||||||
|
const [bootstrapping, setBootstrapping] = useState(false)
|
||||||
|
const [bootstrapMsg, setBootstrapMsg] = useState<string | null>(null)
|
||||||
const items: any[] = data?.items || []
|
const items: any[] = data?.items || []
|
||||||
|
|
||||||
const sellVol = items.filter(i => (i.iv_rank ?? 50) >= 80)
|
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 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 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 (
|
return (
|
||||||
<div className="p-6 space-y-5">
|
<div className="p-6 space-y-5">
|
||||||
|
{needsBootstrap && !bootstrapMsg && (
|
||||||
|
<div className="flex items-center justify-between gap-3 px-4 py-3 rounded border border-amber-700/40 bg-amber-900/10 text-xs text-amber-300">
|
||||||
|
<span>⚠️ Historique IV insuffisant — IV Rank bloqué à 50%. Bootstrappe 1 an de données réalisées pour calibrer le Rank.</span>
|
||||||
|
<button
|
||||||
|
onClick={handleBootstrap}
|
||||||
|
disabled={bootstrapping}
|
||||||
|
className="flex items-center gap-1.5 text-xs bg-amber-700/30 hover:bg-amber-700/50 border border-amber-600/50 text-amber-200 px-3 py-1.5 rounded transition-all whitespace-nowrap disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Database className={clsx('w-3.5 h-3.5', bootstrapping && 'animate-pulse')} />
|
||||||
|
{bootstrapping ? 'Chargement...' : 'Initialiser historique'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bootstrapMsg && (
|
||||||
|
<div className="px-4 py-3 rounded border border-blue-700/40 bg-blue-900/10 text-xs text-blue-300">
|
||||||
|
ℹ️ {bootstrapMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user