""" Multi-leg strategy catalog for the Backtest page. Backtest simulates years of history (2022-2024 etc.) via a single trailing-realized-vol Black-Scholes price per leg (see routers/backtest.py) — there's no real option chain to draw strikes from that far back (accumulated Saxo history only covers the last few weeks, see services/option_chain.py's own docstring). The 14 template-based strategies below therefore reuse services.strategy_templates's offset-based generators (built for Strategy Builder against a REAL chain) against a SYNTHETIC strike grid centered on spot instead — same leg-selection logic, just fed a fabricated but structurally identical "expiry" dict. The 6 single/vertical strategies use `strike_offset_pct` directly (S * (1 +/- pct)), matching the original single-leg backtest's behavior exactly rather than going through the grid, since they don't need a strike LIST to pick from. Vertical spreads (bull/bear call/put) aren't in strategy_templates.py — Strategy Builder's own residual search finds them without needing a template — so they're defined locally here rather than added to that shared module, to avoid changing Strategy Builder's and Portfolio's already-shipped optimizer behavior as a side effect of this feature. """ from typing import Any, Dict, List, Optional, Tuple from services import strategy_templates as tmpl Leg = Dict[str, Any] # STRATEGIES entries are (key, label, n_legs) — n_legs is purely informational (frontend # leg-count badge), the actual leg count comes from what build_legs() returns. STRATEGIES: List[Tuple[str, str, int]] = [ ("long_call", "Long Call", 1), ("long_put", "Long Put", 1), ("bull_call_spread", "Bull Call Spread", 2), ("bear_put_spread", "Bear Put Spread", 2), ("bear_call_spread", "Bear Call Spread", 2), ("bull_put_spread", "Bull Put Spread", 2), ("long_straddle", "Long Straddle", 2), ("short_straddle", "Short Straddle", 2), ("long_strangle", "Long Strangle", 2), ("short_strangle", "Short Strangle", 2), ("call_ratio_spread", "Call Ratio Spread", 2), ("put_ratio_spread", "Put Ratio Spread", 2), ("calendar_spread", "Calendar Spread", 2), ("diagonal_spread", "Diagonal Spread", 2), ("call_butterfly", "Call Butterfly", 3), ("put_butterfly", "Put Butterfly", 3), ("call_condor", "Call Condor", 4), ("put_condor", "Put Condor", 4), ("iron_condor", "Iron Condor", 4), ("iron_butterfly", "Iron Butterfly", 4), ] _TEMPLATE_STRATEGY_KEYS = {s[0] for s in STRATEGIES[6:]} # everything past the 6 direct ones _GRID_STEP_PCT = 0.02 _GRID_HALF_WIDTH = 25 # strikes from -50% to +50% of spot in 2% steps — enough room for # strategy_templates' OFFSETS/WIDTHS (max reach ~10 steps either side) def synthetic_expiry(expiry_date: str, days_to_expiry: int, spot: float) -> Dict[str, Any]: """A fabricated 'expiry' shaped exactly like services.option_chain.get_chain_slice's real output (expiry_date/days_to_expiry/calls/puts with {strike} rows) — strategy_templates' generators only ever read strike lists off it, so they work unmodified against this.""" strikes = [round(spot * (1 + i * _GRID_STEP_PCT), 4) for i in range(-_GRID_HALF_WIDTH, _GRID_HALF_WIDTH + 1)] return { "expiry_date": expiry_date, "days_to_expiry": days_to_expiry, "calls": [{"strike": k} for k in strikes], "puts": [{"strike": k} for k in strikes], } def _atm_index(strikes: List[float], spot: float) -> int: return min(range(len(strikes)), key=lambda i: abs(strikes[i] - spot)) def _at(strikes: List[float], idx: int) -> Optional[float]: return strikes[idx] if 0 <= idx < len(strikes) else None def _leg(expiry: Dict[str, Any], strike: float, option_type: str, position: str, quantity: int = 1) -> Leg: return { "expiry_date": expiry["expiry_date"], "days_to_expiry": expiry["days_to_expiry"], "strike": strike, "option_type": option_type, "position": position, "quantity": quantity, } def _first_by_name(candidates: List[Tuple[str, List[Leg]]], name: str) -> Optional[List[Leg]]: return next((legs for n, legs in candidates if n == name), None) 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 farther); False -> credit spread (short the closer, long the farther).""" sign = 1 if option_type == "call" else -1 near_k = round(spot * (1 + sign * offset_pct), 4) far_k = round(spot * (1 + sign * offset_pct * 3), 4) near_pos, far_pos = ("long", "short") if buy_near else ("short", "long") return [_leg(expiry, near_k, option_type, near_pos), _leg(expiry, far_k, option_type, far_pos)] def build_legs( strategy_key: str, spot: float, strike_offset_pct: float, near_expiry: Dict[str, Any], far_expiry: Optional[Dict[str, Any]], ) -> List[Leg]: """Returns the leg list for one of STRATEGIES' keys, or [] if it can't be built (calendar/diagonal with no far_expiry, or the synthetic grid came up short).""" if strategy_key == "long_call": return [_leg(near_expiry, round(spot * (1 + strike_offset_pct), 4), "call", "long")] if strategy_key == "long_put": return [_leg(near_expiry, round(spot * (1 - strike_offset_pct), 4), "put", "long")] if strategy_key == "bull_call_spread": return _vertical(near_expiry, spot, strike_offset_pct, "call", buy_near=True) if strategy_key == "bear_put_spread": return _vertical(near_expiry, spot, strike_offset_pct, "put", buy_near=True) if strategy_key == "bear_call_spread": return _vertical(near_expiry, spot, strike_offset_pct, "call", buy_near=False) if strategy_key == "bull_put_spread": return _vertical(near_expiry, spot, strike_offset_pct, "put", buy_near=False) if strategy_key not in _TEMPLATE_STRATEGY_KEYS: return [] if strategy_key in ("long_straddle", "short_straddle", "long_strangle", "short_strangle"): name = {"long_straddle": "Long Straddle", "short_straddle": "Short Straddle", "long_strangle": "Long Strangle", "short_strangle": "Short Strangle"}[strategy_key] return _first_by_name(list(tmpl.straddle_strangle(near_expiry, spot)), name) or [] if strategy_key in ("call_butterfly", "put_butterfly"): name = "Call Butterfly" if strategy_key == "call_butterfly" else "Put Butterfly" return _first_by_name(list(tmpl.butterfly(near_expiry, spot)), name) or [] if strategy_key == "iron_butterfly": return _first_by_name(list(tmpl.iron_butterfly(near_expiry, spot)), "Iron Butterfly") or [] if strategy_key in ("call_condor", "put_condor"): name = "Call Condor" if strategy_key == "call_condor" else "Put Condor" return _first_by_name(list(tmpl.condor(near_expiry, spot)), name) or [] if strategy_key == "iron_condor": return _first_by_name(list(tmpl.iron_condor(near_expiry, spot)), "Iron Condor") or [] if strategy_key in ("call_ratio_spread", "put_ratio_spread"): name = "Call Ratio Spread" if strategy_key == "call_ratio_spread" else "Put Ratio Spread" return _first_by_name(list(tmpl.ratio_spread(near_expiry, spot)), name) or [] if strategy_key == "calendar_spread": if far_expiry is None: return [] return _first_by_name(list(tmpl.calendar_spread(near_expiry, far_expiry, spot)), "Calendar Spread") or [] if strategy_key == "diagonal_spread": if far_expiry is None: return [] return _first_by_name(list(tmpl.diagonal_spread(near_expiry, far_expiry, spot)), "Diagonal Spread") or [] return []