diff --git a/backend/routers/strategy_builder.py b/backend/routers/strategy_builder.py index 088d153..f024010 100644 --- a/backend/routers/strategy_builder.py +++ b/backend/routers/strategy_builder.py @@ -179,6 +179,30 @@ def suggested_profile(scenario: ScenarioIn): return infer_natural_greek_profile(scenario.spot_shock_pct, scenario.iv_level_shift, scenario.horizon_days) +class ReplayRequest(BaseModel): + symbol: str + legs: List[LegIn] + start_date: str + end_date: str + contract_size: float = DEFAULT_CONTRACT_SIZE + + +@router.post("/replay") +def replay(req: ReplayRequest): + """Day-by-day mark-to-market of these exact legs against REAL accumulated Saxo + history between two dates — not a scenario, a replay of what actually happened. + See services.strategy_replay for why it's a distinct thing from /price's scenario + pricing (which prices a hypothetical spot/IV shock, not real historical quotes).""" + from services.strategy_replay import replay_position + try: + return replay_position( + req.symbol, [leg.dict() for leg in req.legs], req.start_date, req.end_date, + contract_size=req.contract_size, + ) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + @router.post("/optimize") def optimize(req: OptimizeRequest): if req.constraints.max_legs > 4: diff --git a/backend/services/strategy_replay.py b/backend/services/strategy_replay.py new file mode 100644 index 0000000..493ee1e --- /dev/null +++ b/backend/services/strategy_replay.py @@ -0,0 +1,88 @@ +""" +Day-by-day replay of a fixed set of REAL legs (exact expiry/strike from a real Saxo chain, +built the normal Strategy Builder way) against the ACTUALLY accumulated Saxo history — +not a hypothetical scenario, a mark-to-market of what really happened between two dates +that are both within services.option_chain's accumulated snapshot depth (currently up to +~120 days — see services.saxo_client.snapshot_options_chain's max_days). + +This answers a different question than Strategy Builder's own scenario pricing ("what +would this be worth if spot moved X% and IV moved Y%") — here nothing is guessed, every +day's mark comes from a real quote captured that day, or the position isn't valued for a +day where any leg has no real quote (skipped, not synthesized — a replay should show what +was actually knowable, not fill gaps with a theoretical price). +""" +from datetime import date, timedelta +from typing import Any, Dict, List + + +def _daterange(start_date: str, end_date: str) -> List[str]: + d0 = date.fromisoformat(start_date[:10]) + d1 = date.fromisoformat(end_date[:10]) + return [(d0 + timedelta(days=i)).isoformat() for i in range((d1 - d0).days + 1)] + + +def replay_position( + symbol: str, legs: List[Dict[str, Any]], start_date: str, end_date: str, + contract_size: float = 100_000, +) -> Dict[str, Any]: + from services.database import get_saxo_option_symbol_for_ticker + from services.option_chain import get_chain_slice, find_quote + + if end_date <= start_date: + raise ValueError("La date de fin doit être postérieure à la date de départ.") + if not legs: + raise ValueError("Aucune jambe à rejouer.") + + saxo_symbol = get_saxo_option_symbol_for_ticker(symbol) or symbol.upper() + + signed_qty = [(1 if leg["position"] == "long" else -1) * leg.get("quantity", 1) for leg in legs] + avg_days = sum(leg.get("days_to_expiry", 30) for leg in legs) / len(legs) + + points: List[Dict[str, Any]] = [] + entry_value = None + missing_dates: List[str] = [] + + for d in _daterange(start_date, end_date): + try: + # n_expiries wide enough to virtually guarantee every expiry the legs use is + # present regardless of how target_days ranks them from this day's viewpoint — + # accumulated history rarely holds more than ~20 distinct expiries per symbol. + chain = get_chain_slice(saxo_symbol, target_days=int(avg_days), n_expiries=25, dte_min=0, dte_max=400, as_of=d) + except ValueError: + missing_dates.append(d) + continue + + value = 0.0 # dollar value of the whole position, contract_size already applied + complete = True + for leg, sq in zip(legs, signed_qty): + 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 + if not complete: + missing_dates.append(d) + continue + + if entry_value is None: + entry_value = value + points.append({ + "date": d, "spot": chain.get("spot"), + "position_value": round(value, 2), + "pnl": round(value - entry_value, 2), + }) + + if not points: + raise ValueError( + f"Aucune cotation réelle exploitable pour ces jambes entre {start_date} et {end_date} " + "— vérifiez que ces strikes/échéances exactes ont bien été cotés par Saxo sur cette période." + ) + + return { + "symbol": symbol, "saxo_symbol": saxo_symbol, + "start_date": start_date, "end_date": end_date, + "entry_date": points[0]["date"], "entry_value": round(entry_value, 2), + "final_pnl": points[-1]["pnl"], + "points": points, + "missing_dates": missing_dates, + } diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 7758bab..26971be 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1856,6 +1856,19 @@ export const useOptimizeStrategy = () => api.post('/strategy-builder/optimize', body).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 ReplayResult = { + symbol: string; saxo_symbol: string; start_date: string; end_date: string + entry_date: string; entry_value: number; final_pnl: number + points: ReplayPoint[]; missing_dates: string[] +} +export const useReplayStrategy = () => + useMutation({ + mutationFn: (body) => api.post('/strategy-builder/replay', body).then(r => r.data), + }) + // Mode 1 of the scenario/profile/constraints split — what Greek behavior the scenario // alone already implies, before the user sets any explicit target (project memory: // Strategy Builder Greeks plan, Phase 4). diff --git a/frontend/src/pages/StrategyBuilder.tsx b/frontend/src/pages/StrategyBuilder.tsx index 58fd811..55eeaa1 100644 --- a/frontend/src/pages/StrategyBuilder.tsx +++ b/frontend/src/pages/StrategyBuilder.tsx @@ -1,11 +1,11 @@ import { useEffect, useMemo, useState } from 'react' import { - LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer, + LineChart, Line, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer, } from 'recharts' -import { Layers, Plus, Trash2, RefreshCw, AlertTriangle, Search, Save, FolderOpen, X } from 'lucide-react' +import { Layers, Plus, Trash2, RefreshCw, AlertTriangle, Search, Save, FolderOpen, X, History } from 'lucide-react' import clsx from 'clsx' import { - useOptionChainSlice, usePriceStrategy, useOptimizeStrategy, useSuggestedProfile, + useOptionChainSlice, usePriceStrategy, useOptimizeStrategy, useSuggestedProfile, useReplayStrategy, useScenarios, useSaveScenario, useDeleteScenario, useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy, useSaxoSymbols, useIvForTrade, @@ -552,6 +552,114 @@ function SuggestedProfileCard({ ) } +function ReplayCard({ symbol, legs, contractSize }: { symbol: string; legs: StrategyLeg[]; contractSize: number }) { + 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('') + + // 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)) + 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)) + } + }, [bounds]) // eslint-disable-line react-hooks/exhaustive-deps + + if (!bounds) return null + + const run = () => { + if (legs.length === 0 || !startDate || !endDate) return + runReplay({ symbol, legs, start_date: startDate, end_date: endDate, contract_size: contractSize }) + } + + return ( +
+
+
+ Rejouer sur l'historique Saxo réel (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. +

+
+
+ + setStartDate(e.target.value)} + className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" + /> +
+
+ + setEndDate(e.target.value)} + 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.'} +
+ )} + + {result && ( + <> +
+ Entrée le {result.entry_date} + {' '}(valeur {fmtMoney(result.entry_value)}) · P&L final{' '} + {fmtMoney(result.final_pnl)} + {result.missing_dates.length > 0 && ( + · {result.missing_dates.length} jour(s) sans cotation exploitable, exclu(s) + )} +
+ + + + + = 0 ? '#10b981' : '#ef4444'} stopOpacity={0.3} /> + = 0 ? '#10b981' : '#ef4444'} stopOpacity={0} /> + + + + + fmtMoney(v) ?? ''} /> + [name === 'pnl' ? fmtMoney(v) : v, name === 'pnl' ? 'P&L' : 'Spot']} + /> + + = 0 ? '#10b981' : '#ef4444'} fill="url(#replay-grad)" strokeWidth={2} dot={{ r: 2 }} /> + + + + )} +
+ ) +} + function GreekProfilePanel({ profile, setProfile, }: { profile: GreekProfile; setProfile: (v: GreekProfile) => void }) { @@ -929,6 +1037,10 @@ export default function StrategyBuilder() { )} + {chain && legs.length > 0 && ( + + )} + {chain && ( <>