From efe29cef536df6f806974bff9c322078d9cb3984 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Thu, 30 Jul 2026 14:48:20 +0200 Subject: [PATCH] feat: strategy builder --- backend/routers/strategy_builder.py | 22 +- backend/services/realized_scenario.py | 67 ++++++ backend/services/strategy_replay.py | 44 +++- frontend/src/hooks/useApi.ts | 22 ++ frontend/src/pages/StrategyBuilder.tsx | 301 +++++++++++++++++++++---- 5 files changed, 412 insertions(+), 44 deletions(-) create mode 100644 backend/services/realized_scenario.py diff --git a/backend/routers/strategy_builder.py b/backend/routers/strategy_builder.py index 1c2b5b4..9f035ca 100644 --- a/backend/routers/strategy_builder.py +++ b/backend/routers/strategy_builder.py @@ -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 diff --git a/backend/services/realized_scenario.py b/backend/services/realized_scenario.py new file mode 100644 index 0000000..e8f598b --- /dev/null +++ b/backend/services/realized_scenario.py @@ -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, + } diff --git a/backend/services/strategy_replay.py b/backend/services/strategy_replay.py index ba44d88..85bd380 100644 --- a/backend/services/strategy_replay.py +++ b/backend/services/strategy_replay.py @@ -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, } diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 4348843..30d837c 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1757,6 +1757,7 @@ export type StrategyScenario = { contract_size?: number dte_min?: number | null dte_max?: number | null + as_of?: string | null } export type StrategyLeg = { @@ -1875,12 +1876,33 @@ export const useOptimizeStrategy = () => api.post('/strategy-builder/optimize', body).then(r => r.data), }) +// "Dériver d'un historique" mode: turns a real Du→Au window into scenario inputs computed +// from what actually happened (real spot move, real ATM IV move) — not a guess. +export type RealizedScenario = { + symbol: string; saxo_symbol: string; start_date: string; end_date: string + spot_a: number; spot_b: number; spot_shock_pct: number + iv_a: number | null; iv_b: number | null; iv_level_shift: number | null + horizon_days: number +} +export const useRealizedScenario = () => + useMutation({ + mutationFn: ({ symbol, start_date, end_date }) => + api.get('/strategy-builder/realized-scenario', { params: { symbol, start_date, end_date } }).then(r => r.data), + }) + // Day-by-day mark-to-market of a fixed set of legs against REAL accumulated Saxo history // between two dates — not a scenario, a replay of what actually happened. export type ReplayPoint = { date: string; spot: number | null; position_value: number; pnl: number } +export type ReplayLegSnapshot = { + option_type: 'call' | 'put' | 'stock'; position: 'long' | 'short'; quantity: number + strike: number; expiry_date: string + mid: number; bid: number | null; ask: number | null; iv: number | null + greeks: { delta: number; gamma: number; theta: number; vega: number } | null +} export type ReplayResult = { symbol: string; saxo_symbol: string; start_date: string; end_date: string entry_date: string; entry_value: number; final_pnl: number + entry_legs: ReplayLegSnapshot[]; exit_legs: ReplayLegSnapshot[] points: ReplayPoint[]; missing_dates: string[] } export const useReplayStrategy = () => diff --git a/frontend/src/pages/StrategyBuilder.tsx b/frontend/src/pages/StrategyBuilder.tsx index e594061..664215c 100644 --- a/frontend/src/pages/StrategyBuilder.tsx +++ b/frontend/src/pages/StrategyBuilder.tsx @@ -5,7 +5,7 @@ import { import { Layers, Plus, Trash2, RefreshCw, AlertTriangle, Search, Save, FolderOpen, X, History } from 'lucide-react' import clsx from 'clsx' import { - useOptionChainSlice, usePriceStrategy, useOptimizeStrategy, useSuggestedProfile, useReplayStrategy, usePresets, + useOptionChainSlice, usePriceStrategy, useOptimizeStrategy, useSuggestedProfile, useReplayStrategy, usePresets, useRealizedScenario, useScenarios, useSaveScenario, useDeleteScenario, useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy, useSaxoSymbols, useIvForTrade, @@ -101,7 +101,9 @@ function GreeksTile({ label, now, scenario, precision = 4, hint }: { label: stri // ── Scenario panel ──────────────────────────────────────────────────────────── -function ScenarioPanel({ +// Shared across all 3 modes (Construire/Dériver/Tester) — which symbol, which chain +// window (horizon for ranking + DTE bounds for filtering) legs get drawn from. +function SymbolPanel({ symbol, setSymbol, onCommitSymbol, horizonDays, setHorizonDays, scenario, setScenario, watchlistTickers, }: { symbol: string; setSymbol: (v: string) => void; onCommitSymbol: (v?: string) => void @@ -109,25 +111,8 @@ function ScenarioPanel({ scenario: StrategyScenario; setScenario: (v: StrategyScenario) => void watchlistTickers: string[] }) { - const slider = ( - key: 'spot_shock_pct' | 'iv_level_shift' | 'skew_tilt' | 'term_slope_shift' | 'rate_shock_bps', - label: string, min: number, max: number, step: number, fmt: (v: number) => string, - ) => ( -
-
- {label} - {fmt(scenario[key] ?? 0)} -
- setScenario({ ...scenario, [key]: parseFloat(e.target.value) })} - className="w-full accent-blue-500" - /> -
- ) - return ( -
+
@@ -179,7 +164,33 @@ function ScenarioPanel({ />
+
+ ) +} +// Construire-only: the manual "what if" sliders. Kept separate from SymbolPanel so +// Dériver/Tester (which don't use a hand-dialed scenario) don't render them at all. +function ScenarioSlidersPanel({ scenario, setScenario }: { scenario: StrategyScenario; setScenario: (v: StrategyScenario) => void }) { + const slider = ( + key: 'spot_shock_pct' | 'iv_level_shift' | 'skew_tilt' | 'term_slope_shift' | 'rate_shock_bps', + label: string, min: number, max: number, step: number, fmt: (v: number) => string, + ) => ( +
+
+ {label} + {fmt(scenario[key] ?? 0)} +
+ setScenario({ ...scenario, [key]: parseFloat(e.target.value) })} + className="w-full accent-blue-500" + /> +
+ ) + + return ( +
+
Scénario manuel — "et si..."
{slider('spot_shock_pct', 'Choc spot', -20, 20, 0.5, (v) => `${v >= 0 ? '+' : ''}${v.toFixed(1)}%`)} {slider('iv_level_shift', 'Choc niveau IV', -0.15, 0.15, 0.005, (v) => `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}pts`)} @@ -649,14 +660,64 @@ function ReplayCard({ {result && ( <> -
- Entrée le {result.entry_date} - {' '}(valeur {fmtMoney(result.entry_value)}) · P&L final{' '} - {fmtMoney(result.final_pnl)} - {result.missing_dates.length > 0 && ( - · {result.missing_dates.length} jour(s) sans cotation exploitable, exclu(s) - )} +
+
+
Valeur d'entrée ({result.entry_date})
+
{fmtMoney(result.entry_value)}
+
+
+
P&L final
+
{fmtMoney(result.final_pnl)}
+
+
+
Jours exploités
+
{result.points.length}
+
+
+
Jours exclus (sans cotation réelle)
+
{result.missing_dates.length}
+
+ +
+ + + + + + + + + + + + + + + {result.entry_legs.map((leg, i) => { + const exitLeg = result.exit_legs[i] + return ( + + + + + + + + + + + ) + })} + +
JambeStrikeÉchéanceMid entréeBid/Ask entréeIV entréeΔ entréeMid sortie
+ {leg.position === 'long' ? 'Achat' : 'Vente'} + {' '}{leg.quantity > 1 ? `${leg.quantity}x ` : ''}{leg.option_type === 'stock' ? 'Sous-jacent' : (leg.option_type === 'call' ? 'Call' : 'Put')} + {leg.option_type === 'stock' ? '—' : fmtPrice(leg.strike)}{leg.option_type === 'stock' ? '—' : leg.expiry_date}{fmtPrice(leg.mid)} + {leg.bid != null && leg.ask != null ? `${fmtPrice(leg.bid)} / ${fmtPrice(leg.ask)}` : '—'} + {leg.iv != null ? `${(leg.iv * 100).toFixed(1)}%` : '—'}{leg.greeks ? leg.greeks.delta.toFixed(3) : (leg.option_type === 'stock' ? '1.000' : '—')}{exitLeg ? fmtPrice(exitLeg.mid) : '—'}
+
+ @@ -884,6 +945,13 @@ export default function StrategyBuilder() { // TODAY's chain may not have existed yet, or may have had a very different strike // ladder, on a date a past Replay window actually starts from. const [chainAsOf, setChainAsOf] = useState('') + // Three distinct jobs this page does, kept visually separate per user feedback (a single + // long vertical page mixed "build a hypothetical position," "derive one from what + // actually happened," and "test a fixed position against real history" together): + // Construire = manual scenario + optimizer against a hypothetical. Dériver = auto-scenario + // from a REAL historical window, then optimize under it. Tester = replay fixed legs + // against real quotes day by day. All three share symbol/legs/chainAsOf. + const [mode, setMode] = useState<'build' | 'derive' | 'replay'>('build') const [constraints, setConstraints] = useState({ max_legs: 4, delta_threshold: 0.15, max_loss_cap: null, objective: 'net_pnl', top_n: 20, }) @@ -929,11 +997,14 @@ export default function StrategyBuilder() { useEffect(() => { if (!chain || legs.length === 0) return const t = setTimeout(() => { - priceMutation.mutate({ scenario, legs }) + // Keep pricing consistent with whatever chain the Jambes editor is actually showing + // (chainAsOf) — otherwise a leg picked from a pinned historical chain would silently + // get priced against today's live one instead. + priceMutation.mutate({ scenario: { ...scenario, as_of: chainAsOf || undefined }, legs }) }, 400) return () => clearTimeout(t) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(scenario), JSON.stringify(legs), chain]) + }, [JSON.stringify(scenario), JSON.stringify(legs), chain, chainAsOf]) const priced = priceMutation.data @@ -953,7 +1024,7 @@ export default function StrategyBuilder() { const handleOptimize = () => { setActiveTemplate(null) - optimizeMutation.mutate({ scenario, constraints, greek_profile: greekProfile }) + optimizeMutation.mutate({ scenario: { ...scenario, as_of: chainAsOf || undefined }, constraints, greek_profile: greekProfile }) } const handleSelectCandidate = (c: StrategyCandidate) => { @@ -961,6 +1032,37 @@ export default function StrategyBuilder() { setLegs(c.legs) } + // ── Dériver d'un historique ──────────────────────────────────────────────── + const deriveBounds = saxoSymbols?.find(s => s.symbol.toUpperCase() === debouncedSymbol.toUpperCase()) + const [deriveStart, setDeriveStart] = useState('') + const [deriveEnd, setDeriveEnd] = useState('') + const { mutate: computeRealized, data: realized, isPending: realizedPending, error: realizedError, reset: resetRealized } = useRealizedScenario() + + useEffect(() => { + if (deriveBounds && !deriveStart && !deriveEnd) { + setDeriveEnd(deriveBounds.last_date.slice(0, 10)) + const end = new Date(deriveBounds.last_date.slice(0, 10)) + const start = new Date(Math.max(end.getTime() - 7 * 86400000, new Date(deriveBounds.first_date.slice(0, 10)).getTime())) + setDeriveStart(start.toISOString().slice(0, 10)) + } + }, [deriveBounds]) // eslint-disable-line react-hooks/exhaustive-deps + + const runDeriveOptimize = () => { + if (!realized) return + const derived: StrategyScenario = { + ...scenario, + spot_shock_pct: realized.spot_shock_pct, + iv_level_shift: realized.iv_level_shift ?? scenario.iv_level_shift, + horizon_days: realized.horizon_days, + as_of: deriveStart, + } + setScenario(derived) + setHorizonDays(realized.horizon_days) + setChainAsOf(deriveStart) + setActiveTemplate(null) + optimizeMutation.mutate({ scenario: derived, constraints, greek_profile: greekProfile }) + } + const handleLoadScenario = (s: SavedScenario) => { setSymbol(s.symbol) setHorizonDays(s.horizon_days) @@ -1009,16 +1111,133 @@ export default function StrategyBuilder() {
)} - +
+ {([ + ['build', 'Construire', 'Scénario manuel + jambes + optimiseur — fabriquer une stratégie dans l\'absolu'], + ['derive', 'Dériver d\'un historique', 'Scénario calculé depuis un vrai mouvement passé, puis optimiseur dessus'], + ['replay', 'Tester (Replay)', 'Marque au marché des jambes fixes contre l\'historique Saxo réel'], + ] as const).map(([key, label, title]) => ( + + ))} +
+ + {mode === 'build' && ( + <> + + { setActiveTemplate(templateName); setLegs(legs) }} /> + + )} {chainLoading &&
Chargement de la chaîne réelle ({debouncedSymbol})…
} - {chain && } - {chain && } + {mode === 'build' && chain && } + {mode === 'build' && chain && } + + {mode === 'derive' && ( +
+
Scénario dérivé d'un historique réel
+

+ Calcule le mouvement de spot et d'IV ATM réellement survenu entre deux dates (vraies cotations Saxo captées, pas une hypothèse), puis l'utilise comme scénario pour l'optimiseur — "qu'aurait-il fallu faire pour ce mouvement-là ?" + Tilt skew et pente du terme ne sont pas dérivés (comparer deux smiles réels de façon fiable est un exercice à part) — ils restent à 0, ajustables ensuite dans l'onglet Construire. +

+ {!deriveBounds &&
Aucun historique Saxo pour ce symbole.
} + {deriveBounds && ( + <> +
+
+ + { setDeriveStart(e.target.value); resetRealized() }} + className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" + /> +
+
+ + { setDeriveEnd(e.target.value); resetRealized() }} + className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" + /> +
+ +
+ + {realizedError && ( +
{(realizedError as any)?.response?.data?.detail ?? 'Erreur de calcul.'}
+ )} + + {realized && ( +
+
+ + Spot : {fmtPrice(realized.spot_a)} → {fmtPrice(realized.spot_b)} + {' '}(= 0 ? 'text-emerald-400' : 'text-red-400'}>{realized.spot_shock_pct >= 0 ? '+' : ''}{realized.spot_shock_pct.toFixed(2)}%) + + {realized.iv_a != null && realized.iv_b != null ? ( + + IV ATM : {(realized.iv_a * 100).toFixed(1)}% → {(realized.iv_b * 100).toFixed(1)}% + {' '}(= 0) ? 'text-orange-400' : 'text-blue-400'}>{(realized.iv_level_shift ?? 0) >= 0 ? '+' : ''}{((realized.iv_level_shift ?? 0) * 100).toFixed(1)}pts) + + ) : ( + IV ATM indisponible à l'une des deux dates + )} + Sur {realized.horizon_days}j +
+ + +
+ )} + + )} + + {optimizeMutation.isError && ( +
+ {(optimizeMutation.error as any)?.response?.data?.detail ?? "Erreur lors de l'optimisation."} +
+ )} + {optimizeMutation.data && optimizeMutation.data.warnings.length > 0 && ( +
+ {optimizeMutation.data.warnings.map((w, i) => ( +
+ + {w} +
+ ))} +
+ )} + {optimizeMutation.data && ( + <> + +

+ Une jambe sélectionnée ci-dessus alimente l'éditeur de jambes plus bas — passe ensuite à l'onglet Tester (Replay) pour voir comment cette structure se serait réellement comportée sur cette même fenêtre. +

+ + )} +
+ )} {chain && (
@@ -1128,14 +1347,14 @@ export default function StrategyBuilder() {
)} - {chain && legs.length > 0 && ( + {mode === 'replay' && chain && legs.length > 0 && ( )} - {chain && ( + {mode === 'build' && chain && ( <> )} - {optimizeMutation.isError && ( + {mode === 'build' && optimizeMutation.isError && (
{(optimizeMutation.error as any)?.response?.data?.detail ?? "Erreur lors de l'optimisation."}
)} - {optimizeMutation.data && optimizeMutation.data.warnings.length > 0 && ( + {mode === 'build' && optimizeMutation.data && optimizeMutation.data.warnings.length > 0 && (
{optimizeMutation.data.warnings.map((w, i) => (
@@ -1167,18 +1386,18 @@ export default function StrategyBuilder() { ))}
)} - {optimizeMutation.data && ( + {mode === 'build' && optimizeMutation.data && ( )} - {priceMutation.isPending &&
Calcul en cours…
} - {priceMutation.isError && ( + {mode === 'build' && priceMutation.isPending &&
Calcul en cours…
} + {mode === 'build' && priceMutation.isError && (
Erreur de pricing — vérifiez les jambes sélectionnées.
)} - {priced && ( + {mode === 'build' && priced && ( <>