feat: strategy builder

This commit is contained in:
OpenSquared
2026-07-30 11:30:59 +02:00
parent 15528e0c98
commit 1c4d8013c4
4 changed files with 240 additions and 3 deletions

View File

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

View File

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