Split exchange and strategy modules for future Binance support.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,118 +1,36 @@
|
||||
"""合约选择:剩余时长过滤 + ATM 平值期权。"""
|
||||
"""兼容层:选约逻辑已迁至 strategy.selection;OKX 解析在 exchange.okx.parse。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .types import OptionPair
|
||||
from ..exchange.okx.parse import (
|
||||
expiry_ms_from_ymd,
|
||||
parse_option_inst_id,
|
||||
safe_float,
|
||||
)
|
||||
from ..exchange.types import OptionPair
|
||||
from ..strategy.selection import (
|
||||
hours_until_expiry,
|
||||
list_eligible_expiry_ymds as _list_eligible,
|
||||
next_session_expiry_ymd,
|
||||
option_leverage,
|
||||
pick_atm_strike,
|
||||
select_option_pair as _select_pair,
|
||||
normalize_contracts,
|
||||
)
|
||||
|
||||
_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))
|
||||
__all__ = [
|
||||
"expiry_ms_from_ymd",
|
||||
"hours_until_expiry",
|
||||
"list_eligible_expiry_ymds",
|
||||
"next_session_expiry_ymd",
|
||||
"option_leverage",
|
||||
"parse_option_inst_id",
|
||||
"pick_atm_strike",
|
||||
"safe_float",
|
||||
"select_option_pair",
|
||||
]
|
||||
|
||||
|
||||
def select_option_pair(
|
||||
@@ -121,54 +39,22 @@ def select_option_pair(
|
||||
mark_px: float,
|
||||
expiry_ymd: str | None = None,
|
||||
min_hours: float | None = None,
|
||||
now: datetime | None = None,
|
||||
now=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"],
|
||||
contracts = normalize_contracts(instruments)
|
||||
return _select_pair(
|
||||
contracts,
|
||||
mark_px=mark_px,
|
||||
expiry_ymd=expiry_ymd,
|
||||
min_hours=min_hours,
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
def list_eligible_expiry_ymds(
|
||||
instruments: list[dict[str, Any]],
|
||||
*,
|
||||
min_hours: float,
|
||||
now=None,
|
||||
) -> list[str]:
|
||||
return _list_eligible(normalize_contracts(instruments), min_hours=min_hours, now=now)
|
||||
|
||||
Reference in New Issue
Block a user