"""ATM / 合资格到期选择。规则:最接近指数的行权价;最近剩余时长 ≥ min_hours 的到期。""" from __future__ import annotations import re from dataclasses import dataclass from datetime import datetime from typing import Any from zoneinfo import ZoneInfo from packages.domain.expiry import expiry_ms_from_ymd _SH = ZoneInfo("Asia/Shanghai") _DATE_RE = re.compile(r"^\d{6}$") @dataclass(frozen=True) class OptionPair: expiry_ymd: str expiry_ms: int strike: float call_inst_id: str put_inst_id: str def safe_float(v: Any) -> float | None: if v is None or v == "": return None try: return float(v) except (TypeError, ValueError): return None def parse_option_inst_id(inst_id: str) -> tuple[str | None, float | None, str | None]: """ETH-USD_UM-YYMMDD-STRIKE-C → (YYMMDD, strike, C|P).""" parts = (inst_id or "").strip().split("-") if len(parts) < 5: return None, None, None ymd = parts[-3] strike = safe_float(parts[-2]) opt = parts[-1].upper() if not _DATE_RE.fullmatch(ymd) or strike is None or opt not in ("C", "P"): return None, None, None return ymd, strike, opt def rows_to_contracts(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] for row in rows: if not isinstance(row, dict): continue state = str(row.get("state") or "live").lower() if state and state != "live": continue inst_id = str(row.get("instId") or "") y, stk, opt = parse_option_inst_id(inst_id) exp_ms: int | None = None if y is None or stk is None or opt is None: from datetime import timezone exp = safe_float(row.get("expTime")) if exp: ms = int(exp) if exp > 10_000_000_000 else int(exp * 1000) y = datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%y%m%d") exp_ms = ms stk = safe_float(row.get("stk")) opt_raw = str(row.get("optType") or "").upper() opt = opt_raw if opt_raw in ("C", "P") else None if not inst_id or not y or stk is None or opt not in ("C", "P"): continue if exp_ms is None: exp_ms = expiry_ms_from_ymd(y) out.append( { "inst_id": inst_id, "expiry_ymd": y, "expiry_ms": int(exp_ms), "strike": float(stk), "side": opt, } ) return out 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 pick_atm_strike(strikes: list[float], index_px: float) -> float | None: """最接近指数的行权价(平值)。""" if not strikes or index_px <= 0: return None return min(strikes, key=lambda s: (abs(s - index_px), s)) def _complete_by_expiry( contracts: list[dict[str, Any]], ) -> dict[str, tuple[int, dict[float, dict[str, str]]]]: 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 ems = ms_map.get(ymd) or expiry_ms_from_ymd(ymd) out[ymd] = (ems, complete) return out def select_atm_pair( contracts: list[dict[str, Any]], *, index_px: float, min_hours: float = 12.0, now: datetime | None = None, ) -> OptionPair | None: """ 选最近合资格到期(剩余 ≥ min_hours)+ ATM Call/Put。 ATM = 行权价最接近指数。 """ complete = _complete_by_expiry(contracts) if not complete or index_px <= 0: return None eligible = [ ymd for ymd, (ems, _) in complete.items() if hours_until_ms(ems, now) + 1e-9 >= float(min_hours) ] if not eligible: return None eligible.sort(key=lambda y: complete[y][0]) ymd = eligible[0] ems, strikes_map = complete[ymd] strike = pick_atm_strike(list(strikes_map.keys()), index_px) if strike is None: return None legs = strikes_map[strike] return OptionPair( expiry_ymd=ymd, expiry_ms=ems, strike=float(strike), call_inst_id=legs["C"], put_inst_id=legs["P"], )