Files
eth_hedge_sim/backend/app/strategy/selection.py
T
dekun 457838ef64 Extend semi-auto with ITM/ATM/OTM, offset, and 1:4 sizing.
Moneyness and perp:option units are plan-only; OTM enforces leverage >=180.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:42:57 +08:00

287 lines
8.6 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 平值 + 期权杠杆(交易所无关)。"""
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 pick_otm_strike(
strikes: list[float],
mark_px: float,
*,
option_side: str,
max_offset: float,
) -> float | None:
"""
虚值:Call K>S、Put K<S;取最接近标的且 |KS|≤max_offset 的一档。
"""
if not strikes or mark_px <= 0:
return None
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 None
if not cands:
return None
return min(cands, key=lambda s: (abs(s - spot), s))
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≤SPut: 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)