diff --git a/backend/services/strategy_engine.py b/backend/services/strategy_engine.py index 2258781..7a09b08 100644 --- a/backend/services/strategy_engine.py +++ b/backend/services/strategy_engine.py @@ -285,15 +285,30 @@ def payoff_curves( entry_ref = priced["entry_cost"] lo, hi = spot * 0.6, spot * 1.4 - prices = np.linspace(lo, hi, n) + # A calendar/ratio spread's payoff can spike sharply right at a strike — a plain + # uniform sweep over the full 0.6x-1.4x range (n points) can straddle right over that + # peak without ever sampling it (same issue fixed in check_bounded_risk). Blend a + # coarse baseline (overall shape) with a dense window around the legs' own strikes. + baseline = np.linspace(lo, hi, n) + strikes = [l["strike"] for l in legs] + lo_k, hi_k = max(min(strikes) * 0.9, lo), min(max(strikes) * 1.1, hi) + near_strikes = np.linspace(lo_k, hi_k, n * 3) + prices = np.unique(np.concatenate([baseline, near_strikes])) + prices.sort() eval_days_expiry = min(l["days_to_expiry"] for l in legs) + # Both curves share the scenario's volatility view (surface_scenario) — only the + # evaluation DATE differs: "à échéance" prices the day the near leg expires (matching + # the Max gain/Max perte tile, itself computed with surface_scenario for the same + # reason — see check_bounded_risk), "à J+8" prices your chosen scenario horizon. Using + # surface_now here instead would silently mix in today's un-shocked vol, producing a + # curve whose peak doesn't match the Max gain tile right next to it. at_expiry = [ - {"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), eval_days_expiry, surface_now, r, contract_size) - entry_ref), 2)} + {"underlying": round(float(p), 4), "pnl": round(float(value_at(legs, float(p), eval_days_expiry, surface_scenario, r, contract_size) - entry_ref), 2)} for p in prices ] at_scenario = [ - {"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), horizon_days, surface_scenario, r, contract_size) - entry_ref), 2)} + {"underlying": round(float(p), 4), "pnl": round(float(value_at(legs, float(p), horizon_days, surface_scenario, r, contract_size) - entry_ref), 2)} for p in prices ] return {"at_expiry": at_expiry, "at_scenario": at_scenario, **priced} diff --git a/frontend/src/pages/StrategyBuilder.tsx b/frontend/src/pages/StrategyBuilder.tsx index 6f1bb9e..62ddee2 100644 --- a/frontend/src/pages/StrategyBuilder.tsx +++ b/frontend/src/pages/StrategyBuilder.tsx @@ -72,14 +72,14 @@ function PayoffChart({ priced, spot, scenarioSpot }: { priced: PriceCombo; spot: tickFormatter={(v) => `${v}`} /> `Sous-jacent: ${Number(v).toFixed(2)}`} + labelFormatter={(v) => `Sous-jacent: ${Number(v).toFixed(decimals)}`} formatter={(v: number, name: string) => [fmtMoney(v), name]} /> - + @@ -818,6 +818,9 @@ export default function StrategyBuilder() {
Diagramme payoff
+

+ Les deux courbes utilisent la même vue de volatilité (celle du scénario) — seule la date diffère : bleu = à l'échéance de la jambe la plus proche, orange = à J+{horizonDays}. +