"""合约选择:剩余时长过滤 + ATM 平值期权。""" from __future__ import annotations import re from datetime import datetime, timedelta, timezone from typing import Any from zoneinfo import ZoneInfo from .types import OptionPair _SH = ZoneInfo("Asia/Shanghai") _DATE_RE = re.compile(r"^\d{6}$") 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 expiry_ms_from_ymd(ymd: str) -> int: """OKX 期权到期:当日 08:00 UTC = 上海 16:00。""" yy, mm, dd = int(ymd[0:2]), int(ymd[2:4]), int(ymd[4:6]) dt = datetime(2000 + yy, mm, dd, 8, 0, 0, tzinfo=timezone.utc) return int(dt.timestamp() * 1000) def hours_until_expiry(ymd: str, now: datetime | None = None) -> float: """距到期剩余小时(可为负)。""" n = (now or datetime.now(tz=_SH)).astimezone(_SH) left_ms = expiry_ms_from_ymd(ymd) - int(n.timestamp() * 1000) return left_ms / 3_600_000.0 def next_session_expiry_ymd(now: datetime | None = None) -> str: """兼容旧逻辑:次日/当日 16:00 到期键(展示/测试用)。""" 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 _complete_by_expiry( instruments: list[dict[str, Any]], ) -> dict[str, dict[float, dict[str, str]]]: """expiry_ymd -> strike -> {C|P: instId},仅完整 Call+Put。""" by_exp: dict[str, dict[float, dict[str, str]]] = {} for row in instruments: 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) if y is None or stk is None or opt is None: 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") 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 by_exp.setdefault(y, {}).setdefault(float(stk), {})[opt] = inst_id out: dict[str, 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 complete: out[ymd] = complete return out def list_eligible_expiry_ymds( instruments: list[dict[str, Any]], *, min_hours: float, now: datetime | None = None, ) -> list[str]: """剩余时间 >= min_hours 的到期日,由近到远。""" complete = _complete_by_expiry(instruments) eligible = [ ymd for ymd in complete if hours_until_expiry(ymd, now) + 1e-9 >= float(min_hours) ] return sorted(eligible, key=lambda y: expiry_ms_from_ymd(y)) def select_option_pair( instruments: list[dict[str, Any]], *, mark_px: float, expiry_ymd: str | None = None, min_hours: float | None = None, now: datetime | None = None, ) -> OptionPair | None: """ 选 ATM Call/Put。 - 若给 expiry_ymd:在该到期日选平值。 - 若给 min_hours:选「剩余时长合格」中最近到期日的平值。 - 否则回退 next_session_expiry_ymd。 """ complete = _complete_by_expiry(instruments) 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( instruments, 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(instruments, min_hours=0, now=now) if not eligible: return None ymd = eligible[0] strikes_map = complete[ymd] atm = pick_atm_strike(list(strikes_map.keys()), mark_px) if atm is None: return None legs = strikes_map[atm] return OptionPair( expiry_ymd=ymd, expiry_ms=expiry_ms_from_ymd(ymd), strike=atm, call_inst_id=legs["C"], put_inst_id=legs["P"], ) 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)