feat: strategy builder

This commit is contained in:
OpenSquared
2026-07-30 14:48:20 +02:00
parent d85c0348d8
commit efe29cef53
5 changed files with 412 additions and 44 deletions

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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<OptimizeResponse>('/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<RealizedScenario, Error, { symbol: string; start_date: string; end_date: string }>({
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 = () =>

View File

@@ -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,
) => (
<div>
<div className="flex items-center justify-between text-xs text-slate-400 mb-1">
<span>{label}</span>
<span className="text-white font-semibold">{fmt(scenario[key] ?? 0)}</span>
</div>
<input
type="range" min={min} max={max} step={step} value={scenario[key] ?? 0}
onChange={(e) => setScenario({ ...scenario, [key]: parseFloat(e.target.value) })}
className="w-full accent-blue-500"
/>
</div>
)
return (
<div className="card space-y-4">
<div className="card">
<div className="flex items-center gap-3">
<div className="flex-1">
<label className="stat-label block mb-1">Symbole</label>
@@ -179,7 +164,33 @@ function ScenarioPanel({
/>
</div>
</div>
</div>
)
}
// 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,
) => (
<div>
<div className="flex items-center justify-between text-xs text-slate-400 mb-1">
<span>{label}</span>
<span className="text-white font-semibold">{fmt(scenario[key] ?? 0)}</span>
</div>
<input
type="range" min={min} max={max} step={step} value={scenario[key] ?? 0}
onChange={(e) => setScenario({ ...scenario, [key]: parseFloat(e.target.value) })}
className="w-full accent-blue-500"
/>
</div>
)
return (
<div className="card space-y-4">
<div className="stat-label">Scénario manuel "et si..."</div>
<div className="grid grid-cols-2 gap-4">
{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 && (
<>
<div className="text-xs text-slate-500">
Entrée le <span className="text-white font-semibold">{result.entry_date}</span>
{' '}(valeur {fmtMoney(result.entry_value)}) · P&L final{' '}
<span className={clsx('font-bold', pnlColor(result.final_pnl))}>{fmtMoney(result.final_pnl)}</span>
{result.missing_dates.length > 0 && (
<span className="text-slate-600"> · {result.missing_dates.length} jour(s) sans cotation exploitable, exclu(s)</span>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="card-sm">
<div className="stat-label">Valeur d'entrée ({result.entry_date})</div>
<div className="text-lg font-bold text-white">{fmtMoney(result.entry_value)}</div>
</div>
<div className="card-sm">
<div className="stat-label">P&amp;L final</div>
<div className={clsx('text-lg font-bold', pnlColor(result.final_pnl))}>{fmtMoney(result.final_pnl)}</div>
</div>
<div className="card-sm">
<div className="stat-label">Jours exploités</div>
<div className="text-lg font-bold text-white">{result.points.length}</div>
</div>
<div className="card-sm">
<div className="stat-label">Jours exclus (sans cotation réelle)</div>
<div className="text-lg font-bold text-slate-400">{result.missing_dates.length}</div>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-500">
<th className="text-left pb-1 pr-3">Jambe</th>
<th className="text-right pb-1 pr-3">Strike</th>
<th className="text-left pb-1 pr-3">Échéance</th>
<th className="text-right pb-1 pr-3">Mid entrée</th>
<th className="text-right pb-1 pr-3">Bid/Ask entrée</th>
<th className="text-right pb-1 pr-3">IV entrée</th>
<th className="text-right pb-1 pr-3">Δ entrée</th>
<th className="text-right pb-1">Mid sortie</th>
</tr>
</thead>
<tbody>
{result.entry_legs.map((leg, i) => {
const exitLeg = result.exit_legs[i]
return (
<tr key={i} className="border-t border-slate-700/20">
<td className="py-1 pr-3 whitespace-nowrap">
<span className={leg.position === 'long' ? 'text-emerald-400' : 'text-red-400'}>{leg.position === 'long' ? 'Achat' : 'Vente'}</span>
{' '}{leg.quantity > 1 ? `${leg.quantity}x ` : ''}{leg.option_type === 'stock' ? 'Sous-jacent' : (leg.option_type === 'call' ? 'Call' : 'Put')}
</td>
<td className="py-1 pr-3 text-right font-mono">{leg.option_type === 'stock' ? '—' : fmtPrice(leg.strike)}</td>
<td className="py-1 pr-3 text-slate-400 whitespace-nowrap">{leg.option_type === 'stock' ? '—' : leg.expiry_date}</td>
<td className="py-1 pr-3 text-right font-mono">{fmtPrice(leg.mid)}</td>
<td className="py-1 pr-3 text-right font-mono text-slate-400">
{leg.bid != null && leg.ask != null ? `${fmtPrice(leg.bid)} / ${fmtPrice(leg.ask)}` : '—'}
</td>
<td className="py-1 pr-3 text-right font-mono text-slate-400">{leg.iv != null ? `${(leg.iv * 100).toFixed(1)}%` : '—'}</td>
<td className="py-1 pr-3 text-right font-mono text-slate-400">{leg.greeks ? leg.greeks.delta.toFixed(3) : (leg.option_type === 'stock' ? '1.000' : '—')}</td>
<td className="py-1 text-right font-mono">{exitLeg ? fmtPrice(exitLeg.mid) : '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={result.points}>
<defs>
@@ -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<string>('')
// 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<OptimizeConstraints>({
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() {
</div>
)}
<ScenarioPanel symbol={symbol} setSymbol={setSymbol} onCommitSymbol={commitSymbol} horizonDays={horizonDays} setHorizonDays={setHorizonDays}
<SymbolPanel symbol={symbol} setSymbol={setSymbol} onCommitSymbol={commitSymbol} horizonDays={horizonDays} setHorizonDays={setHorizonDays}
scenario={scenario} setScenario={setScenario} watchlistTickers={watchlistTickers} />
<div className="flex gap-1 border-b border-slate-700/40">
{([
['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]) => (
<button
key={key}
onClick={() => setMode(key)}
title={title}
className={clsx('px-4 py-2 text-sm font-semibold border-b-2 -mb-px transition-colors', {
'border-blue-500 text-white': mode === key,
'border-transparent text-slate-500 hover:text-slate-300': mode !== key,
})}
>
{label}
</button>
))}
</div>
{mode === 'build' && (
<>
<ScenarioSlidersPanel scenario={scenario} setScenario={setScenario} />
<ScenarioLibrary symbol={debouncedSymbol} scenario={scenario} onLoad={handleLoadScenario} />
<SavedStrategiesLibrary symbol={debouncedSymbol} onLoad={(legs, templateName) => { setActiveTemplate(templateName); setLegs(legs) }} />
</>
)}
{chainLoading && <div className="card-sm text-xs text-slate-500">Chargement de la chaîne réelle ({debouncedSymbol})</div>}
{chain && <ScenarioGrid chain={chain} spot={chain.spot} scenario={scenario} setScenario={setScenario} />}
{chain && <VolSurfaceHeatmap chain={chain} spot={chain.spot} />}
{mode === 'build' && chain && <ScenarioGrid chain={chain} spot={chain.spot} scenario={scenario} setScenario={setScenario} />}
{mode === 'build' && chain && <VolSurfaceHeatmap chain={chain} spot={chain.spot} />}
{mode === 'derive' && (
<div className="card space-y-3">
<div className="stat-label">Scénario dérivé d'un historique réel</div>
<p className="text-[11px] text-slate-500">
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.
</p>
{!deriveBounds && <div className="text-xs text-slate-600">Aucun historique Saxo pour ce symbole.</div>}
{deriveBounds && (
<>
<div className="flex items-end gap-3 flex-wrap">
<div>
<label className="text-xs text-slate-400 block mb-1">Du</label>
<input
type="date" value={deriveStart} min={deriveBounds.first_date.slice(0, 10)} max={deriveBounds.last_date.slice(0, 10)}
onChange={(e) => { setDeriveStart(e.target.value); resetRealized() }}
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white"
/>
</div>
<div>
<label className="text-xs text-slate-400 block mb-1">Au</label>
<input
type="date" value={deriveEnd} min={deriveBounds.first_date.slice(0, 10)} max={deriveBounds.last_date.slice(0, 10)}
onChange={(e) => { setDeriveEnd(e.target.value); resetRealized() }}
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white"
/>
</div>
<button
onClick={() => deriveStart && deriveEnd && computeRealized({ symbol: debouncedSymbol, start_date: deriveStart, end_date: deriveEnd })}
disabled={realizedPending || !deriveStart || !deriveEnd}
className="flex items-center gap-1.5 text-xs bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-3 py-1.5 rounded font-semibold"
>
<RefreshCw className={clsx('w-3.5 h-3.5', realizedPending && 'animate-spin')} />
{realizedPending ? 'Calcul' : 'Calculer le mouvement réalisé'}
</button>
</div>
{realizedError && (
<div className="text-xs text-red-300">{(realizedError as any)?.response?.data?.detail ?? 'Erreur de calcul.'}</div>
)}
{realized && (
<div className="space-y-3">
<div className="flex flex-wrap gap-4 text-xs bg-dark-700/40 border border-slate-700/40 rounded px-3 py-2">
<span className="text-slate-400">
Spot : <span className="text-white font-semibold">{fmtPrice(realized.spot_a)} → {fmtPrice(realized.spot_b)}</span>
{' '}(<span className={realized.spot_shock_pct >= 0 ? 'text-emerald-400' : 'text-red-400'}>{realized.spot_shock_pct >= 0 ? '+' : ''}{realized.spot_shock_pct.toFixed(2)}%</span>)
</span>
{realized.iv_a != null && realized.iv_b != null ? (
<span className="text-slate-400">
IV ATM : <span className="text-white font-semibold">{(realized.iv_a * 100).toFixed(1)}% → {(realized.iv_b * 100).toFixed(1)}%</span>
{' '}(<span className={((realized.iv_level_shift ?? 0) >= 0) ? 'text-orange-400' : 'text-blue-400'}>{(realized.iv_level_shift ?? 0) >= 0 ? '+' : ''}{((realized.iv_level_shift ?? 0) * 100).toFixed(1)}pts</span>)
</span>
) : (
<span className="text-slate-600">IV ATM indisponible à l'une des deux dates</span>
)}
<span className="text-slate-400">Sur <span className="text-white font-semibold">{realized.horizon_days}j</span></span>
</div>
<OptimizerPanel constraints={constraints} setConstraints={setConstraints} onRun={runDeriveOptimize} isRunning={optimizeMutation.isPending} />
</div>
)}
</>
)}
{optimizeMutation.isError && (
<div className="px-4 py-3 rounded border border-red-700/40 bg-red-900/10 text-xs text-red-300">
{(optimizeMutation.error as any)?.response?.data?.detail ?? "Erreur lors de l'optimisation."}
</div>
)}
{optimizeMutation.data && optimizeMutation.data.warnings.length > 0 && (
<div className="space-y-1.5">
{optimizeMutation.data.warnings.map((w, i) => (
<div key={i} className="flex items-start gap-2 px-3 py-2 rounded border border-amber-700/40 bg-amber-900/10 text-xs text-amber-300">
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
<span>{w}</span>
</div>
))}
</div>
)}
{optimizeMutation.data && (
<>
<ResultsTable results={optimizeMutation.data.candidates} onSelect={handleSelectCandidate} />
<p className="text-[11px] text-slate-500">
Une jambe sélectionnée ci-dessus alimente l'éditeur de jambes plus bas — passe ensuite à l'onglet <strong>Tester (Replay)</strong> pour voir comment cette structure se serait réellement comportée sur cette même fenêtre.
</p>
</>
)}
</div>
)}
{chain && (
<div className="card space-y-3">
@@ -1128,14 +1347,14 @@ export default function StrategyBuilder() {
</div>
)}
{chain && legs.length > 0 && (
{mode === 'replay' && chain && legs.length > 0 && (
<ReplayCard
symbol={debouncedSymbol} legs={legs} contractSize={scenario.contract_size ?? 100_000}
chainAsOf={chainAsOf} onUseAsChainAsOf={setChainAsOf}
/>
)}
{chain && (
{mode === 'build' && chain && (
<>
<SuggestedProfileCard
scenario={scenario} enabled={!!chain}
@@ -1152,12 +1371,12 @@ export default function StrategyBuilder() {
</>
)}
{optimizeMutation.isError && (
{mode === 'build' && optimizeMutation.isError && (
<div className="px-4 py-3 rounded border border-red-700/40 bg-red-900/10 text-xs text-red-300">
{(optimizeMutation.error as any)?.response?.data?.detail ?? "Erreur lors de l'optimisation."}
</div>
)}
{optimizeMutation.data && optimizeMutation.data.warnings.length > 0 && (
{mode === 'build' && optimizeMutation.data && optimizeMutation.data.warnings.length > 0 && (
<div className="space-y-1.5">
{optimizeMutation.data.warnings.map((w, i) => (
<div key={i} className="flex items-start gap-2 px-3 py-2 rounded border border-amber-700/40 bg-amber-900/10 text-xs text-amber-300">
@@ -1167,18 +1386,18 @@ export default function StrategyBuilder() {
))}
</div>
)}
{optimizeMutation.data && (
{mode === 'build' && optimizeMutation.data && (
<ResultsTable results={optimizeMutation.data.candidates} onSelect={handleSelectCandidate} />
)}
{priceMutation.isPending && <div className="card-sm text-xs text-slate-500">Calcul en cours</div>}
{priceMutation.isError && (
{mode === 'build' && priceMutation.isPending && <div className="card-sm text-xs text-slate-500">Calcul en cours</div>}
{mode === 'build' && priceMutation.isError && (
<div className="px-4 py-3 rounded border border-red-700/40 bg-red-900/10 text-xs text-red-300">
Erreur de pricing vérifiez les jambes sélectionnées.
</div>
)}
{priced && (
{mode === 'build' && priced && (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="card-sm">