first commit
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
"""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"],
|
||||
)
|
||||
Reference in New Issue
Block a user