feat: strategy builder
This commit is contained in:
@@ -139,9 +139,10 @@ def chain(
|
|||||||
n_expiries: int = Query(3),
|
n_expiries: int = Query(3),
|
||||||
dte_min: Optional[int] = Query(None),
|
dte_min: Optional[int] = Query(None),
|
||||||
dte_max: Optional[int] = Query(None),
|
dte_max: Optional[int] = Query(None),
|
||||||
|
as_of: Optional[str] = Query(None, description="Reconstruct the chain as it stood at/before this date instead of now — e.g. to build legs against the same chain a past Replay window will walk, rather than today's."),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
return get_chain_slice(symbol, horizon_days, n_expiries, dte_min=dte_min, dte_max=dte_max)
|
return get_chain_slice(symbol, horizon_days, n_expiries, dte_min=dte_min, dte_max=dte_max, as_of=as_of)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
@@ -152,15 +153,18 @@ def presets(
|
|||||||
horizon_days: int = Query(8),
|
horizon_days: int = Query(8),
|
||||||
dte_min: Optional[int] = Query(None),
|
dte_min: Optional[int] = Query(None),
|
||||||
dte_max: Optional[int] = Query(None),
|
dte_max: Optional[int] = Query(None),
|
||||||
|
as_of: Optional[str] = Query(None),
|
||||||
):
|
):
|
||||||
"""The full strategy catalog (services.backtest_strategies.STRATEGIES) built from the
|
"""The full strategy catalog (services.backtest_strategies.STRATEGIES) built from the
|
||||||
REAL current chain instead of Backtest's synthetic grid — so a preset click here seeds
|
REAL chain instead of Backtest's synthetic grid — so a preset click here seeds the leg
|
||||||
the leg editor with actually-quoted strikes/expiries, ready to price or replay as-is.
|
editor with actually-quoted strikes/expiries, ready to price or replay as-is.
|
||||||
n_expiries=5 (vs. Strategy Builder's own default of 3) so calendar/diagonal presets,
|
n_expiries=20 (vs. Strategy Builder's own default of 3) so calendar/diagonal presets,
|
||||||
which need two distinct expiries, reliably have a second one to draw from."""
|
which need two distinct expiries, reliably have a second one to draw from, and so a
|
||||||
|
wide dte_min/dte_max window (e.g. hunting for a ~30d expiry) isn't silently narrowed
|
||||||
|
back down to whatever's nearest horizon_days."""
|
||||||
from services.backtest_strategies import STRATEGIES, build_legs
|
from services.backtest_strategies import STRATEGIES, build_legs
|
||||||
try:
|
try:
|
||||||
chain_slice = get_chain_slice(symbol, horizon_days, 5, dte_min=dte_min, dte_max=dte_max)
|
chain_slice = get_chain_slice(symbol, horizon_days, 20, dte_min=dte_min, dte_max=dte_max, as_of=as_of)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|||||||
@@ -1822,12 +1822,12 @@ export const DEFAULT_GREEK_PROFILE: GreekProfile = {
|
|||||||
|
|
||||||
export const useOptionChainSlice = (
|
export const useOptionChainSlice = (
|
||||||
symbol: string, horizonDays: number, nExpiries = 3, enabled = true,
|
symbol: string, horizonDays: number, nExpiries = 3, enabled = true,
|
||||||
dteMin?: number | null, dteMax?: number | null,
|
dteMin?: number | null, dteMax?: number | null, asOf?: string | null,
|
||||||
) =>
|
) =>
|
||||||
useQuery<ChainSlice>({
|
useQuery<ChainSlice>({
|
||||||
queryKey: ['strategy-builder-chain', symbol, horizonDays, nExpiries, dteMin, dteMax],
|
queryKey: ['strategy-builder-chain', symbol, horizonDays, nExpiries, dteMin, dteMax, asOf],
|
||||||
queryFn: () => api.get('/strategy-builder/chain', {
|
queryFn: () => api.get('/strategy-builder/chain', {
|
||||||
params: { symbol, horizon_days: horizonDays, n_expiries: nExpiries, dte_min: dteMin ?? undefined, dte_max: dteMax ?? undefined },
|
params: { symbol, horizon_days: horizonDays, n_expiries: nExpiries, dte_min: dteMin ?? undefined, dte_max: dteMax ?? undefined, as_of: asOf || undefined },
|
||||||
}).then(r => r.data),
|
}).then(r => r.data),
|
||||||
enabled: enabled && !!symbol,
|
enabled: enabled && !!symbol,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
@@ -1845,11 +1845,14 @@ export const usePriceStrategy = () =>
|
|||||||
// can't be built right now (e.g. calendar/diagonal with only one real expiry available)
|
// can't be built right now (e.g. calendar/diagonal with only one real expiry available)
|
||||||
// is simply absent from the response.
|
// is simply absent from the response.
|
||||||
export type StrategyPreset = { key: string; label: string; n_legs: number; legs: StrategyLeg[] }
|
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) =>
|
export const usePresets = (
|
||||||
|
symbol: string, horizonDays: number, enabled: boolean,
|
||||||
|
dteMin?: number | null, dteMax?: number | null, asOf?: string | null,
|
||||||
|
) =>
|
||||||
useQuery<StrategyPreset[]>({
|
useQuery<StrategyPreset[]>({
|
||||||
queryKey: ['strategy-builder-presets', symbol, horizonDays, dteMin, dteMax],
|
queryKey: ['strategy-builder-presets', symbol, horizonDays, dteMin, dteMax, asOf],
|
||||||
queryFn: () => api.get('/strategy-builder/presets', {
|
queryFn: () => api.get('/strategy-builder/presets', {
|
||||||
params: { symbol, horizon_days: horizonDays, dte_min: dteMin ?? undefined, dte_max: dteMax ?? undefined },
|
params: { symbol, horizon_days: horizonDays, dte_min: dteMin ?? undefined, dte_max: dteMax ?? undefined, as_of: asOf || undefined },
|
||||||
}).then(r => r.data),
|
}).then(r => r.data),
|
||||||
enabled: enabled && !!symbol,
|
enabled: enabled && !!symbol,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
|
|||||||
@@ -561,7 +561,12 @@ function SuggestedProfileCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReplayCard({ symbol, legs, contractSize }: { symbol: string; legs: StrategyLeg[]; contractSize: number }) {
|
function ReplayCard({
|
||||||
|
symbol, legs, contractSize, chainAsOf, onUseAsChainAsOf,
|
||||||
|
}: {
|
||||||
|
symbol: string; legs: StrategyLeg[]; contractSize: number
|
||||||
|
chainAsOf: string; onUseAsChainAsOf: (date: string) => void
|
||||||
|
}) {
|
||||||
const { data: saxoSymbols } = useSaxoSymbols()
|
const { data: saxoSymbols } = useSaxoSymbols()
|
||||||
const bounds = saxoSymbols?.find(s => s.symbol.toUpperCase() === symbol.toUpperCase())
|
const bounds = saxoSymbols?.find(s => s.symbol.toUpperCase() === symbol.toUpperCase())
|
||||||
const { mutate: runReplay, data: result, isPending, error } = useReplayStrategy()
|
const { mutate: runReplay, data: result, isPending, error } = useReplayStrategy()
|
||||||
@@ -626,6 +631,14 @@ function ReplayCard({ symbol, legs, contractSize }: { symbol: string; legs: Stra
|
|||||||
<RefreshCw className={clsx('w-3.5 h-3.5', isPending && 'animate-spin')} />
|
<RefreshCw className={clsx('w-3.5 h-3.5', isPending && 'animate-spin')} />
|
||||||
{isPending ? 'Replay en cours…' : 'Lancer le replay'}
|
{isPending ? 'Replay en cours…' : 'Lancer le replay'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => startDate && onUseAsChainAsOf(startDate)}
|
||||||
|
disabled={!startDate || chainAsOf === startDate}
|
||||||
|
title="Reconstruit la chain (et le catalogue de préréglages) telle qu'elle était à la date 'Du' — une échéance choisie aujourd'hui n'existait peut-être pas encore, ou avait des strikes très différents, à cette date passée."
|
||||||
|
className="flex items-center gap-1.5 text-xs border border-slate-700/50 hover:border-blue-500/60 disabled:opacity-40 text-slate-300 px-3 py-1.5 rounded"
|
||||||
|
>
|
||||||
|
Construire les jambes depuis la chain du {startDate || '…'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -866,6 +879,11 @@ export default function StrategyBuilder() {
|
|||||||
const commitSymbol = (v?: string) => setDebouncedSymbol((v ?? symbol).trim())
|
const commitSymbol = (v?: string) => setDebouncedSymbol((v ?? symbol).trim())
|
||||||
|
|
||||||
const [legs, setLegs] = useState<StrategyLeg[]>([])
|
const [legs, setLegs] = useState<StrategyLeg[]>([])
|
||||||
|
// Empty = build against the live chain (now). Set (typically synced from the Replay
|
||||||
|
// card's "Du") to reconstruct the chain as it stood back then — a leg picked against
|
||||||
|
// TODAY's chain may not have existed yet, or may have had a very different strike
|
||||||
|
// ladder, on a date a past Replay window actually starts from.
|
||||||
|
const [chainAsOf, setChainAsOf] = useState<string>('')
|
||||||
const [constraints, setConstraints] = useState<OptimizeConstraints>({
|
const [constraints, setConstraints] = useState<OptimizeConstraints>({
|
||||||
max_legs: 4, delta_threshold: 0.15, max_loss_cap: null, objective: 'net_pnl', top_n: 20,
|
max_legs: 4, delta_threshold: 0.15, max_loss_cap: null, objective: 'net_pnl', top_n: 20,
|
||||||
})
|
})
|
||||||
@@ -879,10 +897,15 @@ export default function StrategyBuilder() {
|
|||||||
const { data: saxoSymbols } = useSaxoSymbols()
|
const { data: saxoSymbols } = useSaxoSymbols()
|
||||||
const watchlistTickers = (saxoSymbols ?? []).map(s => s.symbol).sort()
|
const watchlistTickers = (saxoSymbols ?? []).map(s => s.symbol).sort()
|
||||||
|
|
||||||
|
// A narrowed DTE window means the user is deliberately hunting for something specific
|
||||||
|
// in there (e.g. dte_min/max=1..60 looking for a ~30d expiry) — capping at 3 nearest-
|
||||||
|
// to-horizon would silently hide it again even though it's inside the window. Only
|
||||||
|
// widen when they've actually set a bound, so the default view stays compact.
|
||||||
|
const chainNExpiries = (scenario.dte_min != null || scenario.dte_max != null) ? 20 : 3
|
||||||
const { data: chain, isLoading: chainLoading, isError: chainError, error: chainErrorObj, refetch: refetchChain, isFetching } =
|
const { data: chain, isLoading: chainLoading, isError: chainError, error: chainErrorObj, refetch: refetchChain, isFetching } =
|
||||||
useOptionChainSlice(debouncedSymbol, horizonDays, 3, true, scenario.dte_min, scenario.dte_max)
|
useOptionChainSlice(debouncedSymbol, horizonDays, chainNExpiries, true, scenario.dte_min, scenario.dte_max, chainAsOf)
|
||||||
const { data: ivForTrade } = useIvForTrade(debouncedSymbol)
|
const { data: ivForTrade } = useIvForTrade(debouncedSymbol)
|
||||||
const { data: presets } = usePresets(debouncedSymbol, horizonDays, !!chain, scenario.dte_min, scenario.dte_max)
|
const { data: presets } = usePresets(debouncedSymbol, horizonDays, !!chain, scenario.dte_min, scenario.dte_max, chainAsOf)
|
||||||
const [presetLegCountFilter, setPresetLegCountFilter] = useState<number | null>(null)
|
const [presetLegCountFilter, setPresetLegCountFilter] = useState<number | null>(null)
|
||||||
const [activePreset, setActivePreset] = useState<string | null>(null)
|
const [activePreset, setActivePreset] = useState<string | null>(null)
|
||||||
const filteredPresets = (presets ?? []).filter(p => presetLegCountFilter === null || p.n_legs === presetLegCountFilter)
|
const filteredPresets = (presets ?? []).filter(p => presetLegCountFilter === null || p.n_legs === presetLegCountFilter)
|
||||||
@@ -1043,6 +1066,15 @@ export default function StrategyBuilder() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{chain && chainAsOf && (
|
||||||
|
<div className="px-3 py-2 rounded border border-amber-700/40 bg-amber-900/10 text-xs text-amber-300 flex items-center justify-between gap-2">
|
||||||
|
<span>
|
||||||
|
Chain figée au <strong>{chainAsOf}</strong> — les préréglages et l'éditeur de jambes ci-dessous utilisent les strikes/échéances réels de cette date-là, pas ceux d'aujourd'hui.
|
||||||
|
</span>
|
||||||
|
<button onClick={() => setChainAsOf('')} className="shrink-0 underline hover:text-amber-200">Revenir à la chain en direct</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{chain && (
|
{chain && (
|
||||||
<div className="card space-y-3">
|
<div className="card space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -1097,7 +1129,10 @@ export default function StrategyBuilder() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{chain && legs.length > 0 && (
|
{chain && legs.length > 0 && (
|
||||||
<ReplayCard symbol={debouncedSymbol} legs={legs} contractSize={scenario.contract_size ?? 100_000} />
|
<ReplayCard
|
||||||
|
symbol={debouncedSymbol} legs={legs} contractSize={scenario.contract_size ?? 100_000}
|
||||||
|
chainAsOf={chainAsOf} onUseAsChainAsOf={setChainAsOf}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{chain && (
|
{chain && (
|
||||||
|
|||||||
Reference in New Issue
Block a user