"""策略选约:剩余时长 + ATM 平值 + 期权杠杆(交易所无关)。""" from __future__ import annotations from datetime import datetime, timedelta from typing import Any from zoneinfo import ZoneInfo from ..exchange.types import OptionPair _SH = ZoneInfo("Asia/Shanghai") def hours_until_ms(expiry_ms: int, now: datetime | None = None) -> float: n = (now or datetime.now(tz=_SH)).astimezone(_SH) return (int(expiry_ms) - int(n.timestamp() * 1000)) / 3_600_000.0 def hours_until_expiry( ymd: str, now: datetime | None = None, *, expiry_ms: int | None = None, ) -> float: if expiry_ms is not None: return hours_until_ms(expiry_ms, now) # 兼容测试:无 ms 时按 OKX 惯例(UTC 08:00)推算 from ..exchange.expiry import expiry_ms_from_ymd return hours_until_ms(expiry_ms_from_ymd(ymd), now) def next_session_expiry_ymd(now: datetime | None = None) -> str: now_sh = (now or datetime.now(tz=_SH)).astimezone(_SH) open_today = now_sh.replace(hour=16, minute=0, second=0, microsecond=0) if now_sh >= open_today: target = now_sh.date() + timedelta(days=1) else: target = now_sh.date() return target.strftime("%y%m%d") def pick_atm_strike(strikes: list[float], mark_px: float) -> float | None: if not strikes or mark_px <= 0: return None return min(strikes, key=lambda s: (abs(s - mark_px), s)) def list_otm_strikes( strikes: list[float], mark_px: float, *, option_side: str, max_offset: float, ) -> list[float]: """ 虚值候选:Call K>S、Put K spot + 1e-9 and float(s) - spot <= cap + 1e-9 ] elif side == "put": cands = [ float(s) for s in strikes if float(s) < spot - 1e-9 and spot - float(s) <= cap + 1e-9 ] else: return [] return sorted(cands, key=lambda s: (abs(s - spot), s)) def pick_otm_strike( strikes: list[float], mark_px: float, *, option_side: str, max_offset: float, ) -> float | None: """虚值:取最接近标的且 |K−S|≤max_offset 的一档。""" cands = list_otm_strikes( strikes, mark_px, option_side=option_side, max_offset=max_offset, ) return cands[0] if cands else None def is_otm(*, option_side: str, strike: float, mark_px: float) -> bool: if mark_px <= 0: return False side = (option_side or "").strip().lower() k = float(strike) s = float(mark_px) if side == "call": return k > s + 1e-9 if side == "put": return k < s - 1e-9 return False def pick_itm_or_atm_strike( strikes: list[float], mark_px: float, *, option_side: str, ) -> float | None: """ 固定方向选约:只要实值或平值,不要虚值。 - Call:行权价 ≤ 标的(平值/实值) - Put:行权价 ≥ 标的(平值/实值) 在合格档中取最接近标的者(优先平值)。 """ if not strikes or mark_px <= 0: return None side = (option_side or "").strip().lower() if side == "call": cands = [float(s) for s in strikes if float(s) <= float(mark_px) + 1e-9] elif side == "put": cands = [float(s) for s in strikes if float(s) >= float(mark_px) - 1e-9] else: return None if not cands: return None return min(cands, key=lambda s: (abs(s - float(mark_px)), s)) def is_itm_or_atm(*, option_side: str, strike: float, mark_px: float) -> bool: """Call: K≤S;Put: K≥S。""" if mark_px <= 0: return False side = (option_side or "").strip().lower() k = float(strike) s = float(mark_px) if side == "call": return k <= s + 1e-9 if side == "put": return k >= s - 1e-9 return False def atm_open_offset(strike: float, mark_px: float) -> float: """开仓用:ATM 行权价相对标的的绝对点差。""" return abs(float(strike) - float(mark_px)) def atm_allows_open( strike: float, mark_px: float, *, max_offset: float, enabled: bool = False, ) -> bool: """开启限制时:|strike − mark| ≤ max_offset 才允许开仓;关闭则始终允许。""" if not enabled: return True if mark_px <= 0 or max_offset < 0: return False return atm_open_offset(strike, mark_px) <= float(max_offset) + 1e-9 def option_leverage(underlying_px: float, premium_ask: float) -> float | None: if underlying_px <= 0 or premium_ask is None or premium_ask <= 0: return None return float(underlying_px) / float(premium_ask) def _complete_by_expiry( contracts: list[dict[str, Any]], ) -> dict[str, tuple[int, dict[float, dict[str, str]]]]: """ymd -> (expiry_ms, strike -> {C|P: instId})""" by_exp: dict[str, dict[float, dict[str, str]]] = {} ms_map: dict[str, int] = {} for c in contracts: y = str(c.get("expiry_ymd") or "") stk = c.get("strike") opt = str(c.get("side") or "").upper() inst_id = str(c.get("inst_id") or "") if not y or stk is None or opt not in ("C", "P") or not inst_id: continue by_exp.setdefault(y, {}).setdefault(float(stk), {})[opt] = inst_id if c.get("expiry_ms") is not None: ms_map[y] = int(c["expiry_ms"]) out: dict[str, tuple[int, dict[float, dict[str, str]]]] = {} for ymd, strikes in by_exp.items(): complete = {s: v for s, v in strikes.items() if "C" in v and "P" in v} if not complete: continue if ymd in ms_map: ems = ms_map[ymd] else: from ..exchange.expiry import expiry_ms_from_ymd ems = expiry_ms_from_ymd(ymd) out[ymd] = (ems, complete) return out def list_eligible_expiry_ymds( contracts: list[dict[str, Any]], *, min_hours: float, now: datetime | None = None, ) -> list[str]: complete = _complete_by_expiry(contracts) eligible = [ ymd for ymd, (ems, _) in complete.items() if hours_until_ms(ems, now) + 1e-9 >= float(min_hours) ] return sorted(eligible, key=lambda y: complete[y][0]) def select_option_pair( contracts: list[dict[str, Any]], *, mark_px: float, expiry_ymd: str | None = None, min_hours: float | None = None, now: datetime | None = None, option_side: str | None = None, moneyness: str | None = None, otm_max_offset: float | None = None, ) -> OptionPair | None: """ 选到期 + 行权价。 option_side 为 call/put 时:按 moneyness 选档(默认实值/平值,兼容固定方向); 否则仍选 ATM(现有规则)。 moneyness: itm | atm | otm(仅半自动传入 otm/atm)。 """ complete = _complete_by_expiry(contracts) if not complete: return None if expiry_ymd: ymd = expiry_ymd if ymd not in complete: return None elif min_hours is not None: eligible = list_eligible_expiry_ymds(contracts, min_hours=min_hours, now=now) if not eligible: return None ymd = eligible[0] else: ymd = next_session_expiry_ymd(now) if ymd not in complete: eligible = list_eligible_expiry_ymds(contracts, min_hours=0, now=now) if not eligible: return None ymd = eligible[0] ems, strikes_map = complete[ymd] keys = list(strikes_map.keys()) side = (option_side or "").strip().lower() or None mny = (moneyness or "").strip().lower() or None if side in ("call", "put"): if mny == "otm": strike = pick_otm_strike( keys, mark_px, option_side=side, max_offset=float(otm_max_offset or 0), ) elif mny == "atm": strike = pick_atm_strike(keys, mark_px) else: # itm 或未指定:实值/平值(固定方向默认) strike = pick_itm_or_atm_strike(keys, mark_px, option_side=side) else: strike = pick_atm_strike(keys, mark_px) if strike is None: return None legs = strikes_map[strike] return OptionPair( expiry_ymd=ymd, expiry_ms=ems, strike=strike, call_inst_id=legs["C"], put_inst_id=legs["P"], ) def normalize_contracts(contracts_or_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: """若已是中性结构则原样返回;否则按 OKX 原始行解析(测试兼容)。""" if not contracts_or_rows: return [] sample = contracts_or_rows[0] if "inst_id" in sample and "expiry_ymd" in sample: return contracts_or_rows from ..exchange.okx.parse import rows_to_option_contracts return rows_to_option_contracts(contracts_or_rows)