feat: strategy builder

This commit is contained in:
OpenSquared
2026-07-30 14:48:20 +02:00
parent d85c0348d8
commit efe29cef53
5 changed files with 412 additions and 44 deletions

View File

@@ -41,6 +41,12 @@ class ScenarioIn(BaseModel):
# options (e.g. dte_min=20, dte_max=60) instead of horizon_days doing double duty.
dte_min: Optional[int] = None
dte_max: Optional[int] = None
# "Dériver d'un historique" mode: reconstruct the chain (and every leg strike drawn
# from it, including in /optimize) as it stood at/before this date instead of live —
# e.g. so the optimizer searches over what was ACTUALLY quoted on the day a realized
# scenario's window starts, not today's chain. None (default) = live, unchanged
# behavior for the normal Construire flow.
as_of: Optional[str] = None
@property
def shocked_rate(self) -> float:
@@ -118,7 +124,7 @@ class StrategySaveRequest(BaseModel):
def _build_surfaces(scenario: ScenarioIn):
chain_slice = get_chain_slice(
scenario.symbol, scenario.horizon_days, scenario.n_expiries,
dte_min=scenario.dte_min, dte_max=scenario.dte_max,
dte_min=scenario.dte_min, dte_max=scenario.dte_max, as_of=scenario.as_of,
)
surface_now = build_surface(chain_slice)
surface_scenario = apply_scenario(
@@ -206,6 +212,20 @@ def price(req: PriceRequest):
return result
@router.get("/realized-scenario")
def realized_scenario(symbol: str = Query(...), start_date: str = Query(...), end_date: str = Query(...)):
""""Dériver d'un historique" mode: turns a real Du→Au window into scenario inputs
(spot_shock_pct/iv_level_shift/horizon_days) computed from what actually happened —
see services.realized_scenario. The frontend copies these into the normal scenario
state (same one Construire/optimize use) rather than this being a separate pricing
path of its own."""
from services.realized_scenario import compute_realized_scenario
try:
return compute_realized_scenario(symbol, start_date, end_date)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/suggested-profile")
def suggested_profile(scenario: ScenarioIn):
"""Mode 1 of the scenario/profile/constraints split: what Greek behavior this scenario

View File

@@ -0,0 +1,67 @@
"""
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,
}

View File

@@ -12,7 +12,7 @@ day where any leg has no real quote (skipped, not synthesized — a replay shoul
was actually knowable, not fill gaps with a theoretical price).
"""
from datetime import date, timedelta
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
def _daterange(start_date: str, end_date: str) -> List[str]:
@@ -21,9 +21,40 @@ def _daterange(start_date: str, end_date: str) -> List[str]:
return [(d0 + timedelta(days=i)).isoformat() for i in range((d1 - d0).days + 1)]
def _leg_snapshot(leg: Dict[str, Any], chain: Dict[str, Any], r: float) -> Optional[Dict[str, Any]]:
"""One leg's real quote (or spot, for a stock leg) on a given day, plus Greeks
computed from that quote's own IV — mirrors how Options Lab's pricing-check
attributes a real leg's Greeks, so this reads consistently with the rest of the app."""
from services.option_chain import find_quote
from services.options_pricer import black_scholes
base = {
"option_type": leg["option_type"], "position": leg["position"], "quantity": leg.get("quantity", 1),
"strike": leg["strike"], "expiry_date": leg["expiry_date"],
}
if leg["option_type"] == "stock":
spot = chain.get("spot")
if spot is None:
return None
return {**base, "mid": round(spot, 6), "bid": None, "ask": None, "iv": None, "greeks": None}
q = find_quote(chain, leg["expiry_date"], leg["strike"], leg["option_type"])
if not q or q["mid"] <= 0:
return None
spot = chain.get("spot")
greeks = None
if spot and q.get("iv") and leg.get("days_to_expiry"):
T = max(leg["days_to_expiry"], 1) / 365
g = black_scholes(spot, leg["strike"], T, r, q["iv"], leg["option_type"])
# black_scholes returns numpy scalars (scipy-backed) — FastAPI's JSON encoder
# can't serialize those, must be native floats before this leaves the function.
greeks = {k: round(float(g[k]), 6) for k in ("delta", "gamma", "theta", "vega")}
return {**base, "mid": round(q["mid"], 6), "bid": round(q["bid"], 6), "ask": round(q["ask"], 6), "iv": q.get("iv"), "greeks": greeks}
def replay_position(
symbol: str, legs: List[Dict[str, Any]], start_date: str, end_date: str,
contract_size: float = 100_000,
contract_size: float = 100_000, r: float = 0.05,
) -> Dict[str, Any]:
from services.database import get_saxo_option_symbol_for_ticker
from services.option_chain import get_chain_slice, find_quote
@@ -44,6 +75,8 @@ def replay_position(
points: List[Dict[str, Any]] = []
entry_value = None
entry_legs: Optional[List[Dict[str, Any]]] = None
exit_legs: Optional[List[Dict[str, Any]]] = None
missing_dates: List[str] = []
for d in _daterange(start_date, end_date):
@@ -58,24 +91,29 @@ def replay_position(
value = 0.0 # dollar value of the whole position, contract_size already applied
complete = True
day_legs: List[Dict[str, Any]] = []
for leg, sq in zip(legs, signed_qty):
if leg["option_type"] == "stock":
if chain.get("spot") is None:
complete = False
break
value += sq * chain["spot"] * contract_size
day_legs.append(_leg_snapshot(leg, chain, r))
continue
q = find_quote(chain, leg["expiry_date"], leg["strike"], leg["option_type"])
if not q or q["mid"] <= 0:
complete = False
break
value += sq * q["mid"] * contract_size
day_legs.append(_leg_snapshot(leg, chain, r))
if not complete:
missing_dates.append(d)
continue
if entry_value is None:
entry_value = value
entry_legs = day_legs
exit_legs = day_legs
points.append({
"date": d, "spot": chain.get("spot"),
"position_value": round(value, 2),
@@ -93,6 +131,8 @@ def replay_position(
"start_date": start_date, "end_date": end_date,
"entry_date": points[0]["date"], "entry_value": round(entry_value, 2),
"final_pnl": points[-1]["pnl"],
"entry_legs": entry_legs,
"exit_legs": exit_legs,
"points": points,
"missing_dates": missing_dates,
}