From cc22cbd3e0e54ea6f6729dda98fd498fc09d3e25 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 31 Jul 2026 12:12:24 +0200 Subject: [PATCH] feat: strategy builder --- backend/routers/strategy_builder.py | 41 ++- backend/services/database.py | 10 +- backend/services/strategy_optimizer.py | 17 +- backend/services/strategy_replay.py | 4 + frontend/src/hooks/useApi.ts | 12 +- frontend/src/pages/StrategyBuilder.tsx | 478 +++++++++++++------------ 6 files changed, 316 insertions(+), 246 deletions(-) diff --git a/backend/routers/strategy_builder.py b/backend/routers/strategy_builder.py index 9f035ca..db03681 100644 --- a/backend/routers/strategy_builder.py +++ b/backend/routers/strategy_builder.py @@ -41,12 +41,21 @@ 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 + # "Analyse période 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. + # behavior for the normal Construire flow. This is the position's ENTRY date. as_of: Optional[str] = None + # The scrubbed-to day within the historical period: when set, surface_scenario is a + # REAL smile fit from the Saxo chain at/before this date (services.vol_surface.Surface, + # built the same way surface_now already is) instead of apply_scenario's parametric + # spot/IV/skew/term shock — Surface and ScenarioSurface expose the same .spot/.iv_at() + # interface, so this is a drop-in substitution, not a new pricing path. horizon_days + # should equal (checkpoint_as_of - as_of).days so the elapsed-time math stays + # consistent with what's actually being priced. None (default) = today's synthetic + # scenario shock, unchanged behavior. + checkpoint_as_of: Optional[str] = None @property def shocked_rate(self) -> float: @@ -119,6 +128,7 @@ class StrategySaveRequest(BaseModel): net_pnl_scenario: Optional[float] = None net_delta: Optional[float] = None notes: Optional[str] = "" + source: str = "synthetic" # "synthetic" (Construire) | "historical" (Analyse période historique) def _build_surfaces(scenario: ScenarioIn): @@ -127,14 +137,23 @@ def _build_surfaces(scenario: ScenarioIn): 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( - surface_now, - spot_shock_pct=scenario.spot_shock_pct, - iv_level_shift=scenario.iv_level_shift, - skew_tilt=scenario.skew_tilt, - term_slope_shift=scenario.term_slope_shift, - manual_grid=scenario.manual_grid, - ) + 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, + ) + surface_scenario = build_surface(checkpoint_chain) + else: + surface_scenario = apply_scenario( + surface_now, + spot_shock_pct=scenario.spot_shock_pct, + iv_level_shift=scenario.iv_level_shift, + skew_tilt=scenario.skew_tilt, + term_slope_shift=scenario.term_slope_shift, + manual_grid=scenario.manual_grid, + ) return chain_slice, surface_now, surface_scenario @@ -287,6 +306,8 @@ def optimize(req: OptimizeRequest): top_n=req.constraints.top_n, contract_size=req.scenario.contract_size, greek_profile=req.greek_profile.model_dump() if req.greek_profile else None, + as_of=req.scenario.as_of, + checkpoint_as_of=req.scenario.checkpoint_as_of, ) except Exception as e: import traceback diff --git a/backend/services/database.py b/backend/services/database.py index 56afd48..1b8afc9 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -341,6 +341,11 @@ def init_db(): "ALTER TABLE strategy_scenarios ADD COLUMN rate_shock_bps REAL DEFAULT 0", "ALTER TABLE strategy_scenarios ADD COLUMN dte_min INTEGER", "ALTER TABLE strategy_scenarios ADD COLUMN dte_max INTEGER", + # Strategy Builder — Construire/Analyse historique merge: tags which mode priced + # this strategy when it was saved, so the saved-strategies library (now shown in + # both modes) can badge it and the other mode knows it's re-pricing a strategy that + # wasn't originally priced there. + "ALTER TABLE saved_strategies ADD COLUMN source TEXT DEFAULT 'synthetic'", ]: try: c.execute(_sql) @@ -6421,8 +6426,8 @@ def save_strategy(strategy: Dict[str, Any]) -> str: conn = get_conn() conn.execute("""INSERT INTO saved_strategies ( id, scenario_id, symbol, template_name, objective, legs, - entry_cost, max_gain, max_loss, net_pnl_scenario, net_delta, notes - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", ( + entry_cost, max_gain, max_loss, net_pnl_scenario, net_delta, notes, source + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( strategy_id, strategy.get("scenario_id"), strategy["symbol"], @@ -6435,6 +6440,7 @@ def save_strategy(strategy: Dict[str, Any]) -> str: strategy.get("net_pnl_scenario"), strategy.get("net_delta"), strategy.get("notes", ""), + strategy.get("source", "synthetic"), )) conn.commit() conn.close() diff --git a/backend/services/strategy_optimizer.py b/backend/services/strategy_optimizer.py index 806ed81..c91fae9 100644 --- a/backend/services/strategy_optimizer.py +++ b/backend/services/strategy_optimizer.py @@ -274,14 +274,23 @@ def optimize( dte_max: Optional[int] = None, greek_profile: Optional[Dict[str, Any]] = None, as_of: Optional[str] = None, + checkpoint_as_of: Optional[str] = None, ) -> List[Dict[str, Any]]: r = rate + rate_shock_bps / 10000.0 chain_slice = get_chain_slice(symbol, horizon_days, n_expiries, dte_min=dte_min, dte_max=dte_max, as_of=as_of) surface_now = build_surface(chain_slice) - surface_scenario = apply_scenario( - surface_now, spot_shock_pct=spot_shock_pct, iv_level_shift=iv_level_shift, - skew_tilt=skew_tilt, term_slope_shift=term_slope_shift, manual_grid=manual_grid, - ) + if checkpoint_as_of: + # Real smile-of-the-day (same fitting as surface_now) instead of a parametric + # shock — see routers.strategy_builder.ScenarioIn.checkpoint_as_of. Surface and + # ScenarioSurface share the same .spot/.iv_at() interface, so every candidate + # evaluated below (_evaluate/_residual_search) needs no change. + checkpoint_chain = get_chain_slice(symbol, horizon_days, n_expiries, dte_min=dte_min, dte_max=dte_max, as_of=checkpoint_as_of) + surface_scenario = build_surface(checkpoint_chain) + else: + surface_scenario = apply_scenario( + surface_now, spot_shock_pct=spot_shock_pct, iv_level_shift=iv_level_shift, + skew_tilt=skew_tilt, term_slope_shift=term_slope_shift, manual_grid=manual_grid, + ) candidates = generate_all(chain_slice) scored: List[Dict[str, Any]] = [] diff --git a/backend/services/strategy_replay.py b/backend/services/strategy_replay.py index 85bd380..79f1ae4 100644 --- a/backend/services/strategy_replay.py +++ b/backend/services/strategy_replay.py @@ -118,6 +118,10 @@ def replay_position( "date": d, "spot": chain.get("spot"), "position_value": round(value, 2), "pnl": round(value - entry_value, 2), + # Every day's real per-leg quote/IV/greeks, not just entry/exit — lets the + # "Analyse période historique" day-scrubber show the real leg detail for + # whichever day is currently scrubbed to, not only the window's endpoints. + "legs": day_legs, }) if not points: diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 8613d89..c78b1d5 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1758,6 +1758,11 @@ export type StrategyScenario = { dte_min?: number | null dte_max?: number | null as_of?: string | null + // Analyse période historique: when set, pricing uses a REAL smile fit from the Saxo + // chain at/before this date instead of a parametric shock — see backend ScenarioIn's + // checkpoint_as_of docstring. horizon_days should equal the elapsed days between as_of + // (entry date) and this checkpoint. + checkpoint_as_of?: string | null } export type StrategyLeg = { @@ -1896,12 +1901,15 @@ export const useRealizedScenario = () => // 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 +} | null +export type ReplayPoint = { + date: string; spot: number | null; position_value: number; pnl: number + legs: ReplayLegSnapshot[] } export type ReplayResult = { symbol: string; saxo_symbol: string; start_date: string; end_date: string @@ -1942,6 +1950,7 @@ export type SavedStrategyRecord = { id: string; scenario_id: string | null; symbol: string; template_name: string; objective: string legs: StrategyLeg[]; entry_cost: number | null; max_gain: number | null; max_loss: number | null net_pnl_scenario: number | null; net_delta: number | null; notes: string; created_at: string + source: 'synthetic' | 'historical' } export const useScenarios = (symbol?: string) => @@ -1979,6 +1988,7 @@ export const useSaveStrategyRecord = () => { scenario_id?: string | null; symbol: string; template_name?: string; objective?: string legs: StrategyLeg[]; entry_cost?: number | null; max_gain?: number | null; max_loss?: number | null net_pnl_scenario?: number | null; net_delta?: number | null; notes?: string + source?: 'synthetic' | 'historical' }) => api.post('/strategy-builder/saved', body).then(r => r.data), onSuccess: () => qc.invalidateQueries({ queryKey: ['saved-strategies'] }), }) diff --git a/frontend/src/pages/StrategyBuilder.tsx b/frontend/src/pages/StrategyBuilder.tsx index 322ad17..95956a3 100644 --- a/frontend/src/pages/StrategyBuilder.tsx +++ b/frontend/src/pages/StrategyBuilder.tsx @@ -651,92 +651,136 @@ function SuggestedProfileCard({ ) } -function ReplayCard({ - symbol, legs, contractSize, chainAsOf, onUseAsChainAsOf, +// "Analyse période historique" — merges the old "Dériver d'un historique" (realized +// spot/IV move summary, feeds the optimizer) and "Tester (Replay)" (real day-by-day +// mark-to-market) into one period picker with a real day-scrubber. The scrubbed date +// drives scenario.checkpoint_as_of (parent effect), which is what makes the shared +// results pane below price off a REAL smile-of-the-day instead of a hypothesis. +function HistoricalPeriodPanel({ + symbol, legs, contractSize, onUseAsChainAsOf, + periodStart, setPeriodStart, periodEnd, setPeriodEnd, + realizedQuery, replayQuery, checkpointDate, setCheckpointDate, }: { symbol: string; legs: StrategyLeg[]; contractSize: number - chainAsOf: string; onUseAsChainAsOf: (date: string) => void + onUseAsChainAsOf: (date: string) => void + periodStart: string; setPeriodStart: (v: string) => void + periodEnd: string; setPeriodEnd: (v: string) => void + realizedQuery: ReturnType + replayQuery: ReturnType + checkpointDate: string; setCheckpointDate: (v: string) => void }) { const { data: saxoSymbols } = useSaxoSymbols() const bounds = saxoSymbols?.find(s => s.symbol.toUpperCase() === symbol.toUpperCase()) - const { mutate: runReplay, data: result, isPending, error } = useReplayStrategy() - - const [startDate, setStartDate] = useState('') - const [endDate, setEndDate] = useState('') + const { mutate: computeRealized, data: realized, isPending: realizedPending, error: realizedError, reset: resetRealized } = realizedQuery + const { mutate: runReplay, data: result, isPending: replayPending, error: replayError } = replayQuery // Default the range to the last available week of real history once bounds load — // exactly "entre J et J+7" against what's actually been captured, not a guess. useEffect(() => { - if (bounds && !startDate && !endDate) { - setEndDate(bounds.last_date.slice(0, 10)) + if (bounds && !periodStart && !periodEnd) { + setPeriodEnd(bounds.last_date.slice(0, 10)) const end = new Date(bounds.last_date.slice(0, 10)) const start = new Date(Math.max(end.getTime() - 7 * 86400000, new Date(bounds.first_date.slice(0, 10)).getTime())) - setStartDate(start.toISOString().slice(0, 10)) + setPeriodStart(start.toISOString().slice(0, 10)) } - }, [bounds]) // eslint-disable-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bounds]) - if (!bounds) return null + // Default the scrubber to the most recent real day once a fresh day-by-day walk + // completes — "où en est-on" is the natural first read of a newly loaded period. + useEffect(() => { + if (result?.points.length) setCheckpointDate(result.points[result.points.length - 1].date) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [result]) - const run = () => { - if (legs.length === 0 || !startDate || !endDate) return - runReplay({ symbol, legs, start_date: startDate, end_date: endDate, contract_size: contractSize }) + if (!bounds) return
Aucun historique Saxo pour {symbol || 'ce symbole'}.
+ + const runAnalysis = () => { + if (!periodStart || !periodEnd) return + onUseAsChainAsOf(periodStart) + if (legs.length > 0) runReplay({ symbol, legs, start_date: periodStart, end_date: periodEnd, contract_size: contractSize }) } + const idx = result?.points.findIndex(p => p.date === checkpointDate) ?? -1 + const checkpointLegs = idx >= 0 ? result?.points[idx].legs : undefined + const entryLegs = result?.points[0]?.legs + return (
- Rejouer sur l'historique Saxo réel (pas un scénario) + Période historique réelle (pas un scénario)
Données disponibles : {bounds.first_date.slice(0, 10)} → {bounds.last_date.slice(0, 10)}

- Marque au marché les jambes ci-dessus jour par jour avec de vraies cotations Saxo captées — pas de prix théorique. - Un jour sans cotation réelle pour une jambe est simplement absent de la courbe. + Calcule le mouvement de spot/IV ATM réellement survenu sur la période et marque au marché les jambes ci-dessous jour par jour avec de vraies cotations Saxo captées. + Le curseur pilote le diagramme payoff/heatmap/Greeks partagé plus bas avec une vraie smile de volatilité reconstruite depuis les cotations du jour scruté — pas une IV plate hypothétique. + Un jour sans cotation réelle pour une jambe est simplement absent du curseur.

setStartDate(e.target.value)} + type="date" value={periodStart} min={bounds.first_date.slice(0, 10)} max={bounds.last_date.slice(0, 10)} + onChange={(e) => { setPeriodStart(e.target.value); resetRealized() }} className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" />
setEndDate(e.target.value)} + type="date" value={periodEnd} min={bounds.first_date.slice(0, 10)} max={bounds.last_date.slice(0, 10)} + onChange={(e) => { setPeriodEnd(e.target.value); resetRealized() }} className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" />
- {error && ( -
- {(error as any)?.response?.data?.detail ?? 'Erreur pendant le replay.'} + {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
)} + {replayError && ( +
{(replayError as any)?.response?.data?.detail ?? "Erreur pendant l'analyse."}
+ )} + {result && ( <>
@@ -758,45 +802,6 @@ function ReplayCard({
-
- - - - - - - - - - - - - - - {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) : '—'}
-
- @@ -813,9 +818,67 @@ function ReplayCard({ formatter={(v: number, name: string) => [name === 'pnl' ? fmtMoney(v) : v, name === 'pnl' ? 'P&L' : 'Spot']} /> + {idx >= 0 && ( + + )} = 0 ? '#10b981' : '#ef4444'} fill="url(#replay-grad)" strokeWidth={2} dot={{ r: 2 }} /> + +
+ + setCheckpointDate(result.points[parseInt(e.target.value)].date)} + className="w-full accent-blue-500" + /> +
+ + {checkpointLegs && ( +
+ + + + + + + + + + + + + + + {checkpointLegs.map((leg, i) => { + if (!leg) return null + const entryLeg = entryLegs?.[i] + return ( + + + + + + + + + + + ) + })} + +
JambeStrikeÉchéanceMid entréeMid ce jourBid/Ask ce jourIV ce jourΔ ce jour
+ {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}{entryLeg ? fmtPrice(entryLeg.mid) : '—'}{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' : '—')}
+
+ )} )}
@@ -987,6 +1050,12 @@ function SavedStrategiesLibrary({ symbol, onLoad }: { symbol: string; onLoad: (l @@ -1020,18 +1089,22 @@ export default function StrategyBuilder() { const commitSymbol = (v?: string) => setDebouncedSymbol((v ?? symbol).trim()) const [legs, setLegs] = useState([]) - // Empty = build against the live chain (now). Set (typically synced from the Replay - // card's "Du") to reconstruct the chain as it stood back then — a leg picked against - // 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. + // Empty = build against the live chain (now). Set (from "Analyse période historique"'s + // "Du") to reconstruct the chain as it stood back then — a leg picked against TODAY's + // chain may not have existed yet, or may have had a very different strike ladder, on a + // date a past period actually starts from. Also doubles as that period's entry date. 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') + // Two jobs this page does, kept visually separate: Construire = manual scenario (surface + // deformed by hand) + optimizer against a hypothetical. Analyse période historique = pick + // a real period, scrub through it day by day, and price/optimize off a REAL smile + // reconstructed from that day's captured Saxo quotes — not a hypothesis. Both share + // symbol/legs/chainAsOf AND the results pane below (payoff/heatmap/Greeks), fed by the + // same `priced`, only the surface behind it differs (see the checkpoint_as_of effect). + const [mode, setMode] = useState<'build' | 'historical'>('build') + // Groups the parameter panels so switching mode doesn't reset which one is open — makes + // both modes read as "the same tool" instead of unrelated pages, and cuts the scroll a + // single long stack of sliders/optimizer/library used to force. + const [subTab, setSubTab] = useState<'params' | 'optimizer' | 'library'>('params') const [constraints, setConstraints] = useState({ max_legs: 4, delta_threshold: 0.15, max_loss_cap: null, objective: 'net_pnl', top_n: 20, }) @@ -1112,36 +1185,48 @@ 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() + // ── Analyse période historique ────────────────────────────────────────────── + const [periodStart, setPeriodStart] = useState('') + const [periodEnd, setPeriodEnd] = useState('') + const [checkpointDate, setCheckpointDate] = useState('') + const realizedQuery = useRealizedScenario() + const replayQuery = useReplayStrategy() + const { data: realized } = realizedQuery + // As soon as the realized move is computed, pin the chain/horizon to that period right + // away — otherwise the catalogue/leg editor below silently keep pricing off today's live + // chain until a checkpoint is scrubbed to. spot_shock_pct/iv_level_shift here are purely + // informational from this point on (SuggestedProfileCard's Greek-direction reading) — + // once checkpoint_as_of is set below, pricing itself comes from the real smile, not + // these parametric shock numbers. 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, + setScenario(s => ({ + ...s, spot_shock_pct: realized.spot_shock_pct, - iv_level_shift: realized.iv_level_shift ?? scenario.iv_level_shift, + iv_level_shift: realized.iv_level_shift ?? s.iv_level_shift, horizon_days: realized.horizon_days, - as_of: deriveStart, - } - setScenario(derived) + as_of: periodStart, + })) setHorizonDays(realized.horizon_days) - setChainAsOf(deriveStart) - setActiveTemplate(null) - optimizeMutation.mutate({ scenario: derived, constraints, greek_profile: greekProfile }) - } + setChainAsOf(periodStart) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [realized]) + + // The day-scrubber is what actually drives pricing in historical mode: whenever it (or + // the pinned entry date) changes, keep scenario.checkpoint_as_of/horizon_days in sync so + // the shared results pane below reprices off that real day's smile — see + // routers.strategy_builder.ScenarioIn.checkpoint_as_of. Leaving 'build' mode clears it, + // so a stale historical checkpoint never silently leaks into a synthetic scenario price. + useEffect(() => { + if (mode === 'historical' && checkpointDate && chainAsOf) { + const elapsed = Math.round((new Date(checkpointDate).getTime() - new Date(chainAsOf).getTime()) / 86400000) + setScenario(s => ({ ...s, checkpoint_as_of: checkpointDate, horizon_days: elapsed })) + } else if (mode === 'build') { + setScenario(s => (s.checkpoint_as_of ? { ...s, checkpoint_as_of: null } : s)) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, checkpointDate, chainAsOf]) const handleLoadScenario = (s: SavedScenario) => { setSymbol(s.symbol) @@ -1160,6 +1245,7 @@ export default function StrategyBuilder() { symbol, template_name: activeTemplate || 'Manuel', objective: constraints.objective, legs, entry_cost: priced.entry_cost, max_gain: priced.max_gain, max_loss: priced.max_loss, net_pnl_scenario: priced.net_pnl, net_delta: priced.net_delta_now, + source: mode === 'build' ? 'synthetic' : 'historical', }) } @@ -1197,8 +1283,7 @@ 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'], + ['historical', 'Analyse période historique', 'Choisis une vraie période, scrute-la jour par jour, price/optimise contre les cotations Saxo réelles de ce jour-là'], ] as const).map(([key, label, title]) => ( + ))} +
- - { setActiveTemplate(templateName); setLegs(legs) }} /> - + {subTab === 'params' && mode === 'build' && } + {subTab === 'params' && mode === 'historical' && ( + )} {chainLoading &&
Chargement de la chaîne réelle ({debouncedSymbol})…
} - {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. -

- - )} -
- )} + {subTab === 'params' && mode === 'build' && chain && } + {subTab === 'params' && mode === 'build' && chain && } {chain && (
@@ -1427,14 +1445,7 @@ export default function StrategyBuilder() {
)} - {mode === 'replay' && chain && legs.length > 0 && ( - - )} - - {mode === 'build' && chain && ( + {subTab === 'optimizer' && chain && ( <> )} - {mode === 'build' && optimizeMutation.isError && ( + {subTab === 'optimizer' && optimizeMutation.isError && (
{(optimizeMutation.error as any)?.response?.data?.detail ?? "Erreur lors de l'optimisation."}
)} - {mode === 'build' && optimizeMutation.data && optimizeMutation.data.warnings.length > 0 && ( + {subTab === 'optimizer' && optimizeMutation.data && optimizeMutation.data.warnings.length > 0 && (
{optimizeMutation.data.warnings.map((w, i) => (
@@ -1466,18 +1477,25 @@ export default function StrategyBuilder() { ))}
)} - {mode === 'build' && optimizeMutation.data && ( + {subTab === 'optimizer' && optimizeMutation.data && ( )} - {(mode === 'build' || mode === 'derive') && priceMutation.isPending &&
Calcul en cours…
} - {(mode === 'build' || mode === 'derive') && priceMutation.isError && ( + {subTab === 'library' && ( + <> + {mode === 'build' && } + { setActiveTemplate(templateName); setLegs(legs) }} /> + + )} + + {priceMutation.isPending &&
Calcul en cours…
} + {priceMutation.isError && (
Erreur de pricing — vérifiez les jambes sélectionnées.
)} - {(mode === 'build' || mode === 'derive') && priced && ( + {priced && ( <>
@@ -1485,7 +1503,7 @@ export default function StrategyBuilder() {
= 0 ? 'text-white' : 'text-emerald-400')}>{fmtMoney(priced.entry_cost)}
-
P&L net scénario J+{horizonDays}
+
P&L net scénario J+{scenario.horizon_days}
{fmtMoney(priced.net_pnl)}
@@ -1534,9 +1552,11 @@ export default function StrategyBuilder() {
{payoffView === 'curve' && ( <> - +

- 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}. + {mode === 'historical' + ? <>Les deux courbes utilisent la vraie smile de volatilité capturée au jour scruté — seule la date diffère : bleu = à l'échéance de la jambe la plus proche, orange = au jour scruté (J+{scenario.horizon_days}). + : <>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+{scenario.horizon_days}.}

)} @@ -1544,7 +1564,7 @@ export default function StrategyBuilder() { <>

- Même vue de volatilité (celle du scénario) pour chaque palier — seule la date change, du jour même à l'échéance de la jambe la plus proche : ne montre que l'effet de la valeur temps (theta), pas un changement de vue de vol. + {mode === 'historical' ? 'Vraie smile capturée au jour scruté' : 'Même vue de volatilité (celle du scénario)'} pour chaque palier — seule la date change, du jour même à l'échéance de la jambe la plus proche : ne montre que l'effet de la valeur temps (theta), pas un changement de vue de vol.

)} @@ -1552,7 +1572,7 @@ export default function StrategyBuilder() { <>

- Même vue de volatilité (celle du scénario) sur toute la grille — vert/rouge = gain/perte, l'intensité est relative au P&L max de cette grille. + {mode === 'historical' ? 'Vraie smile capturée au jour scruté' : 'Même vue de volatilité (celle du scénario)'} sur toute la grille — vert/rouge = gain/perte, l'intensité est relative au P&L max de cette grille.

)}