""" 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, }