Files
market_intel/apps/collector/selectors.py
T
2026-08-01 10:33:19 +08:00

158 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"],
)