""" Parametric generators for canonical non-directional / defined-risk option structures. Each generator sweeps a small, bounded grid of strike offsets from ATM (not raw brute force over every strike) so the candidate count stays in the hundreds-to-low-thousands per expiry, not a combinatorial explosion. Every generator yields (template_name, legs). Real chains are often asymmetric — a strike can be listed for puts but not for calls (illiquid/untraded contract). Every generator therefore draws strikes from the type-specific list (calls_strikes/put_strikes) for whichever leg it's building, never from a call+put union — picking an unlisted strike would silently fall back to a theoretical smile price instead of a real tradeable quote. """ from typing import Any, Dict, Iterator, List, Tuple Leg = Dict[str, Any] OFFSETS = [1, 2, 3, 4, 5, 6] WIDTHS = [1, 2, 3, 4] def call_strikes(expiry: Dict[str, Any]) -> List[float]: return sorted({r["strike"] for r in expiry["calls"]}) def put_strikes(expiry: Dict[str, Any]) -> List[float]: return sorted({r["strike"] for r in expiry["puts"]}) def strikes_for(expiry: Dict[str, Any], option_type: str) -> List[float]: return call_strikes(expiry) if option_type == "call" else put_strikes(expiry) def _atm_index(strikes: List[float], spot: float) -> int: return min(range(len(strikes)), key=lambda i: abs(strikes[i] - spot)) 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 _at(strikes: List[float], idx: int) -> float | None: return strikes[idx] if 0 <= idx < len(strikes) else None def iron_condor(expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]: puts, calls = put_strikes(expiry), call_strikes(expiry) if not puts or not calls: return atm_p, atm_c = _atm_index(puts, spot), _atm_index(calls, spot) for po in OFFSETS[1:]: for co in OFFSETS[1:]: for w in WIDTHS[:3]: sp, lp = _at(puts, atm_p - po), _at(puts, atm_p - po - w) sc, lc = _at(calls, atm_c + co), _at(calls, atm_c + co + w) if None in (sp, lp, sc, lc): continue yield "Iron Condor", [ _leg(expiry, sp, "put", "short"), _leg(expiry, lp, "put", "long"), _leg(expiry, sc, "call", "short"), _leg(expiry, lc, "call", "long"), ] def iron_butterfly(expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]: puts, calls = put_strikes(expiry), call_strikes(expiry) common = sorted(set(puts) & set(calls)) if not common: return atm = _atm_index(common, spot) for center in (0, 1): center_strike = _at(common, atm + center) if center_strike is None: continue p_idx, c_idx = puts.index(center_strike), calls.index(center_strike) for w in OFFSETS: lp, lc = _at(puts, p_idx - w), _at(calls, c_idx + w) if None in (lp, lc): continue yield "Iron Butterfly", [ _leg(expiry, center_strike, "put", "short"), _leg(expiry, center_strike, "call", "short"), _leg(expiry, lp, "put", "long"), _leg(expiry, lc, "call", "long"), ] def butterfly(expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]: """Call or put butterfly: long 1 low, short 2 mid, long 1 high (all same type, debit).""" for opt_type in ("call", "put"): strikes = strikes_for(expiry, opt_type) if not strikes: continue atm = _atm_index(strikes, spot) for center in (-1, 0, 1): for w in OFFSETS: mid, lo, hi = _at(strikes, atm + center), _at(strikes, atm + center - w), _at(strikes, atm + center + w) if None in (mid, lo, hi): continue yield f"{opt_type.capitalize()} Butterfly", [ _leg(expiry, lo, opt_type, "long"), _leg(expiry, mid, opt_type, "short", 2), _leg(expiry, hi, opt_type, "long"), ] def condor(expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]: """Call or put condor: long low, short mid-low, short mid-high, long high (same type).""" for opt_type in ("call", "put"): strikes = strikes_for(expiry, opt_type) if not strikes: continue atm = _atm_index(strikes, spot) for inner in (1, 2, 3): for w in WIDTHS: lo, mid_lo = _at(strikes, atm - inner - w), _at(strikes, atm - inner) mid_hi, hi = _at(strikes, atm + inner), _at(strikes, atm + inner + w) if None in (lo, mid_lo, mid_hi, hi): continue yield f"{opt_type.capitalize()} Condor", [ _leg(expiry, lo, opt_type, "long"), _leg(expiry, mid_lo, opt_type, "short"), _leg(expiry, mid_hi, opt_type, "short"), _leg(expiry, hi, opt_type, "long"), ] def straddle_strangle(expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]: puts, calls = put_strikes(expiry), call_strikes(expiry) common = sorted(set(puts) & set(calls)) if common: atm_strike = _at(common, _atm_index(common, spot)) if atm_strike is not None: yield "Long Straddle", [_leg(expiry, atm_strike, "call", "long"), _leg(expiry, atm_strike, "put", "long")] yield "Short Straddle", [_leg(expiry, atm_strike, "call", "short"), _leg(expiry, atm_strike, "put", "short")] if not puts or not calls: return atm_p, atm_c = _atm_index(puts, spot), _atm_index(calls, spot) for w in OFFSETS: put_k, call_k = _at(puts, atm_p - w), _at(calls, atm_c + w) if None in (put_k, call_k): continue yield "Long Strangle", [_leg(expiry, call_k, "call", "long"), _leg(expiry, put_k, "put", "long")] yield "Short Strangle", [_leg(expiry, call_k, "call", "short"), _leg(expiry, put_k, "put", "short")] def ratio_spread(expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]: for opt_type in ("call", "put"): strikes = strikes_for(expiry, opt_type) if not strikes: continue atm = _atm_index(strikes, spot) sign = 1 if opt_type == "call" else -1 for near in (1, 2, 3): for far in (2, 3, 4, 5): if far <= near: continue near_k = _at(strikes, atm + sign * near) far_k = _at(strikes, atm + sign * far) if None in (near_k, far_k): continue yield f"{opt_type.capitalize()} Ratio Spread", [ _leg(expiry, near_k, opt_type, "long"), _leg(expiry, far_k, opt_type, "short", 2), ] def calendar_spread(near_expiry: Dict[str, Any], far_expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]: for opt_type in ("call", "put"): near_strikes = strikes_for(near_expiry, opt_type) far_set = set(strikes_for(far_expiry, opt_type)) if not near_strikes or not far_set: continue atm = _atm_index(near_strikes, spot) for offset in (-1, 0, 1): k = _at(near_strikes, atm + offset) if k is None or k not in far_set: continue yield "Calendar Spread", [ _leg(near_expiry, k, opt_type, "short"), _leg(far_expiry, k, opt_type, "long"), ] def diagonal_spread(near_expiry: Dict[str, Any], far_expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]: for opt_type in ("call", "put"): near_strikes = strikes_for(near_expiry, opt_type) far_strikes = strikes_for(far_expiry, opt_type) if not near_strikes or not far_strikes: continue atm_near, atm_far = _atm_index(near_strikes, spot), _atm_index(far_strikes, spot) sign = 1 if opt_type == "call" else -1 for near_off in (1, 2, 3): for far_off in (0, 1, 2): near_k = _at(near_strikes, atm_near + sign * near_off) far_k = _at(far_strikes, atm_far + sign * far_off) if None in (near_k, far_k) or near_k == far_k: continue yield "Diagonal Spread", [ _leg(near_expiry, near_k, opt_type, "short"), _leg(far_expiry, far_k, opt_type, "long"), ] def generate_all(chain_slice: Dict[str, Any]) -> List[Tuple[str, List[Leg]]]: """All template candidates across the fetched expiries. Single-expiry templates run per expiry; calendar/diagonal templates pair the two nearest expiries.""" spot = chain_slice["spot"] expiries = chain_slice["expiries"] candidates: List[Tuple[str, List[Leg]]] = [] for exp in expiries: for gen in (iron_condor, iron_butterfly, butterfly, condor, straddle_strangle, ratio_spread): candidates.extend(gen(exp, spot)) by_date = sorted(expiries, key=lambda e: e["days_to_expiry"]) if len(by_date) >= 2: near, far = by_date[0], by_date[1] if far["days_to_expiry"] > near["days_to_expiry"]: candidates.extend(calendar_spread(near, far, spot)) candidates.extend(diagonal_spread(near, far, spot)) return candidates