Files
OpenFin/backend/services/realized_scenario.py
2026-07-30 14:48:20 +02:00

68 lines
3.2 KiB
Python

"""
Turns a historical date range into a Strategy Builder scenario (spot_shock_pct,
iv_level_shift, horizon_days) computed from what REALLY happened between those two
dates — not a guess. Powers Strategy Builder's "Dériver d'un historique" mode: instead
of a user manually dialing scenario sliders, the tool answers "what actually moved
between Du and Au" and that becomes the scenario the optimizer searches under.
Deliberately narrower than a full scenario: only spot_shock_pct and iv_level_shift are
derived (the two headline dimensions of "what happened"). skew_tilt/term_slope_shift are
NOT derived — comparing two real smiles/term structures robustly (different strike
ladders, different expiry sets on each date) is a much fuzzier fit than a single ATM
IV read, and a wrong-but-confident derived skew would be worse than none. Both stay at
the caller's own default (0) and remain manually adjustable in the Construire tab.
"""
from datetime import date
from typing import Any, Dict, Optional
def _atm_iv(chain: Dict[str, Any]) -> Optional[float]:
"""ATM implied vol from the chain's nearest expiry: nearest-to-spot strike, call
first then put (whichever actually carries a live IV — see option_chain.py's
row shape, iv=0.0 when Saxo never quoted that contract)."""
expiries = chain.get("expiries") or []
spot = chain.get("spot")
if not expiries or not spot:
return None
exp = expiries[0]
candidates = [r for r in exp["calls"] if r.get("iv")] or [r for r in exp["puts"] if r.get("iv")]
if not candidates:
return None
atm = min(candidates, key=lambda r: abs(r["strike"] - spot))
return atm["iv"]
def compute_realized_scenario(symbol: str, start_date: str, end_date: str) -> Dict[str, Any]:
from services.database import get_saxo_option_symbol_for_ticker
from services.option_chain import get_chain_slice
if end_date <= start_date:
raise ValueError("La date de fin doit être postérieure à la date de départ.")
saxo_symbol = get_saxo_option_symbol_for_ticker(symbol) or symbol.upper()
chain_a = get_chain_slice(saxo_symbol, target_days=30, n_expiries=20, as_of=start_date)
chain_b = get_chain_slice(saxo_symbol, target_days=30, n_expiries=20, as_of=end_date)
spot_a, spot_b = chain_a.get("spot"), chain_b.get("spot")
if not spot_a or not spot_b:
raise ValueError(f"Spot manquant pour '{symbol}' à l'une des deux dates.")
spot_shock_pct = (spot_b - spot_a) / spot_a * 100
iv_a, iv_b = _atm_iv(chain_a), _atm_iv(chain_b)
iv_level_shift = (iv_b - iv_a) if (iv_a is not None and iv_b is not None) else None
horizon_days = max((date.fromisoformat(end_date[:10]) - date.fromisoformat(start_date[:10])).days, 1)
return {
"symbol": symbol, "saxo_symbol": saxo_symbol,
"start_date": start_date, "end_date": end_date,
"spot_a": round(spot_a, 6), "spot_b": round(spot_b, 6),
"spot_shock_pct": round(spot_shock_pct, 4),
"iv_a": round(iv_a, 4) if iv_a is not None else None,
"iv_b": round(iv_b, 4) if iv_b is not None else None,
"iv_level_shift": round(iv_level_shift, 4) if iv_level_shift is not None else None,
"horizon_days": horizon_days,
}