feat: strategy builder
This commit is contained in:
@@ -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
|
||||
|
||||
67
backend/services/realized_scenario.py
Normal file
67
backend/services/realized_scenario.py
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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&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">
|
||||
|
||||
Reference in New Issue
Block a user