3f6e67661b
Server validate on preview/start, UI filters and recommend templates, plus usability/security audit doc. Co-authored-by: Cursor <cursoragent@cursor.com>
286 lines
8.9 KiB
Python
286 lines
8.9 KiB
Python
"""对冲计划虚实值选约与校验.
|
|
|
|
永期(perp_options):期权腿仅允许实值或平值(禁虚值).
|
|
期期(options_options):两腿仅允许平值或虚值(禁实值).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import Any, Optional
|
|
|
|
|
|
def _env_float(name: str, default: float) -> float:
|
|
try:
|
|
return float(os.getenv(name) or default)
|
|
except (TypeError, ValueError):
|
|
return float(default)
|
|
|
|
|
|
def itm_max_dist_usd() -> float:
|
|
"""过深实值上限(USD).优先对冲专用,否则回退期权页."""
|
|
raw = (os.getenv("HEDGE_PLAN_ITM_MAX_DIST_USD") or "").strip()
|
|
if raw:
|
|
try:
|
|
return max(0.0, float(raw))
|
|
except ValueError:
|
|
pass
|
|
return max(0.0, _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0))
|
|
|
|
|
|
def min_option_hours() -> float:
|
|
return max(0.0, _env_float("HEDGE_PLAN_MIN_OPTION_HOURS", 8.0))
|
|
|
|
|
|
def min_option_leverage() -> float:
|
|
"""指数/卖一 最低杠杆门槛;0=不启用."""
|
|
return max(0.0, _env_float("HEDGE_PLAN_MIN_OPTION_LEVERAGE", 0.0))
|
|
|
|
|
|
def _sf(v: Any) -> Optional[float]:
|
|
if v is None or v == "":
|
|
return None
|
|
try:
|
|
return float(v)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def normalize_opt_type(opt_type: Any, inst_id: str = "") -> str:
|
|
o = str(opt_type or "").strip().upper()
|
|
if o in ("C", "CALL"):
|
|
return "C"
|
|
if o in ("P", "PUT"):
|
|
return "P"
|
|
inst = str(inst_id or "").upper()
|
|
if inst.endswith("-C") or inst.endswith("-CALL"):
|
|
return "C"
|
|
if inst.endswith("-P") or inst.endswith("-PUT"):
|
|
return "P"
|
|
return ""
|
|
|
|
|
|
def classify_moneyness(*, opt_type: str, strike: float, index_px: float) -> str:
|
|
"""itm / atm / otm / unknown.与 options_pricing_lib.option_moneyness 同口径."""
|
|
from lib.options.options_pricing_lib import option_moneyness
|
|
|
|
return option_moneyness(opt_type=opt_type, strike=strike, index_px=index_px)
|
|
|
|
|
|
def is_itm_or_atm(*, opt_type: str, strike: float, index_px: float) -> bool:
|
|
"""Call: K<=S(+atm 带);Put: K>=S.用 classify 结果含 atm/itm."""
|
|
m = classify_moneyness(opt_type=opt_type, strike=strike, index_px=index_px)
|
|
if m in ("itm", "atm"):
|
|
return True
|
|
# 几何兜底(与 eth_hedge_sim 一致),避免 atm 带边界漏判
|
|
o = normalize_opt_type(opt_type)
|
|
k = float(strike)
|
|
s = float(index_px)
|
|
if o == "C":
|
|
return k <= s + 1e-9
|
|
if o == "P":
|
|
return k >= s - 1e-9
|
|
return False
|
|
|
|
|
|
def is_atm_or_otm(*, opt_type: str, strike: float, index_px: float) -> bool:
|
|
m = classify_moneyness(opt_type=opt_type, strike=strike, index_px=index_px)
|
|
if m in ("atm", "otm"):
|
|
return True
|
|
o = normalize_opt_type(opt_type)
|
|
k = float(strike)
|
|
s = float(index_px)
|
|
if o == "C":
|
|
return k >= s - 1e-9 # 平值带内或虚值
|
|
if o == "P":
|
|
return k <= s + 1e-9
|
|
return False
|
|
|
|
|
|
def itm_depth_usd(*, opt_type: str, strike: float, index_px: float) -> float:
|
|
o = normalize_opt_type(opt_type)
|
|
k = float(strike)
|
|
s = float(index_px)
|
|
if o == "C" and k < s:
|
|
return s - k
|
|
if o == "P" and k > s:
|
|
return k - s
|
|
return 0.0
|
|
|
|
|
|
def parse_strike_from_inst(inst_id: str) -> Optional[float]:
|
|
"""从 OKX 合约名解析行权价: ETH-USD-260731-1800-P."""
|
|
parts = str(inst_id or "").strip().upper().split("-")
|
|
if len(parts) < 5:
|
|
return None
|
|
return _sf(parts[-2])
|
|
|
|
|
|
def pick_itm_or_atm_contract(
|
|
contracts: list[dict[str, Any]],
|
|
*,
|
|
opt_type: str,
|
|
index_px: float,
|
|
itm_max_dist: Optional[float] = None,
|
|
) -> Optional[dict[str, Any]]:
|
|
"""在合约列表中选距标的最近的实值/平值腿."""
|
|
want = normalize_opt_type(opt_type)
|
|
if not want or index_px <= 0:
|
|
return None
|
|
max_dist = itm_max_dist if itm_max_dist is not None else itm_max_dist_usd()
|
|
cands: list[tuple[float, float, dict[str, Any]]] = []
|
|
for c in contracts or []:
|
|
if normalize_opt_type(c.get("opt_type"), str(c.get("inst_id") or "")) != want:
|
|
continue
|
|
k = _sf(c.get("strike"))
|
|
if k is None:
|
|
continue
|
|
if not is_itm_or_atm(opt_type=want, strike=k, index_px=index_px):
|
|
continue
|
|
depth = itm_depth_usd(opt_type=want, strike=k, index_px=index_px)
|
|
if max_dist > 0 and depth > max_dist:
|
|
continue
|
|
cands.append((abs(k - index_px), k, c))
|
|
if not cands:
|
|
return None
|
|
cands.sort(key=lambda x: (x[0], x[1]))
|
|
return cands[0][2]
|
|
|
|
|
|
def pick_atm_or_otm_contract(
|
|
contracts: list[dict[str, Any]],
|
|
*,
|
|
opt_type: str,
|
|
index_px: float,
|
|
prefer: str = "atm",
|
|
) -> Optional[dict[str, Any]]:
|
|
"""选平值或虚值腿.prefer=atm 取距标的最近;prefer=otm 取最近虚值(不含实值)."""
|
|
want = normalize_opt_type(opt_type)
|
|
if not want or index_px <= 0:
|
|
return None
|
|
prefer_l = (prefer or "atm").strip().lower()
|
|
cands: list[tuple[float, float, dict[str, Any]]] = []
|
|
for c in contracts or []:
|
|
if normalize_opt_type(c.get("opt_type"), str(c.get("inst_id") or "")) != want:
|
|
continue
|
|
k = _sf(c.get("strike"))
|
|
if k is None:
|
|
continue
|
|
if not is_atm_or_otm(opt_type=want, strike=k, index_px=index_px):
|
|
continue
|
|
m = classify_moneyness(opt_type=want, strike=k, index_px=index_px)
|
|
if prefer_l == "otm" and m != "otm":
|
|
continue
|
|
if prefer_l == "atm" and m == "otm":
|
|
# 仍可入选,但排序靠后(先 atm)
|
|
cands.append((1_000_000 + abs(k - index_px), k, c))
|
|
else:
|
|
cands.append((abs(k - index_px), k, c))
|
|
if not cands:
|
|
return None
|
|
cands.sort(key=lambda x: (x[0], x[1]))
|
|
return cands[0][2]
|
|
|
|
|
|
def recommend_oo_legs(
|
|
contracts: list[dict[str, Any]],
|
|
*,
|
|
index_px: float,
|
|
template: str = "atm_straddle",
|
|
) -> Optional[tuple[dict[str, Any], dict[str, Any]]]:
|
|
"""期期推荐两腿.atm_straddle=最近平值 Call+Put;double_otm=最近虚值 Call+Put."""
|
|
tpl = (template or "atm_straddle").strip().lower()
|
|
prefer = "otm" if tpl in ("double_otm", "otm_otm", "otm") else "atm"
|
|
call = pick_atm_or_otm_contract(
|
|
contracts, opt_type="C", index_px=index_px, prefer=prefer
|
|
)
|
|
put = pick_atm_or_otm_contract(
|
|
contracts, opt_type="P", index_px=index_px, prefer=prefer
|
|
)
|
|
if not call or not put:
|
|
return None
|
|
if str(call.get("inst_id") or "") == str(put.get("inst_id") or ""):
|
|
return None
|
|
return call, put
|
|
|
|
|
|
def validate_po_option_moneyness(
|
|
*,
|
|
opt_type: str,
|
|
strike: Any,
|
|
index_px: Any,
|
|
ask: Any = None,
|
|
hours_to_expiry: Any = None,
|
|
) -> Optional[str]:
|
|
"""永期保险腿校验;返回错误文案或 None."""
|
|
o = normalize_opt_type(opt_type)
|
|
k = _sf(strike)
|
|
s = _sf(index_px)
|
|
if o not in ("C", "P"):
|
|
return "期权类型无效"
|
|
if k is None or s is None or s <= 0:
|
|
return "行权价或指数无效,无法校验虚实值"
|
|
if not is_itm_or_atm(opt_type=o, strike=k, index_px=s):
|
|
return "永期保险腿须为实值或平值,不可选虚值"
|
|
max_dist = itm_max_dist_usd()
|
|
depth = itm_depth_usd(opt_type=o, strike=k, index_px=s)
|
|
if max_dist > 0 and depth > max_dist:
|
|
return f"实值过深(距现价 {depth:.1f}U > {max_dist:.0f}U),请换更接近平值的档"
|
|
min_h = min_option_hours()
|
|
h = _sf(hours_to_expiry)
|
|
if min_h > 0 and h is not None and h < min_h:
|
|
return f"剩余到期约 {h:.1f}h,低于最低 {min_h:.0f}h"
|
|
min_lev = min_option_leverage()
|
|
a = _sf(ask)
|
|
if min_lev > 0 and a is not None and a > 0:
|
|
lev = s / a
|
|
if lev < min_lev:
|
|
return f"期权杠杆 S/ask≈{lev:.0f} 低于门槛 {min_lev:.0f}"
|
|
return None
|
|
|
|
|
|
def validate_oo_leg_moneyness(
|
|
*,
|
|
opt_type: str,
|
|
strike: Any,
|
|
index_px: Any,
|
|
role: str = "腿",
|
|
) -> Optional[str]:
|
|
o = normalize_opt_type(opt_type)
|
|
k = _sf(strike)
|
|
s = _sf(index_px)
|
|
if o not in ("C", "P"):
|
|
return f"{role}期权类型无效"
|
|
if k is None or s is None or s <= 0:
|
|
return f"{role}行权价或指数无效,无法校验虚实值"
|
|
m = classify_moneyness(opt_type=o, strike=k, index_px=s)
|
|
if m == "itm":
|
|
return f"{role}须为平值或虚值,不可选实值"
|
|
if not is_atm_or_otm(opt_type=o, strike=k, index_px=s):
|
|
return f"{role}须为平值或虚值"
|
|
return None
|
|
|
|
|
|
def validate_oo_legs_moneyness(
|
|
leg_a: dict[str, Any],
|
|
leg_b: dict[str, Any],
|
|
*,
|
|
index_px: Any,
|
|
) -> Optional[str]:
|
|
err = validate_oo_leg_moneyness(
|
|
opt_type=leg_a.get("opt_type"),
|
|
strike=leg_a.get("strike"),
|
|
index_px=index_px,
|
|
role="腿A",
|
|
)
|
|
if err:
|
|
return err
|
|
err = validate_oo_leg_moneyness(
|
|
opt_type=leg_b.get("opt_type"),
|
|
strike=leg_b.get("strike"),
|
|
index_px=index_px,
|
|
role="腿B",
|
|
)
|
|
if err:
|
|
return err
|
|
return None
|