feat: strategy builder

This commit is contained in:
OpenSquared
2026-07-30 13:28:03 +02:00
parent 1c4d8013c4
commit 81165581d7
8 changed files with 361 additions and 69 deletions

View File

@@ -265,7 +265,7 @@ export const useBacktestSymbols = () =>
staleTime: 60_000,
})
export type BacktestLegPreset = { option_type: 'call' | 'put'; position: 'long' | 'short'; quantity: number; strike_pct: number; expiry: 'near' | 'far' }
export type BacktestLegPreset = { option_type: 'call' | 'put' | 'stock'; position: 'long' | 'short'; quantity: number; strike_pct: number; expiry: 'near' | 'far' }
export type BacktestStrategyInfo = { key: string; label: string; n_legs: number; default_legs: BacktestLegPreset[] }
export const useBacktestStrategies = () =>
useQuery<BacktestStrategyInfo[]>({
@@ -1763,7 +1763,7 @@ export type StrategyLeg = {
expiry_date: string
days_to_expiry: number
strike: number
option_type: 'call' | 'put'
option_type: 'call' | 'put' | 'stock'
position: 'long' | 'short'
quantity: number
}
@@ -1840,6 +1840,22 @@ export const usePriceStrategy = () =>
api.post<PriceCombo>('/strategy-builder/price', body).then(r => r.data),
})
// The 28-strategy catalog (services.backtest_strategies.STRATEGIES) built from the REAL
// current chain — real strikes/expiries, ready to price or replay as-is. A strategy that
// can't be built right now (e.g. calendar/diagonal with only one real expiry available)
// is simply absent from the response.
export type StrategyPreset = { key: string; label: string; n_legs: number; legs: StrategyLeg[] }
export const usePresets = (symbol: string, horizonDays: number, enabled: boolean, dteMin?: number | null, dteMax?: number | null) =>
useQuery<StrategyPreset[]>({
queryKey: ['strategy-builder-presets', symbol, horizonDays, dteMin, dteMax],
queryFn: () => api.get('/strategy-builder/presets', {
params: { symbol, horizon_days: horizonDays, dte_min: dteMin ?? undefined, dte_max: dteMax ?? undefined },
}).then(r => r.data),
enabled: enabled && !!symbol,
staleTime: 30_000,
retry: 1,
})
export type OptimizeConstraints = {
max_legs: number
delta_threshold: number | null

View File

@@ -14,7 +14,12 @@ type EditableLeg = BacktestLegPreset
function legsSummary(legs: LegRow[] | undefined): string {
if (!legs || !legs.length) return '—'
return legs
.map(l => `${l.position === 'long' ? '+' : ''}${l.quantity > 1 ? l.quantity + 'x' : ''}${l.option_type === 'call' ? 'C' : 'P'}${l.strike}`)
.map(l => {
const sign = l.position === 'long' ? '+' : ''
const qty = l.quantity > 1 ? l.quantity + 'x' : ''
if (l.option_type === 'stock') return `${sign}${qty}Spot`
return `${sign}${qty}${l.option_type === 'call' ? 'C' : 'P'}${l.strike}`
})
.join(' / ')
}
@@ -190,11 +195,12 @@ export default function Backtest() {
<div className="flex items-center gap-1">
<select
value={leg.option_type}
onChange={e => updateLeg(idx, { option_type: e.target.value as 'call' | 'put' })}
onChange={e => updateLeg(idx, { option_type: e.target.value as 'call' | 'put' | 'stock' })}
className="flex-1 bg-dark-700 border border-slate-700 rounded px-1 py-1 text-[11px] text-white"
>
<option value="call">Call</option>
<option value="put">Put</option>
<option value="stock">Sous-jacent</option>
</select>
<select
value={leg.position}
@@ -219,22 +225,28 @@ export default function Backtest() {
title="Quantité"
className="w-12 bg-dark-700 border border-slate-700 rounded px-1 py-1 text-[11px] text-white"
/>
<input
type="number" step={0.5} value={Math.round(leg.strike_pct * 1000) / 10}
onChange={e => updateLeg(idx, { strike_pct: (parseFloat(e.target.value) || 100) / 100 })}
title="Strike en % du spot"
className="flex-1 bg-dark-700 border border-slate-700 rounded px-1 py-1 text-[11px] text-white"
/>
<span className="text-[10px] text-slate-600 shrink-0">% spot</span>
<select
value={leg.expiry}
onChange={e => updateLeg(idx, { expiry: e.target.value as 'near' | 'far' })}
title="Échéance"
className="bg-dark-700 border border-slate-700 rounded px-1 py-1 text-[11px] text-white shrink-0"
>
<option value="near">Proche</option>
<option value="far">Lointaine</option>
</select>
{leg.option_type === 'stock' ? (
<span className="flex-1 text-[10px] text-slate-600 italic px-1">position sur le sous-jacent pas de strike/échéance</span>
) : (
<>
<input
type="number" step={0.5} value={Math.round(leg.strike_pct * 1000) / 10}
onChange={e => updateLeg(idx, { strike_pct: (parseFloat(e.target.value) || 100) / 100 })}
title="Strike en % du spot"
className="flex-1 bg-dark-700 border border-slate-700 rounded px-1 py-1 text-[11px] text-white"
/>
<span className="text-[10px] text-slate-600 shrink-0">% spot</span>
<select
value={leg.expiry}
onChange={e => updateLeg(idx, { expiry: e.target.value as 'near' | 'far' })}
title="Échéance"
className="bg-dark-700 border border-slate-700 rounded px-1 py-1 text-[11px] text-white shrink-0"
>
<option value="near">Proche</option>
<option value="far">Lointaine</option>
</select>
</>
)}
</div>
</div>
))}

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,
useOptionChainSlice, usePriceStrategy, useOptimizeStrategy, useSuggestedProfile, useReplayStrategy, usePresets,
useScenarios, useSaveScenario, useDeleteScenario,
useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy,
useSaxoSymbols, useIvForTrade,
@@ -367,38 +367,47 @@ function LegRow({
}) {
const expiry = chain?.expiries.find((e: any) => e.expiry_date === leg.expiry_date)
const rows = expiry ? (leg.option_type === 'call' ? expiry.calls : expiry.puts) : []
const isStock = leg.option_type === 'stock'
return (
<div className="grid grid-cols-12 gap-2 items-center text-xs">
<select
className="col-span-3 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value={leg.expiry_date}
onChange={(e) => {
const exp = chain.expiries.find((x: any) => x.expiry_date === e.target.value)
onChange({ ...leg, expiry_date: e.target.value, days_to_expiry: exp?.days_to_expiry ?? leg.days_to_expiry })
}}
>
{chain?.expiries.map((e: any) => (
<option key={e.expiry_date} value={e.expiry_date}>{e.expiry_date} ({e.days_to_expiry}j)</option>
))}
</select>
<select
className="col-span-2 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value={leg.option_type}
onChange={(e) => onChange({ ...leg, option_type: e.target.value as 'call' | 'put' })}
>
<option value="call">Call</option>
<option value="put">Put</option>
</select>
<select
className="col-span-3 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value={leg.strike}
onChange={(e) => onChange({ ...leg, strike: parseFloat(e.target.value) })}
>
{rows.map((r: any) => (
<option key={r.strike} value={r.strike}>{fmtPrice(r.strike)} (bid {fmtPrice(r.bid)} / ask {fmtPrice(r.ask)})</option>
))}
</select>
{isStock ? (
<div className="col-span-8 bg-dark-700/40 border border-slate-700/30 rounded px-2 py-1.5 text-slate-400 italic">
Sous-jacent (spot{chain?.spot != null ? ` ${fmtPrice(chain.spot)}` : ''}) — position sur le sous-jacent lui-même, pas une option
</div>
) : (
<>
<select
className="col-span-3 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value={leg.expiry_date}
onChange={(e) => {
const exp = chain.expiries.find((x: any) => x.expiry_date === e.target.value)
onChange({ ...leg, expiry_date: e.target.value, days_to_expiry: exp?.days_to_expiry ?? leg.days_to_expiry })
}}
>
{chain?.expiries.map((e: any) => (
<option key={e.expiry_date} value={e.expiry_date}>{e.expiry_date} ({e.days_to_expiry}j)</option>
))}
</select>
<select
className="col-span-2 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value={leg.option_type}
onChange={(e) => onChange({ ...leg, option_type: e.target.value as 'call' | 'put' })}
>
<option value="call">Call</option>
<option value="put">Put</option>
</select>
<select
className="col-span-3 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value={leg.strike}
onChange={(e) => onChange({ ...leg, strike: parseFloat(e.target.value) })}
>
{rows.map((r: any) => (
<option key={r.strike} value={r.strike}>{fmtPrice(r.strike)} (bid {fmtPrice(r.bid)} / ask {fmtPrice(r.ask)})</option>
))}
</select>
</>
)}
<select
className="col-span-2 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value={leg.position}
@@ -873,6 +882,10 @@ export default function StrategyBuilder() {
const { data: chain, isLoading: chainLoading, isError: chainError, error: chainErrorObj, refetch: refetchChain, isFetching } =
useOptionChainSlice(debouncedSymbol, horizonDays, 3, true, scenario.dte_min, scenario.dte_max)
const { data: ivForTrade } = useIvForTrade(debouncedSymbol)
const { data: presets } = usePresets(debouncedSymbol, horizonDays, !!chain, scenario.dte_min, scenario.dte_max)
const [presetLegCountFilter, setPresetLegCountFilter] = useState<number | null>(null)
const [activePreset, setActivePreset] = useState<string | null>(null)
const filteredPresets = (presets ?? []).filter(p => presetLegCountFilter === null || p.n_legs === presetLegCountFilter)
useEffect(() => {
setScenario(s => ({ ...s, symbol: debouncedSymbol, horizon_days: horizonDays }))
@@ -984,6 +997,52 @@ export default function StrategyBuilder() {
{chain && <ScenarioGrid chain={chain} spot={chain.spot} scenario={scenario} setScenario={setScenario} />}
{chain && <VolSurfaceHeatmap chain={chain} spot={chain.spot} />}
{chain && (
<div className="card space-y-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="stat-label">Catalogue de stratégies (30 fiches — hedge funds)</div>
<div className="flex gap-1">
{[null, 1, 2, 3, 4].map(n => (
<button
key={n ?? 'all'}
onClick={() => setPresetLegCountFilter(n)}
className={clsx('px-2 py-0.5 rounded text-xs border', {
'bg-blue-600 border-blue-500 text-white': presetLegCountFilter === n,
'border-slate-700 text-slate-500': presetLegCountFilter !== n,
})}
>
{n ?? 'Toutes'}
</button>
))}
</div>
</div>
<p className="text-[11px] text-slate-500">
Choisis un préréglage pour préremplir les jambes ci-dessous avec les vrais strikes de la chain actuelle — ajustable ensuite librement (jambes, expiries, quantités).
The Wheel, Dispersion Trading, Gamma Scalping et Convertible Arbitrage ne sont pas listés : ce sont des stratégies dynamiques (rehedging continu) ou multi-instruments (plusieurs sous-jacents, obligation convertible) — pas une position statique qu'on peut poser et rejouer telle quelle.
</p>
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto">
{filteredPresets.map(p => (
<button
key={p.key}
onClick={() => { setLegs(p.legs); setActivePreset(p.key) }}
className={clsx('flex items-center gap-1.5 text-xs px-2 py-1 rounded border transition-all', {
'bg-blue-600/20 border-blue-500/60 text-blue-300': activePreset === p.key,
'border-slate-700/40 text-slate-400 hover:border-slate-600': activePreset !== p.key,
})}
>
{p.label}
<span className={clsx('text-[10px] px-1 rounded', activePreset === p.key ? 'bg-blue-500/20 text-blue-300' : 'bg-slate-700/40 text-slate-500')}>
{p.n_legs}j
</span>
</button>
))}
{presets && filteredPresets.length === 0 && (
<div className="text-xs text-slate-600">Aucun préréglage disponible pour ce filtre en ce moment (échéances insuffisantes dans la chain actuelle).</div>
)}
</div>
</div>
)}
{chain && (
<div className="card space-y-3">
<div className="flex items-center justify-between">