From d85c0348d8a8e4cbba54424f8e835c7005bc2d47 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Thu, 30 Jul 2026 13:51:15 +0200 Subject: [PATCH] feat: strategy builder --- backend/routers/strategy_builder.py | 16 ++++++---- frontend/src/hooks/useApi.ts | 15 +++++---- frontend/src/pages/StrategyBuilder.tsx | 43 +++++++++++++++++++++++--- 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/backend/routers/strategy_builder.py b/backend/routers/strategy_builder.py index bf6824e..1c2b5b4 100644 --- a/backend/routers/strategy_builder.py +++ b/backend/routers/strategy_builder.py @@ -139,9 +139,10 @@ def chain( n_expiries: int = Query(3), dte_min: 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: - 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: raise HTTPException(status_code=404, detail=str(e)) @@ -152,15 +153,18 @@ def presets( horizon_days: int = Query(8), dte_min: 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 - REAL current chain instead of Backtest's synthetic grid — so a preset click here seeds - the leg 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, - which need two distinct expiries, reliably have a second one to draw from.""" + REAL chain instead of Backtest's synthetic grid — so a preset click here seeds the leg + editor with actually-quoted strikes/expiries, ready to price or replay as-is. + 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, 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 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: raise HTTPException(status_code=404, detail=str(e)) diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 353c693..4348843 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1822,12 +1822,12 @@ export const DEFAULT_GREEK_PROFILE: GreekProfile = { export const useOptionChainSlice = ( symbol: string, horizonDays: number, nExpiries = 3, enabled = true, - dteMin?: number | null, dteMax?: number | null, + dteMin?: number | null, dteMax?: number | null, asOf?: string | null, ) => useQuery({ - 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', { - 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), enabled: enabled && !!symbol, 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) // 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) => +export const usePresets = ( + symbol: string, horizonDays: number, enabled: boolean, + dteMin?: number | null, dteMax?: number | null, asOf?: string | null, +) => useQuery({ - queryKey: ['strategy-builder-presets', symbol, horizonDays, dteMin, dteMax], + queryKey: ['strategy-builder-presets', symbol, horizonDays, dteMin, dteMax, asOf], 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), enabled: enabled && !!symbol, staleTime: 30_000, diff --git a/frontend/src/pages/StrategyBuilder.tsx b/frontend/src/pages/StrategyBuilder.tsx index c457836..e594061 100644 --- a/frontend/src/pages/StrategyBuilder.tsx +++ b/frontend/src/pages/StrategyBuilder.tsx @@ -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 bounds = saxoSymbols?.find(s => s.symbol.toUpperCase() === symbol.toUpperCase()) const { mutate: runReplay, data: result, isPending, error } = useReplayStrategy() @@ -626,6 +631,14 @@ function ReplayCard({ symbol, legs, contractSize }: { symbol: string; legs: Stra {isPending ? 'Replay en cours…' : 'Lancer le replay'} + {error && ( @@ -866,6 +879,11 @@ export default function StrategyBuilder() { const commitSymbol = (v?: string) => setDebouncedSymbol((v ?? symbol).trim()) const [legs, setLegs] = useState([]) + // 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('') const [constraints, setConstraints] = useState({ 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 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 } = - 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: 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(null) const [activePreset, setActivePreset] = useState(null) const filteredPresets = (presets ?? []).filter(p => presetLegCountFilter === null || p.n_legs === presetLegCountFilter) @@ -1043,6 +1066,15 @@ export default function StrategyBuilder() { )} + {chain && chainAsOf && ( +
+ + Chain figée au {chainAsOf} — 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. + + +
+ )} + {chain && (
@@ -1097,7 +1129,10 @@ export default function StrategyBuilder() { )} {chain && legs.length > 0 && ( - + )} {chain && (