From b73a7388be8d9b69a89ddc493ca12daf8f1992cb Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Mon, 3 Aug 2026 11:57:23 +0200 Subject: [PATCH] feat: strategy builder --- backend/routers/strategy_builder.py | 36 +++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/backend/routers/strategy_builder.py b/backend/routers/strategy_builder.py index 17aaabc..4cd3c72 100644 --- a/backend/routers/strategy_builder.py +++ b/backend/routers/strategy_builder.py @@ -171,18 +171,40 @@ def _resolve_terminal_shocks(scenario: "ScenarioIn"): ) -def _build_surfaces(scenario: ScenarioIn): +def _chain_window_for_legs(scenario: ScenarioIn, legs: Optional[List[Dict[str, Any]]]): + """get_chain_slice picks the `n_expiries` expiries CLOSEST to horizon_days, even inside + a dte_min/dte_max window — so a calendar/diagonal whose far leg sits well past + horizon_days can silently lose that leg's real quote to the trim (confirmed via the + strategy_price_debug trace: the far leg's exec_price/mid matched a Black-Scholes+5%- + spread FALLBACK price, not its actual bid/ask, because find_quote came up empty against + the narrower chain /price had fetched). When we know the exact legs being priced, widen + the window to guarantee every one of their expiries survives — no reason to rely on a + horizon-proximity heuristic when the expiries are already explicit.""" + dte_min, dte_max, n_expiries = scenario.dte_min, scenario.dte_max, scenario.n_expiries + leg_days = [l["days_to_expiry"] for l in (legs or []) if l.get("option_type") != "stock"] + if leg_days: + lo, hi = min(leg_days), max(leg_days) + dte_min = min(dte_min, lo) if dte_min is not None else lo + dte_max = max(dte_max, hi) if dte_max is not None else hi + # Enough slots that narrowing-to-window doesn't get re-trimmed by horizon-proximity + # sort — same n_expiries=20 the /presets endpoint already uses for this reason. + n_expiries = max(n_expiries, 20) + return dte_min, dte_max, n_expiries + + +def _build_surfaces(scenario: ScenarioIn, legs: Optional[List[Dict[str, Any]]] = None): + dte_min, dte_max, n_expiries = _chain_window_for_legs(scenario, legs) chain_slice = get_chain_slice( - scenario.symbol, scenario.horizon_days, scenario.n_expiries, - dte_min=scenario.dte_min, dte_max=scenario.dte_max, as_of=scenario.as_of, + scenario.symbol, scenario.horizon_days, n_expiries, + dte_min=dte_min, dte_max=dte_max, as_of=scenario.as_of, ) surface_now = build_surface(chain_slice) if scenario.checkpoint_as_of: # Real smile-of-the-day, not a hypothesis — same fitting code as surface_now # (build_surface), just fed the chain as it stood at the scrubbed-to date. checkpoint_chain = get_chain_slice( - scenario.symbol, scenario.horizon_days, scenario.n_expiries, - dte_min=scenario.dte_min, dte_max=scenario.dte_max, as_of=scenario.checkpoint_as_of, + scenario.symbol, scenario.horizon_days, n_expiries, + dte_min=dte_min, dte_max=dte_max, as_of=scenario.checkpoint_as_of, ) surface_scenario = build_surface(checkpoint_chain) else: @@ -255,12 +277,12 @@ def price(req: PriceRequest): if len(req.legs) > 4: raise HTTPException(status_code=400, detail="4 jambes maximum") + legs = [leg.model_dump() for leg in req.legs] try: - chain_slice, surface_now, surface_scenario = _build_surfaces(req.scenario) + chain_slice, surface_now, surface_scenario = _build_surfaces(req.scenario, legs=legs) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) - legs = [leg.model_dump() for leg in req.legs] # Paths only drive the day-by-day payoff table, and only make sense for the synthetic # parametric scenario — "Analyse période historique" (checkpoint_as_of) prices against # a real remembered chain instead, which has no notion of a hypothesized path.