diff --git a/backend/routers/backtest.py b/backend/routers/backtest.py index 6d269f7..73f947e 100644 --- a/backend/routers/backtest.py +++ b/backend/routers/backtest.py @@ -56,7 +56,10 @@ def _settle_leg(leg: BacktestLeg, strike: float, days_to_expiry: int, near_days: """Value one leg at the near expiry: intrinsic if it expires there too (the common case), else a fresh Black-Scholes price for its remaining time (a 'far' leg — closed alongside the near leg rather than held to its own later expiry, the standard way - calendar/diagonal-style structures are actually managed).""" + calendar/diagonal-style structures are actually managed). A 'stock' leg (Covered + Call/Protective Put/Collar's underlying position) is worth exactly the spot, always.""" + if leg.option_type == "stock": + return S_settle remaining_days = days_to_expiry - near_days if remaining_days <= 0: if leg.option_type == "call": @@ -70,7 +73,7 @@ def _settle_leg(leg: BacktestLeg, strike: float, days_to_expiry: int, near_days: def run_backtest(req: BacktestRequest): try: for leg in req.legs: - if leg.option_type not in ("call", "put") or leg.position not in ("long", "short"): + if leg.option_type not in ("call", "put", "stock") or leg.position not in ("long", "short"): return {"error": f"Jambe invalide: {leg}"} ticker = yf.Ticker(req.symbol) @@ -103,7 +106,7 @@ def run_backtest(req: BacktestRequest): leg_days = [req.expiry_days if leg.expiry != "far" else req.far_expiry_days for leg in req.legs] entry_premiums = [ - float(black_scholes(S, k, d / 365, r, sigma, leg.option_type)["price"]) + S if leg.option_type == "stock" else float(black_scholes(S, k, d / 365, r, sigma, leg.option_type)["price"]) for leg, k, d in zip(req.legs, leg_strikes, leg_days) ] diff --git a/backend/routers/strategy_builder.py b/backend/routers/strategy_builder.py index f024010..bf6824e 100644 --- a/backend/routers/strategy_builder.py +++ b/backend/routers/strategy_builder.py @@ -146,6 +146,38 @@ def chain( raise HTTPException(status_code=404, detail=str(e)) +@router.get("/presets") +def presets( + symbol: str = Query(...), + horizon_days: int = Query(8), + dte_min: Optional[int] = Query(None), + dte_max: Optional[int] = 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.""" + 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) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + expiries = chain_slice["expiries"] + if not expiries: + raise HTTPException(status_code=404, detail=f"Aucune échéance exploitable pour '{symbol}'.") + near, far = expiries[0], expiries[1] if len(expiries) > 1 else None + + out = [] + for key, label, n_legs in STRATEGIES: + legs = build_legs(key, chain_slice["spot"], 0.05, near, far) + if not legs: + continue # e.g. calendar/diagonal with only one real expiry available right now + out.append({"key": key, "label": label, "n_legs": n_legs, "legs": legs}) + return out + + @router.post("/price") def price(req: PriceRequest): if not req.legs: diff --git a/backend/services/backtest_strategies.py b/backend/services/backtest_strategies.py index 6d878b2..f533609 100644 --- a/backend/services/backtest_strategies.py +++ b/backend/services/backtest_strategies.py @@ -47,9 +47,20 @@ STRATEGIES: List[Tuple[str, str, int]] = [ ("put_condor", "Put Condor", 4), ("iron_condor", "Iron Condor", 4), ("iron_butterfly", "Iron Butterfly", 4), + ("broken_wing_butterfly", "Broken Wing Butterfly", 3), + ("ratio_backspread", "Ratio Backspread", 2), + ("jade_lizard", "Jade Lizard", 3), + ("risk_reversal", "Risk Reversal", 2), + ("box_spread", "Box Spread", 4), + ("covered_call", "Covered Call (Buy-Write)", 2), + ("protective_put", "Protective Put", 2), + ("collar", "Collar (tunnel)", 3), ] -_TEMPLATE_STRATEGY_KEYS = {s[0] for s in STRATEGIES[6:]} # everything past the 6 direct ones +# Everything past the 6 direct (single/vertical) strategies is built from a strike LIST +# rather than a plain spot*(1+/-pct) formula. +_TEMPLATE_STRATEGY_KEYS = {s[0] for s in STRATEGIES[6:]} +_STOCK_LEG_STRATEGY_KEYS = {"covered_call", "protective_put", "collar"} _GRID_STEP_PCT = 0.02 _GRID_HALF_WIDTH = 25 # strikes from -50% to +50% of spot in 2% steps — enough room for @@ -86,6 +97,119 @@ def _first_by_name(candidates: List[Tuple[str, List[Leg]]], name: str) -> Option return next((legs for n, legs in candidates if n == name), None) +def _stock_leg(expiry: Dict[str, Any], spot: float, position: str, quantity: int = 1) -> Leg: + """A position in the underlying itself (Covered Call/Protective Put/Collar), not an + option — see services.strategy_engine's option_type=="stock" handling. strike/expiry + are placeholders never read for pricing: strike=spot is harmless in check_bounded_risk's + strike-range hint, and days_to_expiry is set far out so this leg never wins a + min(l["days_to_expiry"] for l in legs) used elsewhere to pick the position's eval date.""" + return { + "expiry_date": expiry["expiry_date"], "days_to_expiry": 36_500, + "strike": round(spot, 4), "option_type": "stock", "position": position, "quantity": quantity, + } + + +def broken_wing_butterfly(expiry: Dict[str, Any], spot: float) -> List[Leg]: + """Like a call butterfly but the far wing is pulled wider than the near one — the + resulting asymmetry removes risk on one side entirely (funded by the wider wing's + lower cost) at the expense of a small max loss on the other.""" + calls = tmpl.call_strikes(expiry) + if not calls: + return [] + atm = _atm_index(calls, spot) + near_k, mid_k, far_k = _at(calls, atm + 1), _at(calls, atm + 3), _at(calls, atm + 8) + if None in (near_k, mid_k, far_k): + return [] + return [_leg(expiry, near_k, "call", "long"), _leg(expiry, mid_k, "call", "short", 2), _leg(expiry, far_k, "call", "long")] + + +def ratio_backspread(expiry: Dict[str, Any], spot: float) -> List[Leg]: + """Opposite of strategy_templates.ratio_spread: sell the near strike, buy 2x the + farther one — net long gamma/vega, unlimited gain if the underlying makes a big move + past the long strikes, bounded loss in the flat middle zone.""" + calls = tmpl.call_strikes(expiry) + if not calls: + return [] + atm = _atm_index(calls, spot) + near_k, far_k = _at(calls, atm + 1), _at(calls, atm + 4) + if None in (near_k, far_k): + return [] + return [_leg(expiry, near_k, "call", "short"), _leg(expiry, far_k, "call", "long", 2)] + + +def jade_lizard(expiry: Dict[str, Any], spot: float) -> List[Leg]: + """Short put + short call spread, sized so the call side's width is fully covered by + the combined credit — no upside risk by construction, only downside (below the short + put) and a capped zone in between.""" + puts, calls = tmpl.put_strikes(expiry), tmpl.call_strikes(expiry) + if not puts or not calls: + return [] + put_k = _at(puts, _atm_index(puts, spot) - 2) + call_k, far_call_k = _at(calls, _atm_index(calls, spot) + 1), _at(calls, _atm_index(calls, spot) + 3) + if None in (put_k, call_k, far_call_k): + return [] + return [_leg(expiry, put_k, "put", "short"), _leg(expiry, call_k, "call", "short"), _leg(expiry, far_call_k, "call", "long")] + + +def risk_reversal(expiry: Dict[str, Any], spot: float) -> List[Leg]: + """Sell an OTM put, buy an OTM call — a low-cost (often near-zero) directional bet, + funded by giving up protection below the short put strike.""" + puts, calls = tmpl.put_strikes(expiry), tmpl.call_strikes(expiry) + if not puts or not calls: + return [] + put_k = _at(puts, _atm_index(puts, spot) - 2) + call_k = _at(calls, _atm_index(calls, spot) + 2) + if None in (put_k, call_k): + return [] + return [_leg(expiry, put_k, "put", "short"), _leg(expiry, call_k, "call", "long")] + + +def box_spread(expiry: Dict[str, Any], spot: float) -> List[Leg]: + """Synthetic long (long call + short put) at K1 combined with a synthetic short at + K2 — a pure financing structure (locks in the strike-width discounted at the risk-free + rate) whose payoff is independent of the underlying, not a market bet.""" + puts, calls = tmpl.put_strikes(expiry), tmpl.call_strikes(expiry) + common = sorted(set(puts) & set(calls)) + if len(common) < 2: + return [] + atm = _atm_index(common, spot) + k1 = _at(common, atm) + k2 = _at(common, min(atm + 3, len(common) - 1)) + if k1 is None or k2 is None or k1 == k2: + return [] + return [ + _leg(expiry, k1, "call", "long"), _leg(expiry, k2, "call", "short"), + _leg(expiry, k2, "put", "long"), _leg(expiry, k1, "put", "short"), + ] + + +def covered_call(expiry: Dict[str, Any], spot: float) -> List[Leg]: + calls = tmpl.call_strikes(expiry) + call_k = _at(calls, _atm_index(calls, spot) + 2) if calls else None + if call_k is None: + return [] + return [_stock_leg(expiry, spot, "long"), _leg(expiry, call_k, "call", "short")] + + +def protective_put(expiry: Dict[str, Any], spot: float) -> List[Leg]: + puts = tmpl.put_strikes(expiry) + put_k = _at(puts, _atm_index(puts, spot) - 3) if puts else None + if put_k is None: + return [] + return [_stock_leg(expiry, spot, "long"), _leg(expiry, put_k, "put", "long")] + + +def collar(expiry: Dict[str, Any], spot: float) -> List[Leg]: + puts, calls = tmpl.put_strikes(expiry), tmpl.call_strikes(expiry) + if not puts or not calls: + return [] + put_k = _at(puts, _atm_index(puts, spot) - 3) + call_k = _at(calls, _atm_index(calls, spot) + 3) + if None in (put_k, call_k): + return [] + return [_stock_leg(expiry, spot, "long"), _leg(expiry, put_k, "put", "long"), _leg(expiry, call_k, "call", "short")] + + def _vertical(expiry: Dict[str, Any], spot: float, offset_pct: float, option_type: str, buy_near: bool) -> List[Leg]: """2-leg vertical, same expiry/type: one leg at spot*(1+/-offset_pct), the other at 3x that offset. buy_near=True -> debit spread (long the closer strike, short the @@ -144,6 +268,22 @@ def build_legs( if far_expiry is None: return [] return _first_by_name(list(tmpl.diagonal_spread(near_expiry, far_expiry, spot)), "Diagonal Spread") or [] + if strategy_key == "broken_wing_butterfly": + return broken_wing_butterfly(near_expiry, spot) + if strategy_key == "ratio_backspread": + return ratio_backspread(near_expiry, spot) + if strategy_key == "jade_lizard": + return jade_lizard(near_expiry, spot) + if strategy_key == "risk_reversal": + return risk_reversal(near_expiry, spot) + if strategy_key == "box_spread": + return box_spread(near_expiry, spot) + if strategy_key == "covered_call": + return covered_call(near_expiry, spot) + if strategy_key == "protective_put": + return protective_put(near_expiry, spot) + if strategy_key == "collar": + return collar(near_expiry, spot) return [] @@ -165,7 +305,9 @@ def default_legs_pct(strategy_key: str, strike_offset_pct: float = 0.05) -> List { "option_type": leg["option_type"], "position": leg["position"], "quantity": leg["quantity"], "strike_pct": round(leg["strike"] / _NOMINAL_SPOT, 4), - "expiry": "near" if leg["days_to_expiry"] == _NOMINAL_NEAR_DAYS else "far", + # A stock leg's placeholder days_to_expiry (36500, "never expires") would + # otherwise misclassify it as "far" here — it's always effectively "near". + "expiry": "near" if leg["option_type"] == "stock" or leg["days_to_expiry"] == _NOMINAL_NEAR_DAYS else "far", } for leg in legs ] diff --git a/backend/services/strategy_engine.py b/backend/services/strategy_engine.py index 152c28b..80a4cbb 100644 --- a/backend/services/strategy_engine.py +++ b/backend/services/strategy_engine.py @@ -56,7 +56,14 @@ def _quote_spread_pct(quote: Optional[Dict[str, Any]]) -> float: def entry_price(leg: Dict[str, Any], chain_slice: Dict[str, Any], surface_now: Surface, r: float) -> Dict[str, float]: - """Real execution price (crossing the spread) + theoretical mid, for one leg today.""" + """Real execution price (crossing the spread) + theoretical mid, for one leg today. + A "stock" leg (Covered Call/Protective Put/Collar's underlying position, not an + option) has no strike/vol at all — its price IS the spot, no spread modeled (the + underlying's own spread is typically far tighter than any option on it, and this + tool doesn't have a live quote for it the way find_quote does for options).""" + if leg["option_type"] == "stock": + S = chain_slice["spot"] + return {"exec_price": S, "mid": S, "spread_pct": 0.0} quote = find_quote(chain_slice, leg["expiry_date"], leg["strike"], leg["option_type"]) T = max(leg["days_to_expiry"], 0.001) / 365 sigma = surface_now.iv_at(leg["strike"], leg["days_to_expiry"]) @@ -87,14 +94,17 @@ def value_at( computed and discarded on every one of those calls.""" total = 0.0 for leg in legs: - remaining = leg["days_to_expiry"] - eval_days_from_now qty = leg.get("quantity", 1) sign = _sign(leg) - if remaining <= 0: - price = _intrinsic(S, leg["strike"], leg["option_type"]) + if leg["option_type"] == "stock": + price = S # a share/lot of the underlying is worth exactly the spot, always else: - sigma = surface.iv_at(leg["strike"], remaining) - price = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"], include_second_order=False)["price"] + remaining = leg["days_to_expiry"] - eval_days_from_now + if remaining <= 0: + price = _intrinsic(S, leg["strike"], leg["option_type"]) + else: + sigma = surface.iv_at(leg["strike"], remaining) + price = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"], include_second_order=False)["price"] total += sign * price * qty * contract_size return total @@ -105,9 +115,14 @@ def greeks_at(legs: List[Dict[str, Any]], S: float, eval_days_from_now: float, s "vanna": 0.0, "charm": 0.0, "vomma": 0.0, "veta": 0.0, "speed": 0.0, "color": 0.0, "zomma": 0.0, } for leg in legs: - remaining = max(leg["days_to_expiry"] - eval_days_from_now, 0.001) qty = leg.get("quantity", 1) sign = _sign(leg) + if leg["option_type"] == "stock": + # d(spot)/d(spot) = 1, and every other Greek (gamma, theta, vega, rho, the + # second-order ones) is exactly zero for a position in the underlying itself. + net["delta"] += sign * qty + continue + remaining = max(leg["days_to_expiry"] - eval_days_from_now, 0.001) sigma = surface.iv_at(leg["strike"], remaining) g = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"]) for k in net: @@ -169,14 +184,17 @@ def price_combo( scenario_mid = 0.0 scenario_exec = 0.0 for leg in legs: - remaining = max(leg["days_to_expiry"] - horizon_days, 0.001) qty = leg.get("quantity", 1) sign = _sign(leg) - sigma = surface_scenario.iv_at(leg["strike"], remaining) - theo = black_scholes(spot_scenario, leg["strike"], remaining / 365, r, sigma, leg["option_type"])["price"] - quote = find_quote(chain_slice, leg["expiry_date"], leg["strike"], leg["option_type"]) - spread_pct = _quote_spread_pct(quote) - exec_price = theo * (1 - spread_pct / 2) if leg.get("position", "long") == "long" else theo * (1 + spread_pct / 2) + if leg["option_type"] == "stock": + theo = exec_price = spot_scenario # no spread modeled for the underlying itself + else: + remaining = max(leg["days_to_expiry"] - horizon_days, 0.001) + sigma = surface_scenario.iv_at(leg["strike"], remaining) + theo = black_scholes(spot_scenario, leg["strike"], remaining / 365, r, sigma, leg["option_type"])["price"] + quote = find_quote(chain_slice, leg["expiry_date"], leg["strike"], leg["option_type"]) + spread_pct = _quote_spread_pct(quote) + exec_price = theo * (1 - spread_pct / 2) if leg.get("position", "long") == "long" else theo * (1 + spread_pct / 2) scenario_mid += sign * theo * qty * contract_size scenario_exec += sign * exec_price * qty * contract_size diff --git a/backend/services/strategy_replay.py b/backend/services/strategy_replay.py index 493ee1e..ba44d88 100644 --- a/backend/services/strategy_replay.py +++ b/backend/services/strategy_replay.py @@ -36,7 +36,11 @@ def replay_position( saxo_symbol = get_saxo_option_symbol_for_ticker(symbol) or symbol.upper() signed_qty = [(1 if leg["position"] == "long" else -1) * leg.get("quantity", 1) for leg in legs] - avg_days = sum(leg.get("days_to_expiry", 30) for leg in legs) / len(legs) + # A "stock" leg's placeholder days_to_expiry (~effectively infinite, see + # backtest_strategies._stock_leg) would otherwise skew this — it's not a real option + # expiry and never should influence which expiries the chain fetch favors. + option_legs = [leg for leg in legs if leg["option_type"] != "stock"] + avg_days = sum(leg.get("days_to_expiry", 30) for leg in option_legs) / len(option_legs) if option_legs else 30 points: List[Dict[str, Any]] = [] entry_value = None @@ -55,6 +59,12 @@ def replay_position( value = 0.0 # dollar value of the whole position, contract_size already applied complete = True for leg, sq in zip(legs, signed_qty): + if leg["option_type"] == "stock": + if chain.get("spot") is None: + complete = False + break + value += sq * chain["spot"] * contract_size + continue q = find_quote(chain, leg["expiry_date"], leg["strike"], leg["option_type"]) if not q or q["mid"] <= 0: complete = False diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 26971be..353c693 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -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({ @@ -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('/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({ + 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 diff --git a/frontend/src/pages/Backtest.tsx b/frontend/src/pages/Backtest.tsx index ad0a58c..7cd208a 100644 --- a/frontend/src/pages/Backtest.tsx +++ b/frontend/src/pages/Backtest.tsx @@ -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() {
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" - /> - % spot - + {leg.option_type === 'stock' ? ( + position sur le sous-jacent — pas de strike/échéance + ) : ( + <> + 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" + /> + % spot + + + )}
))} diff --git a/frontend/src/pages/StrategyBuilder.tsx b/frontend/src/pages/StrategyBuilder.tsx index 55eeaa1..c457836 100644 --- a/frontend/src/pages/StrategyBuilder.tsx +++ b/frontend/src/pages/StrategyBuilder.tsx @@ -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 (
- - - + {isStock ? ( +
+ Sous-jacent (spot{chain?.spot != null ? ` ${fmtPrice(chain.spot)}` : ''}) — position sur le sous-jacent lui-même, pas une option +
+ ) : ( + <> + + + + + )}