Lock hedge-plan option selection to ITM/ATM (perp) and ATM/OTM (OO).
Server validate on preview/start, UI filters and recommend templates, plus usability/security audit doc. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
"""对冲计划虚实值选约与校验.
|
||||
|
||||
永期(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
|
||||
@@ -965,6 +965,26 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
|
||||
return "做多须满足 止损 < 入场 < 止盈"
|
||||
if direction == "short" and not (tp < entry < sl):
|
||||
return "做空须满足 止盈 < 入场 < 止损"
|
||||
from lib.hedge_plan.hedge_plan_moneyness_lib import (
|
||||
parse_strike_from_inst,
|
||||
validate_po_option_moneyness,
|
||||
)
|
||||
|
||||
strike = body.get("strike")
|
||||
if strike in (None, ""):
|
||||
strike = parse_strike_from_inst(str(body.get("opt_inst_id") or ""))
|
||||
index_px = body.get("index_px")
|
||||
if index_px in (None, ""):
|
||||
index_px = entry
|
||||
money_err = validate_po_option_moneyness(
|
||||
opt_type=opt_type,
|
||||
strike=strike,
|
||||
index_px=index_px,
|
||||
ask=body.get("ask"),
|
||||
hours_to_expiry=body.get("hours_to_expiry"),
|
||||
)
|
||||
if money_err:
|
||||
return money_err
|
||||
return None
|
||||
if pt == "options_options":
|
||||
a = body.get("leg_a") or {}
|
||||
@@ -985,6 +1005,37 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
|
||||
return "上破目标价必须大于下破目标价"
|
||||
except (TypeError, ValueError):
|
||||
return "目标价无效"
|
||||
from lib.hedge_plan.hedge_plan_moneyness_lib import (
|
||||
parse_strike_from_inst,
|
||||
validate_oo_legs_moneyness,
|
||||
)
|
||||
|
||||
def _leg_for_money(leg: dict) -> dict:
|
||||
strike = leg.get("strike")
|
||||
if strike in (None, ""):
|
||||
strike = parse_strike_from_inst(str(leg.get("inst_id") or ""))
|
||||
opt_type = leg.get("opt_type")
|
||||
if not opt_type:
|
||||
inst = str(leg.get("inst_id") or "").upper()
|
||||
if inst.endswith("-C"):
|
||||
opt_type = "C"
|
||||
elif inst.endswith("-P"):
|
||||
opt_type = "P"
|
||||
return {"opt_type": opt_type, "strike": strike}
|
||||
|
||||
index_px = body.get("index_px")
|
||||
if index_px in (None, ""):
|
||||
try:
|
||||
index_px = (float(up) + float(down)) / 2.0
|
||||
except (TypeError, ValueError):
|
||||
index_px = None
|
||||
money_err = validate_oo_legs_moneyness(
|
||||
_leg_for_money(a),
|
||||
_leg_for_money(b),
|
||||
index_px=index_px,
|
||||
)
|
||||
if money_err:
|
||||
return money_err
|
||||
return None
|
||||
return "未知计划类型"
|
||||
|
||||
|
||||
@@ -891,6 +891,8 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
|
||||
|
||||
def _preview_po(body: dict[str, Any]) -> dict[str, Any]:
|
||||
from lib.hedge_plan.hedge_plan_moneyness_lib import validate_po_option_moneyness
|
||||
|
||||
direction = str(body.get("direction") or "long").lower()
|
||||
entry = float(body["entry"])
|
||||
tp = float(body["tp"])
|
||||
@@ -908,6 +910,16 @@ def _preview_po(body: dict[str, Any]) -> dict[str, Any]:
|
||||
raise ValueError("缺少权利金或卖一价")
|
||||
premium = option_premium_total(ask=float(ask), sheets=sheets, ct_mult=ct_mult)
|
||||
index_px = body.get("index_px")
|
||||
idx_for_money = float(index_px) if index_px is not None else entry
|
||||
money_err = validate_po_option_moneyness(
|
||||
opt_type=opt_type,
|
||||
strike=strike,
|
||||
index_px=idx_for_money,
|
||||
ask=ask,
|
||||
hours_to_expiry=body.get("hours_to_expiry"),
|
||||
)
|
||||
if money_err:
|
||||
raise ValueError(money_err)
|
||||
return build_perp_options_preview(
|
||||
direction=direction,
|
||||
entry=entry,
|
||||
@@ -925,6 +937,8 @@ def _preview_po(body: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
|
||||
from lib.hedge_plan.hedge_plan_moneyness_lib import validate_oo_legs_moneyness
|
||||
|
||||
up = body.get("target_price_up")
|
||||
down = body.get("target_price_down")
|
||||
legacy = body.get("target_price")
|
||||
@@ -952,6 +966,9 @@ def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
|
||||
)
|
||||
if leg.get("premium_paid") is None:
|
||||
raise ValueError(f"缺少 {name} 权利金")
|
||||
money_err = validate_oo_legs_moneyness(leg_a, leg_b, index_px=index_px)
|
||||
if money_err:
|
||||
raise ValueError(money_err)
|
||||
return build_options_options_preview(
|
||||
target_price_up=up_f,
|
||||
target_price_down=down_f,
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<div class="tip-collapse-body rule-tip">
|
||||
<p><strong>账户</strong>:永续腿走<strong>合约账户</strong>(USDT);保险期权走<strong>期权账户</strong>(USDC)。两账户分开下单、资金不互通。</p>
|
||||
<p><strong>下单</strong>:先「计算」再「启动」。启动瞬间会再拉卖一并以 IOC 等完全成交;半腿失败可补开或「结束计划」(不平仓)。永期开仓需全仓计仓 + 对冲实盘门禁。</p>
|
||||
<p><strong>板块</strong>:左填永续开仓/止盈止损与张数;右选保险腿(做多配 Put、做空配 Call)。止盈后保险腿默认可持有;止损会联动平期权。</p>
|
||||
<p><strong>板块</strong>:左填永续开仓/止盈止损与张数;右选保险腿(做多配 Put、做空配 Call)。<strong>保险腿仅允许实值或平值</strong>(禁虚值)。止盈后保险腿默认可持有;止损会联动平期权。</p>
|
||||
</div>
|
||||
</details>
|
||||
<div class="form-row hp-uly-row">
|
||||
@@ -91,9 +91,9 @@
|
||||
<h2>期权 · <span id="hp-opt-type-label">Put</span> <span class="muted hp-acct-tag" id="hp-opt-acct-tag">期权账户</span></h2>
|
||||
<div class="form-row hp-opt-toolbar hp-po-opt-toolbar">
|
||||
<select id="hp-exp-select"><option value="">选择到期日</option></select>
|
||||
<button type="button" class="btn-secondary hp-money-btn active" data-money="all">全部</button>
|
||||
<button type="button" class="btn-secondary hp-money-btn" data-money="itm">实值</button>
|
||||
<button type="button" class="btn-secondary hp-money-btn" data-money="otm">虚值</button>
|
||||
<button type="button" class="btn-secondary hp-money-btn active" data-money="itm" title="实值+平值">实值/平值</button>
|
||||
<button type="button" class="btn-secondary hp-money-btn" data-money="atm" title="仅平值">仅平值</button>
|
||||
<button type="button" class="btn-secondary" id="hp-recommend-opt" title="选距指数最近的实值/平值">推荐</button>
|
||||
<button type="button" class="btn-secondary" id="hp-load-chain">刷新链</button>
|
||||
<span id="hp-index-line" class="hp-po-index" aria-live="polite">指数 —</span>
|
||||
</div>
|
||||
@@ -135,7 +135,7 @@
|
||||
<div class="tip-collapse-body rule-tip">
|
||||
<p><strong>账户</strong>:两腿都在<strong>期权账户</strong>。可用预算 = min(交易 USDC × 对冲缓冲 <strong id="hp-oo-buf-ratio">{{ '%.2f'|format(hedge_plan_budget_buffer|default(0.95)|float) }}</strong>, 单笔预算);可在 env「对冲预算缓冲比例」改。</p>
|
||||
<p><strong>下单</strong>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p>
|
||||
<p><strong>板块</strong>:左填上破/下破与张数模式(同张数/做多/做空);右 T 型选腿。「全平」= 盈利腿平后清另一腿;「到期平」= 另一腿持有至到期。</p>
|
||||
<p><strong>板块</strong>:左填上破/下破与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。「全平」= 盈利腿平后清另一腿;「到期平」= 另一腿持有至到期。</p>
|
||||
</div>
|
||||
</details>
|
||||
<div class="form-row hp-uly-row">
|
||||
@@ -185,6 +185,11 @@
|
||||
<h2>期权 T 型报价</h2>
|
||||
<div class="form-row">
|
||||
<select id="hp-oo-exp-select"><option value="">选择到期日</option></select>
|
||||
<button type="button" class="btn-secondary hp-oo-money-btn active" data-oo-money="atm_otm" title="平值+虚值">平/虚</button>
|
||||
<button type="button" class="btn-secondary hp-oo-money-btn" data-oo-money="atm" title="仅平值">仅平值</button>
|
||||
<button type="button" class="btn-secondary hp-oo-money-btn" data-oo-money="otm" title="仅虚值">仅虚值</button>
|
||||
<button type="button" class="btn-secondary" id="hp-oo-recommend-atm" title="最近平值 Call+Put">推荐跨式</button>
|
||||
<button type="button" class="btn-secondary" id="hp-oo-recommend-otm" title="最近虚值 Call+Put">推荐双虚</button>
|
||||
<button type="button" class="btn-secondary" id="hp-oo-load-chain">刷新链</button>
|
||||
</div>
|
||||
<div class="options-strike-table-wrap options-strike-table-wrap--t">
|
||||
|
||||
Reference in New Issue
Block a user