From 3417bb60759ff00d5c5fdd2d205c5e5058ed2130 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Sun, 19 Jul 2026 09:39:09 +0200 Subject: [PATCH] feat: strategy builder --- backend/routers/strategy_builder.py | 5 +- backend/services/saxo_client.py | 84 +++++++++++++++++++------- backend/services/strategy_engine.py | 63 ++++++++++++------- backend/services/strategy_optimizer.py | 19 +++--- frontend/src/hooks/useApi.ts | 1 + frontend/src/pages/StrategyBuilder.tsx | 32 +++++++--- 6 files changed, 142 insertions(+), 62 deletions(-) diff --git a/backend/routers/strategy_builder.py b/backend/routers/strategy_builder.py index cb6c3c8..c5c320f 100644 --- a/backend/routers/strategy_builder.py +++ b/backend/routers/strategy_builder.py @@ -5,7 +5,7 @@ from pydantic import BaseModel from services.option_chain import get_chain_slice from services.vol_surface import build_surface, apply_scenario -from services.strategy_engine import payoff_curves +from services.strategy_engine import payoff_curves, DEFAULT_CONTRACT_SIZE from services.strategy_optimizer import optimize as run_optimizer from services.database import ( save_scenario, get_scenarios, delete_scenario, @@ -34,6 +34,7 @@ class ScenarioIn(BaseModel): manual_grid: Optional[List[Dict[str, Any]]] = None rate: float = 0.05 n_expiries: int = 3 + contract_size: float = DEFAULT_CONTRACT_SIZE class PriceRequest(BaseModel): @@ -121,6 +122,7 @@ def price(req: PriceRequest): result = payoff_curves( legs, chain_slice, surface_now, surface_scenario, req.scenario.horizon_days, req.scenario.rate, + contract_size=req.scenario.contract_size, ) result["spot"] = chain_slice["spot"] result["scenario_spot"] = surface_scenario.spot @@ -146,6 +148,7 @@ def optimize(req: OptimizeRequest): constraints=req.constraints.model_dump(), objective=req.constraints.objective, top_n=req.constraints.top_n, + contract_size=req.scenario.contract_size, ) except Exception as e: import traceback diff --git a/backend/services/saxo_client.py b/backend/services/saxo_client.py index 8038bbd..daca826 100644 --- a/backend/services/saxo_client.py +++ b/backend/services/saxo_client.py @@ -18,6 +18,7 @@ import httpx from services.options_pricer import black_scholes from services.saxo_auth import SAXO_API_BASE_URL, get_valid_access_token +from services.vol_surface import Surface logger = logging.getLogger(__name__) @@ -278,8 +279,12 @@ def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str, Returns normalized rows ready for services/database.save_saxo_snapshot_rows: {symbol, snapshot_date, spot, expiry_date, strike, option_type, bid, ask, mid, volatility_pct, delta, gamma, theta, vega, is_synthetic}. bid/ask/mid are - Black-Scholes-synthesized from IV (is_synthetic=True) whenever Saxo returns no live - Bid/Ask for that contract (e.g. FX options outside market hours). + Black-Scholes-synthesized (is_synthetic=True) whenever Saxo returns no live Bid/Ask for + that contract (e.g. FX options outside market hours) — using that contract's own IV + when Saxo quoted it, or otherwise an IV borrowed from a smile built across whatever + strikes/expiries in this same snapshot DID carry a live MidVolatility (Saxo's "active + quoting window" is often just the near-the-money strikes on the nearest expiry; the + rest of the chain has no Greeks/MidVolatility at all, not just no Bid/Ask). """ instrument = resolve_instrument(symbol) root_uic = instrument["uic"] @@ -295,10 +300,15 @@ def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str, # real payload) — MidStrikePrice on the nearest expiry is the best available proxy. spot = next((eb.get("MidStrikePrice") for eb in expiry_blocks if eb.get("MidStrikePrice") is not None), None) - rows: List[Dict[str, Any]] = [] + # First pass: take exactly what Saxo quoted, no synthesis yet. + raw: List[Dict[str, Any]] = [] for expiry_block in expiry_blocks: expiry_date = (expiry_block.get("Expiry") or "")[:10] or None - for strike_block in (strike_block for strike_block in (expiry_block.get("Strikes") or [])): + try: + days_to_expiry = (date.fromisoformat(expiry_date) - date.fromisoformat(snapshot_date)).days if expiry_date else None + except ValueError: + days_to_expiry = None + for strike_block in (expiry_block.get("Strikes") or []): strike = strike_block.get("Strike") for side_key in ("Call", "Put"): side = strike_block.get(side_key) @@ -307,38 +317,68 @@ def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str, greeks = side.get("Greeks") or {} bid, ask = side.get("Bid"), side.get("Ask") mid_vol = greeks.get("MidVolatility") - option_type = "put" if side_key == "Put" else "call" - vol_pct = round(mid_vol * 100, 4) if mid_vol is not None else None - mid = round((bid + ask) / 2, 6) if (bid is not None and ask is not None) else None - - is_synthetic = False - if not bid and not ask: - syn_bid, syn_ask, syn_mid = _synthesize_quote( - spot, strike, expiry_date, snapshot_date, vol_pct, option_type, - ) - if syn_bid is not None: - bid, ask, mid, is_synthetic = syn_bid, syn_ask, syn_mid, True - - rows.append({ + raw.append({ "symbol": symbol.upper(), "snapshot_date": snapshot_date, "spot": float(spot) if spot is not None else None, "expiry_date": expiry_date, + "days_to_expiry": days_to_expiry, "strike": float(strike) if strike is not None else None, - "option_type": option_type, + "option_type": "put" if side_key == "Put" else "call", "bid": bid, "ask": ask, - "mid": mid, + "mid": round((bid + ask) / 2, 6) if (bid is not None and ask is not None) else None, # MidVolatility comes back as a decimal fraction (0.05 = 5%) — store as an # actual percentage to match the volatility_pct column's name/convention. - "volatility_pct": vol_pct, + "volatility_pct": round(mid_vol * 100, 4) if mid_vol is not None else None, "delta": greeks.get("Delta"), "gamma": greeks.get("Gamma"), "theta": greeks.get("Theta"), "vega": greeks.get("Vega"), - "is_synthetic": is_synthetic, }) - if not rows: + if not raw: raise ValueError(f"Snapshot Saxo vide pour '{symbol}' (clés reçues: {list(snapshot.keys())})") + + fallback_surface = _build_fallback_surface(spot, raw) + + rows: List[Dict[str, Any]] = [] + for r in raw: + bid, ask, mid, vol_pct = r["bid"], r["ask"], r["mid"], r["volatility_pct"] + is_synthetic = False + if not bid and not ask: + iv_for_synth = vol_pct + if iv_for_synth is None and fallback_surface is not None and r["strike"] and r["days_to_expiry"]: + iv_for_synth = round(fallback_surface.iv_at(r["strike"], max(r["days_to_expiry"], 1)) * 100, 4) + syn_bid, syn_ask, syn_mid = _synthesize_quote( + r["spot"], r["strike"], r["expiry_date"], snapshot_date, iv_for_synth, r["option_type"], + ) + if syn_bid is not None: + bid, ask, mid, is_synthetic = syn_bid, syn_ask, syn_mid, True + if vol_pct is None: + vol_pct = iv_for_synth + + rows.append({ + **{k: v for k, v in r.items() if k != "days_to_expiry"}, + "bid": bid, "ask": ask, "mid": mid, "volatility_pct": vol_pct, + "is_synthetic": is_synthetic, + }) + return rows + + +def _build_fallback_surface(spot: Optional[float], raw_rows: List[Dict[str, Any]]) -> Optional[Surface]: + """A smile built only from strikes/expiries that carried a live MidVolatility in this + same snapshot — used to borrow a plausible IV for contracts Saxo didn't quote at all.""" + if not spot: + return None + by_days: Dict[float, Dict[str, Any]] = {} + for r in raw_rows: + if r["volatility_pct"] is None or r["days_to_expiry"] is None or r["strike"] is None: + continue + exp = by_days.setdefault(r["days_to_expiry"], {"days_to_expiry": r["days_to_expiry"], "calls": [], "puts": []}) + entry = {"strike": r["strike"], "iv": r["volatility_pct"] / 100.0} + (exp["calls"] if r["option_type"] == "call" else exp["puts"]).append(entry) + if not by_days: + return None + return Surface(spot, list(by_days.values())) diff --git a/backend/services/strategy_engine.py b/backend/services/strategy_engine.py index 6ecbe14..2258781 100644 --- a/backend/services/strategy_engine.py +++ b/backend/services/strategy_engine.py @@ -16,6 +16,7 @@ from services.option_chain import find_quote from services.vol_surface import Surface, ScenarioSurface DEFAULT_SPREAD_PCT = 0.05 # fallback relative bid/ask spread when no live quote is found +DEFAULT_CONTRACT_SIZE = 100_000 # notional per 1 contract/lot (e.g. a standard FX lot); "quantity" on a leg is the number of these def to_native(obj: Any) -> Any: @@ -77,6 +78,7 @@ def value_at( eval_days_from_now: float, surface: Any, r: float, + contract_size: float = DEFAULT_CONTRACT_SIZE, ) -> float: """Signed portfolio value (BS reprice for unexpired legs, intrinsic for expired ones).""" total = 0.0 @@ -89,7 +91,7 @@ def value_at( else: sigma = surface.iv_at(leg["strike"], remaining) price = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"])["price"] - total += sign * price * qty * 100 + total += sign * price * qty * contract_size return total @@ -113,6 +115,7 @@ def price_combo( surface_scenario: ScenarioSurface, horizon_days: int, r: float = 0.05, + contract_size: float = DEFAULT_CONTRACT_SIZE, ) -> Dict[str, Any]: spot_now = chain_slice["spot"] spot_scenario = surface_scenario.spot @@ -123,8 +126,8 @@ def price_combo( ep = entry_price(leg, chain_slice, surface_now, r) sign = _sign(leg) qty = leg.get("quantity", 1) - entry_ref += sign * ep["exec_price"] * qty * 100 - entry_ref_mid += sign * ep["mid"] * qty * 100 + entry_ref += sign * ep["exec_price"] * qty * contract_size + entry_ref_mid += sign * ep["mid"] * qty * contract_size # Scenario exit: apply each leg's own bid/ask spread (est. from entry quote) to the # theoretical scenario value, since we don't have a live quote for the future date. @@ -139,8 +142,8 @@ def price_combo( quote = find_quote(chain_slice, leg["expiry_date"], leg["strike"], leg["option_type"]) spread_pct = _quote_spread_pct(quote) exec_price = theo * (1 - spread_pct / 2) if leg.get("position", "long") == "long" else theo * (1 + spread_pct / 2) - scenario_mid += sign * theo * qty * 100 - scenario_exec += sign * exec_price * qty * 100 + scenario_mid += sign * theo * qty * contract_size + scenario_exec += sign * exec_price * qty * contract_size net_pnl = scenario_exec - entry_ref broker_cost = (entry_ref - entry_ref_mid) + (scenario_mid - scenario_exec) @@ -153,7 +156,7 @@ def price_combo( # "today's vol" into a number sitting next to net_pnl (which uses the scenario's # shocked vol), producing a max_gain that could be below net_pnl. Pricing both with # the same scenario vol view keeps them consistent. - bounded = check_bounded_risk(legs, entry_ref, surface_scenario, spot_now, r) + bounded = check_bounded_risk(legs, entry_ref, surface_scenario, spot_now, r, contract_size) delta_now = greeks_at(legs, spot_now, 0, surface_now, r)["delta"] delta_scenario = greeks_at(legs, spot_scenario, horizon_days, surface_scenario, r)["delta"] @@ -174,7 +177,7 @@ def price_combo( }) -def check_bounded_risk(legs: List[Dict[str, Any]], entry_ref: float, surface: Any, spot: float, r: float = 0.05) -> Dict[str, Any]: +def check_bounded_risk(legs: List[Dict[str, Any]], entry_ref: float, surface: Any, spot: float, r: float = 0.05, contract_size: float = DEFAULT_CONTRACT_SIZE) -> Dict[str, Any]: """ Scan a wide log-spaced spot range at expiry and inspect both tails independently for LOSS vs GAIN direction. "Bounded risk" only requires the loss side to be capped — a long @@ -190,29 +193,45 @@ def check_bounded_risk(legs: List[Dict[str, Any]], entry_ref: float, surface: An `surface` to be priced, so max_gain/max_loss there is only as good as that vol input. """ eval_days = min(l["days_to_expiry"] for l in legs) - grid = np.geomspace(spot * 0.05, spot * 20, 300) - values = [value_at(legs, float(p), eval_days, surface, r) - entry_ref for p in grid] - tail_n = max(3, len(values) // 30) + # Wide, log-spaced tail grid — used only to detect whether the payoff flattens out + # (bounded) toward either extreme, or keeps moving further away. + tail_grid = np.geomspace(spot * 0.05, spot * 20, 300) + tail_values = [value_at(legs, float(p), eval_days, surface, r, contract_size) - entry_ref for p in tail_grid] + + tail_n = max(3, len(tail_values) // 30) tol = max(abs(entry_ref), 1.0) * 0.01 - lo_edge, lo_in = values[0], values[tail_n] - hi_edge, hi_in = values[-1], values[-1 - tail_n] + lo_edge, lo_in = tail_values[0], tail_values[tail_n] + hi_edge, hi_in = tail_values[-1], tail_values[-1 - tail_n] loss_bounded = (lo_edge >= lo_in - tol) and (hi_edge >= hi_in - tol) gain_bounded = (lo_edge <= lo_in + tol) and (hi_edge <= hi_in + tol) + # A calendar spread's (or ratio spread's) real best/worst case is a sharp peak right at + # a strike, not out in the tails — over a log-spaced 0.05x-20x sweep the two nearest + # samples can straddle right over it, missing the true extremum entirely (confirmed: + # for a real calendar spread the tail grid reported max_gain=-0.05 while the payoff at + # the strike itself was +0.22 — the grid simply never sampled that point). Add a dense + # linear sweep across the legs' own strikes to capture it. + strikes = [l["strike"] for l in legs] + lo_k, hi_k = min(strikes) * 0.7, max(strikes) * 1.3 + near_grid = np.linspace(max(lo_k, spot * 0.05), min(hi_k, spot * 20), 400) + near_values = [value_at(legs, float(p), eval_days, surface, r, contract_size) - entry_ref for p in near_grid] + + all_values = tail_values + near_values + return { "bounded": loss_bounded, - "max_loss": round(min(values), 2) if loss_bounded else None, - "max_gain": round(max(values), 2) if gain_bounded else None, + "max_loss": round(min(all_values), 2) if loss_bounded else None, + "max_gain": round(max(all_values), 2) if gain_bounded else None, } -def payoff_curve_expiry(legs: List[Dict[str, Any]], surface_now: Surface, spot: float, n: int = 100) -> List[Dict[str, float]]: +def payoff_curve_expiry(legs: List[Dict[str, Any]], surface_now: Surface, spot: float, n: int = 100, contract_size: float = DEFAULT_CONTRACT_SIZE) -> List[Dict[str, float]]: eval_days = min(l["days_to_expiry"] for l in legs) prices = np.linspace(spot * 0.5, spot * 1.5, n) return [ - {"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), eval_days, surface_now, 0.05)), 2)} + {"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), eval_days, surface_now, 0.05, contract_size)), 2)} for p in prices ] @@ -224,6 +243,7 @@ def expected_pnl_scenario( r: float, entry_ref: float, n: int = 200, + contract_size: float = DEFAULT_CONTRACT_SIZE, ) -> float: """ Probability-weighted expected P&L at the scenario date: integrates the payoff over a @@ -237,13 +257,13 @@ def expected_pnl_scenario( T = remaining / 365 sd = sigma * math.sqrt(T) if sd <= 1e-6: - return value_at(legs, spot_scenario, horizon_days, surface_scenario, r) - entry_ref + return value_at(legs, spot_scenario, horizon_days, surface_scenario, r, contract_size) - entry_ref mu = math.log(spot_scenario) + (r - 0.5 * sigma ** 2) * T grid = np.geomspace(spot_scenario * 0.15, spot_scenario * 4, n) log_grid = np.log(grid) density = np.exp(-0.5 * ((log_grid - mu) / sd) ** 2) / (grid * sd * math.sqrt(2 * math.pi)) - pnl = np.array([value_at(legs, float(s), horizon_days, surface_scenario, r) - entry_ref for s in grid]) + pnl = np.array([value_at(legs, float(s), horizon_days, surface_scenario, r, contract_size) - entry_ref for s in grid]) numerator = np.trapz(pnl * density, grid) denominator = np.trapz(density, grid) @@ -258,9 +278,10 @@ def payoff_curves( horizon_days: int, r: float = 0.05, n: int = 100, + contract_size: float = DEFAULT_CONTRACT_SIZE, ) -> Dict[str, Any]: spot = chain_slice["spot"] - priced = price_combo(legs, chain_slice, surface_now, surface_scenario, horizon_days, r) + priced = price_combo(legs, chain_slice, surface_now, surface_scenario, horizon_days, r, contract_size) entry_ref = priced["entry_cost"] lo, hi = spot * 0.6, spot * 1.4 @@ -268,11 +289,11 @@ def payoff_curves( eval_days_expiry = min(l["days_to_expiry"] for l in legs) at_expiry = [ - {"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), eval_days_expiry, surface_now, r) - entry_ref), 2)} + {"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), eval_days_expiry, surface_now, 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) - entry_ref), 2)} + {"underlying": round(float(p), 2), "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/backend/services/strategy_optimizer.py b/backend/services/strategy_optimizer.py index 40f6220..39ae597 100644 --- a/backend/services/strategy_optimizer.py +++ b/backend/services/strategy_optimizer.py @@ -10,7 +10,7 @@ from typing import Any, Dict, List, Optional from services.option_chain import get_chain_slice from services.vol_surface import Surface, ScenarioSurface, build_surface, apply_scenario -from services.strategy_engine import price_combo, expected_pnl_scenario, to_native +from services.strategy_engine import price_combo, expected_pnl_scenario, to_native, DEFAULT_CONTRACT_SIZE from services.strategy_templates import generate_all, strikes_for MAX_SEEDS_FOR_RESIDUAL_SEARCH = 40 @@ -18,7 +18,7 @@ RESIDUAL_ITERATIONS_PER_SEED = 8 RESIDUAL_MAX_EVALS = 400 -def _score(priced: Dict[str, Any], legs: List[Dict[str, Any]], objective: str, surface_scenario: ScenarioSurface, horizon_days: int, r: float) -> Optional[float]: +def _score(priced: Dict[str, Any], legs: List[Dict[str, Any]], objective: str, surface_scenario: ScenarioSurface, horizon_days: int, r: float, contract_size: float) -> Optional[float]: if objective == "net_pnl": return priced["net_pnl"] if objective == "return_on_risk": @@ -26,7 +26,7 @@ def _score(priced: Dict[str, Any], legs: List[Dict[str, Any]], objective: str, s return None return priced["net_pnl"] / abs(priced["max_loss"]) if objective == "prob_weighted": - return expected_pnl_scenario(legs, surface_scenario, horizon_days, r, priced["entry_cost"]) + return expected_pnl_scenario(legs, surface_scenario, horizon_days, r, priced["entry_cost"], contract_size=contract_size) raise ValueError(f"Objectif inconnu: {objective}") @@ -46,16 +46,17 @@ def _passes_constraints(legs: List[Dict[str, Any]], priced: Dict[str, Any], cons def _evaluate( name: str, legs: List[Dict[str, Any]], chain_slice: Dict[str, Any], surface_now: Surface, surface_scenario: ScenarioSurface, horizon_days: int, r: float, constraints: Dict[str, Any], objective: str, + contract_size: float = DEFAULT_CONTRACT_SIZE, ) -> Optional[Dict[str, Any]]: if len(legs) > constraints["max_legs"] or len(legs) == 0: return None try: - priced = price_combo(legs, chain_slice, surface_now, surface_scenario, horizon_days, r) + priced = price_combo(legs, chain_slice, surface_now, surface_scenario, horizon_days, r, contract_size) except Exception: return None if not _passes_constraints(legs, priced, constraints): return None - score = _score(priced, legs, objective, surface_scenario, horizon_days, r) + score = _score(priced, legs, objective, surface_scenario, horizon_days, r, contract_size) if score is None: return None return {"template_name": name, "legs": legs, "score": round(score, 2), "objective": objective, **priced} @@ -87,6 +88,7 @@ def _perturb(legs: List[Dict[str, Any]], strikes_by_expiry: Dict[Any, List[float def _residual_search( seeds: List[Dict[str, Any]], chain_slice: Dict[str, Any], surface_now: Surface, surface_scenario: ScenarioSurface, horizon_days: int, r: float, constraints: Dict[str, Any], objective: str, + contract_size: float = DEFAULT_CONTRACT_SIZE, ) -> List[Dict[str, Any]]: strikes_by_expiry = { (exp["expiry_date"], opt_type): strikes_for(exp, opt_type) @@ -104,7 +106,7 @@ def _residual_search( evals += 1 evaluated = _evaluate( f"{seed['template_name']} (variante)", candidate_legs, chain_slice, surface_now, - surface_scenario, horizon_days, r, constraints, objective, + surface_scenario, horizon_days, r, constraints, objective, contract_size, ) if evaluated and evaluated["score"] > current["score"]: current = evaluated @@ -146,6 +148,7 @@ def optimize( constraints: Dict[str, Any], objective: str, top_n: int = 20, + contract_size: float = DEFAULT_CONTRACT_SIZE, ) -> List[Dict[str, Any]]: chain_slice = get_chain_slice(symbol, horizon_days, n_expiries) surface_now = build_surface(chain_slice) @@ -157,14 +160,14 @@ def optimize( candidates = generate_all(chain_slice) scored: List[Dict[str, Any]] = [] for name, legs in candidates: - evaluated = _evaluate(name, legs, chain_slice, surface_now, surface_scenario, horizon_days, rate, constraints, objective) + evaluated = _evaluate(name, legs, chain_slice, surface_now, surface_scenario, horizon_days, rate, constraints, objective, contract_size) if evaluated: scored.append(evaluated) scored.sort(key=lambda c: c["score"], reverse=True) seeds = scored[:MAX_SEEDS_FOR_RESIDUAL_SEARCH] - refined = _residual_search(seeds, chain_slice, surface_now, surface_scenario, horizon_days, rate, constraints, objective) + refined = _residual_search(seeds, chain_slice, surface_now, surface_scenario, horizon_days, rate, constraints, objective, contract_size) scored.extend(refined) scored.sort(key=lambda c: c["score"], reverse=True) diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 81d6bee..e6ce0e5 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1534,6 +1534,7 @@ export type StrategyScenario = { manual_grid?: ManualGridCell[] rate?: number n_expiries?: number + contract_size?: number } export type StrategyLeg = { diff --git a/frontend/src/pages/StrategyBuilder.tsx b/frontend/src/pages/StrategyBuilder.tsx index 9dfb4d0..6f1bb9e 100644 --- a/frontend/src/pages/StrategyBuilder.tsx +++ b/frontend/src/pages/StrategyBuilder.tsx @@ -584,6 +584,7 @@ export default function StrategyBuilder() { const [horizonDays, setHorizonDays] = useState(8) const [scenario, setScenario] = useState({ symbol: '', horizon_days: 8, spot_shock_pct: 0, iv_level_shift: 0, skew_tilt: 0, term_shift: 0, manual_grid: [], + contract_size: 100_000, }) // Chain lookup only commits on blur/Enter/datalist-pick, never mid-keystroke — typing @@ -662,11 +663,11 @@ export default function StrategyBuilder() { const handleLoadScenario = (s: SavedScenario) => { setSymbol(s.symbol) setHorizonDays(s.horizon_days) - setScenario({ + setScenario(prev => ({ symbol: s.symbol, horizon_days: s.horizon_days, spot_shock_pct: s.spot_shock_pct, iv_level_shift: s.iv_level_shift, skew_tilt: s.skew_tilt, term_shift: s.term_shift, - manual_grid: s.manual_grid, - }) + manual_grid: s.manual_grid, contract_size: prev.contract_size, + })) } const handleSaveStrategy = () => { @@ -721,13 +722,24 @@ export default function StrategyBuilder() {
Jambes (1-4) — Spot {chain.spot}
- +
+ + +
{legs.map((leg, i) => (