e51f357b48
Co-authored-by: Cursor <cursoragent@cursor.com>
120 lines
3.9 KiB
Python
120 lines
3.9 KiB
Python
"""合约选择:次日 16:00(上海)到期 + 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 next_session_expiry_ymd(now: datetime | None = None) -> str:
|
|
"""
|
|
业务约定:开仓选「次日 16:00」到期。
|
|
- 上海时间 >= 当日 16:00:目标到期日 = 次日
|
|
- 上海时间 < 当日 16:00:目标到期日 = 当日(当日 16:00 到期仍可用作盘口对齐/预热)
|
|
正式开仓窗从当日 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 select_option_pair(
|
|
instruments: list[dict[str, Any]],
|
|
*,
|
|
mark_px: float,
|
|
expiry_ymd: str | None = None,
|
|
now: datetime | None = None,
|
|
) -> OptionPair | None:
|
|
"""
|
|
从 live 合约列表中选出:目标到期日 + ATM 同行权价 Call/Put。
|
|
行权价规则暂定 ATM(最接近标记/指数价);待拍板后可替换。
|
|
"""
|
|
ymd = expiry_ymd or next_session_expiry_ymd(now)
|
|
by_strike: 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 y != ymd or stk is None or opt not in ("C", "P"):
|
|
continue
|
|
by_strike.setdefault(float(stk), {})[opt] = inst_id
|
|
|
|
complete = {s: v for s, v in by_strike.items() if "C" in v and "P" in v}
|
|
if not complete:
|
|
return None
|
|
|
|
atm = pick_atm_strike(list(complete.keys()), mark_px)
|
|
if atm is None:
|
|
return None
|
|
|
|
legs = complete[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"],
|
|
)
|