68 lines
3.2 KiB
Python
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,
|
|
}
|