6f983ed2ab
Closest Call@1920 at 152x no longer blocks 1930/1940 that already clear 200x. Co-authored-by: Cursor <cursoragent@cursor.com>
303 lines
9.0 KiB
Python
303 lines
9.0 KiB
Python
"""策略选约:剩余时长 + ATM 平值 + 期权杠杆(交易所无关)。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime, timedelta
|
||
from typing import Any
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from ..exchange.types import OptionPair
|
||
|
||
_SH = ZoneInfo("Asia/Shanghai")
|
||
|
||
|
||
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 hours_until_expiry(
|
||
ymd: str,
|
||
now: datetime | None = None,
|
||
*,
|
||
expiry_ms: int | None = None,
|
||
) -> float:
|
||
if expiry_ms is not None:
|
||
return hours_until_ms(expiry_ms, now)
|
||
# 兼容测试:无 ms 时按 OKX 惯例(UTC 08:00)推算
|
||
from ..exchange.expiry import expiry_ms_from_ymd
|
||
|
||
return hours_until_ms(expiry_ms_from_ymd(ymd), now)
|
||
|
||
|
||
def next_session_expiry_ymd(now: datetime | None = None) -> str:
|
||
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 list_otm_strikes(
|
||
strikes: list[float],
|
||
mark_px: float,
|
||
*,
|
||
option_side: str,
|
||
max_offset: float,
|
||
) -> list[float]:
|
||
"""
|
||
虚值候选:Call K>S、Put K<S 且 |K−S|≤max_offset;
|
||
按靠近标的优先排序(近→远)。
|
||
"""
|
||
if not strikes or mark_px <= 0:
|
||
return []
|
||
side = (option_side or "").strip().lower()
|
||
spot = float(mark_px)
|
||
cap = max(0.0, float(max_offset))
|
||
if side == "call":
|
||
cands = [
|
||
float(s)
|
||
for s in strikes
|
||
if float(s) > spot + 1e-9 and float(s) - spot <= cap + 1e-9
|
||
]
|
||
elif side == "put":
|
||
cands = [
|
||
float(s)
|
||
for s in strikes
|
||
if float(s) < spot - 1e-9 and spot - float(s) <= cap + 1e-9
|
||
]
|
||
else:
|
||
return []
|
||
return sorted(cands, key=lambda s: (abs(s - spot), s))
|
||
|
||
|
||
def pick_otm_strike(
|
||
strikes: list[float],
|
||
mark_px: float,
|
||
*,
|
||
option_side: str,
|
||
max_offset: float,
|
||
) -> float | None:
|
||
"""虚值:取最接近标的且 |K−S|≤max_offset 的一档。"""
|
||
cands = list_otm_strikes(
|
||
strikes,
|
||
mark_px,
|
||
option_side=option_side,
|
||
max_offset=max_offset,
|
||
)
|
||
return cands[0] if cands else None
|
||
|
||
|
||
def is_otm(*, option_side: str, strike: float, mark_px: float) -> bool:
|
||
if mark_px <= 0:
|
||
return False
|
||
side = (option_side or "").strip().lower()
|
||
k = float(strike)
|
||
s = float(mark_px)
|
||
if side == "call":
|
||
return k > s + 1e-9
|
||
if side == "put":
|
||
return k < s - 1e-9
|
||
return False
|
||
|
||
|
||
def pick_itm_or_atm_strike(
|
||
strikes: list[float],
|
||
mark_px: float,
|
||
*,
|
||
option_side: str,
|
||
) -> float | None:
|
||
"""
|
||
固定方向选约:只要实值或平值,不要虚值。
|
||
- Call:行权价 ≤ 标的(平值/实值)
|
||
- Put:行权价 ≥ 标的(平值/实值)
|
||
在合格档中取最接近标的者(优先平值)。
|
||
"""
|
||
if not strikes or mark_px <= 0:
|
||
return None
|
||
side = (option_side or "").strip().lower()
|
||
if side == "call":
|
||
cands = [float(s) for s in strikes if float(s) <= float(mark_px) + 1e-9]
|
||
elif side == "put":
|
||
cands = [float(s) for s in strikes if float(s) >= float(mark_px) - 1e-9]
|
||
else:
|
||
return None
|
||
if not cands:
|
||
return None
|
||
return min(cands, key=lambda s: (abs(s - float(mark_px)), s))
|
||
|
||
|
||
def is_itm_or_atm(*, option_side: str, strike: float, mark_px: float) -> bool:
|
||
"""Call: K≤S;Put: K≥S。"""
|
||
if mark_px <= 0:
|
||
return False
|
||
side = (option_side or "").strip().lower()
|
||
k = float(strike)
|
||
s = float(mark_px)
|
||
if side == "call":
|
||
return k <= s + 1e-9
|
||
if side == "put":
|
||
return k >= s - 1e-9
|
||
return False
|
||
|
||
|
||
def atm_open_offset(strike: float, mark_px: float) -> float:
|
||
"""开仓用:ATM 行权价相对标的的绝对点差。"""
|
||
return abs(float(strike) - float(mark_px))
|
||
|
||
|
||
def atm_allows_open(
|
||
strike: float,
|
||
mark_px: float,
|
||
*,
|
||
max_offset: float,
|
||
enabled: bool = False,
|
||
) -> bool:
|
||
"""开启限制时:|strike − mark| ≤ max_offset 才允许开仓;关闭则始终允许。"""
|
||
if not enabled:
|
||
return True
|
||
if mark_px <= 0 or max_offset < 0:
|
||
return False
|
||
return atm_open_offset(strike, mark_px) <= float(max_offset) + 1e-9
|
||
|
||
|
||
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 _complete_by_expiry(
|
||
contracts: list[dict[str, Any]],
|
||
) -> dict[str, tuple[int, dict[float, dict[str, str]]]]:
|
||
"""ymd -> (expiry_ms, strike -> {C|P: instId})"""
|
||
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
|
||
if ymd in ms_map:
|
||
ems = ms_map[ymd]
|
||
else:
|
||
from ..exchange.expiry import expiry_ms_from_ymd
|
||
|
||
ems = expiry_ms_from_ymd(ymd)
|
||
out[ymd] = (ems, complete)
|
||
return out
|
||
|
||
|
||
def list_eligible_expiry_ymds(
|
||
contracts: list[dict[str, Any]],
|
||
*,
|
||
min_hours: float,
|
||
now: datetime | None = None,
|
||
) -> list[str]:
|
||
complete = _complete_by_expiry(contracts)
|
||
eligible = [
|
||
ymd
|
||
for ymd, (ems, _) in complete.items()
|
||
if hours_until_ms(ems, now) + 1e-9 >= float(min_hours)
|
||
]
|
||
return sorted(eligible, key=lambda y: complete[y][0])
|
||
|
||
|
||
def select_option_pair(
|
||
contracts: list[dict[str, Any]],
|
||
*,
|
||
mark_px: float,
|
||
expiry_ymd: str | None = None,
|
||
min_hours: float | None = None,
|
||
now: datetime | None = None,
|
||
option_side: str | None = None,
|
||
moneyness: str | None = None,
|
||
otm_max_offset: float | None = None,
|
||
) -> OptionPair | None:
|
||
"""
|
||
选到期 + 行权价。
|
||
option_side 为 call/put 时:按 moneyness 选档(默认实值/平值,兼容固定方向);
|
||
否则仍选 ATM(现有规则)。
|
||
moneyness: itm | atm | otm(仅半自动传入 otm/atm)。
|
||
"""
|
||
complete = _complete_by_expiry(contracts)
|
||
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(contracts, 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(contracts, min_hours=0, now=now)
|
||
if not eligible:
|
||
return None
|
||
ymd = eligible[0]
|
||
|
||
ems, strikes_map = complete[ymd]
|
||
keys = list(strikes_map.keys())
|
||
side = (option_side or "").strip().lower() or None
|
||
mny = (moneyness or "").strip().lower() or None
|
||
if side in ("call", "put"):
|
||
if mny == "otm":
|
||
strike = pick_otm_strike(
|
||
keys,
|
||
mark_px,
|
||
option_side=side,
|
||
max_offset=float(otm_max_offset or 0),
|
||
)
|
||
elif mny == "atm":
|
||
strike = pick_atm_strike(keys, mark_px)
|
||
else:
|
||
# itm 或未指定:实值/平值(固定方向默认)
|
||
strike = pick_itm_or_atm_strike(keys, mark_px, option_side=side)
|
||
else:
|
||
strike = pick_atm_strike(keys, mark_px)
|
||
if strike is None:
|
||
return None
|
||
legs = strikes_map[strike]
|
||
return OptionPair(
|
||
expiry_ymd=ymd,
|
||
expiry_ms=ems,
|
||
strike=strike,
|
||
call_inst_id=legs["C"],
|
||
put_inst_id=legs["P"],
|
||
)
|
||
|
||
|
||
def normalize_contracts(contracts_or_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
"""若已是中性结构则原样返回;否则按 OKX 原始行解析(测试兼容)。"""
|
||
if not contracts_or_rows:
|
||
return []
|
||
sample = contracts_or_rows[0]
|
||
if "inst_id" in sample and "expiry_ymd" in sample:
|
||
return contracts_or_rows
|
||
from ..exchange.okx.parse import rows_to_option_contracts
|
||
|
||
return rows_to_option_contracts(contracts_or_rows)
|