feat: strategy builder
This commit is contained in:
@@ -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:
|
||||
|
||||
88
backend/services/strategy_replay.py
Normal file
88
backend/services/strategy_replay.py
Normal file
@@ -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,
|
||||
}
|
||||
@@ -1856,6 +1856,19 @@ export const useOptimizeStrategy = () =>
|
||||
api.post<OptimizeResponse>('/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<ReplayResult, Error, { symbol: string; legs: StrategyLeg[]; start_date: string; end_date: string; contract_size?: number }>({
|
||||
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).
|
||||
|
||||
@@ -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 (
|
||||
<div className="card space-y-3">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="stat-label flex items-center gap-2">
|
||||
<History className="w-3.5 h-3.5" /> Rejouer sur l'historique Saxo réel (pas un scénario)
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-600">
|
||||
Données disponibles : {bounds.first_date.slice(0, 10)} → {bounds.last_date.slice(0, 10)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
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.
|
||||
</p>
|
||||
<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={startDate} min={bounds.first_date.slice(0, 10)} max={bounds.last_date.slice(0, 10)}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
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={endDate} min={bounds.first_date.slice(0, 10)} max={bounds.last_date.slice(0, 10)}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={run}
|
||||
disabled={isPending || legs.length === 0}
|
||||
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', isPending && 'animate-spin')} />
|
||||
{isPending ? 'Replay en cours…' : 'Lancer le replay'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-300">
|
||||
{(error as any)?.response?.data?.detail ?? 'Erreur pendant le replay.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={result.points}>
|
||||
<defs>
|
||||
<linearGradient id="replay-grad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={result.final_pnl >= 0 ? '#10b981' : '#ef4444'} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={result.final_pnl >= 0 ? '#10b981' : '#ef4444'} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e2d4d" />
|
||||
<XAxis dataKey="date" tick={{ fill: '#475569', fontSize: 9 }} />
|
||||
<YAxis tick={{ fill: '#475569', fontSize: 9 }} tickLine={false} axisLine={false} tickFormatter={(v) => fmtMoney(v) ?? ''} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#0f1623', border: '1px solid #1e2d4d', fontSize: 11 }}
|
||||
formatter={(v: number, name: string) => [name === 'pnl' ? fmtMoney(v) : v, name === 'pnl' ? 'P&L' : 'Spot']}
|
||||
/>
|
||||
<ReferenceLine y={0} stroke="#475569" strokeDasharray="4 4" />
|
||||
<Area type="monotone" dataKey="pnl" stroke={result.final_pnl >= 0 ? '#10b981' : '#ef4444'} fill="url(#replay-grad)" strokeWidth={2} dot={{ r: 2 }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GreekProfilePanel({
|
||||
profile, setProfile,
|
||||
}: { profile: GreekProfile; setProfile: (v: GreekProfile) => void }) {
|
||||
@@ -929,6 +1037,10 @@ export default function StrategyBuilder() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chain && legs.length > 0 && (
|
||||
<ReplayCard symbol={debouncedSymbol} legs={legs} contractSize={scenario.contract_size ?? 100_000} />
|
||||
)}
|
||||
|
||||
{chain && (
|
||||
<>
|
||||
<SuggestedProfileCard
|
||||
|
||||
Reference in New Issue
Block a user