feat: strategy builder
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user