Initial standalone crypto_okx with one-click deploy.

Add deploy/manage.sh bootstrap for git.bz121.com/dekun/crypto_okx and point docs at this repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-13 20:00:59 +08:00
commit a1abe159fa
238 changed files with 69214 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# hedge_plan package
@@ -0,0 +1,86 @@
"""对冲计划与单独期权开仓互斥门控.
默认开启:有进行中对冲计划时禁止单独开期权;有纯期权持仓时禁止启动对冲计划.
关闭 HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE 后两边可同时开.
"""
from __future__ import annotations
import os
from typing import Any, Callable, Optional
def _env_bool(key: str, default: bool = False) -> bool:
v = (os.getenv(key) or "").strip().lower()
if not v:
return default
return v in ("1", "true", "yes", "on")
def mutual_exclusive_enabled() -> bool:
return _env_bool("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", True)
def block_standalone_option_open_msg(conn: Any) -> Optional[str]:
"""若应拦截单独开期权,返回中文原因;否则 None."""
if not mutual_exclusive_enabled():
return None
try:
from lib.hedge_plan.hedge_plan_db import count_active_plans, init_hedge_plan_tables
init_hedge_plan_tables(conn)
if count_active_plans(conn) > 0:
return "存在进行中对冲计划,禁止单独开期权(可在 env「对冲与期权互斥门控」关闭)"
except Exception:
return "互斥门控校验失败,暂禁止单独开期权"
return None
def _pos_nonzero(raw: dict[str, Any]) -> bool:
try:
return abs(float(raw.get("pos") or 0)) > 1e-12
except (TypeError, ValueError):
return False
def has_standalone_option_position(conn: Any, raw_positions: list[dict[str, Any]] | None) -> bool:
"""交易所期权持仓中,是否存在未挂在进行中对冲计划腿上的仓位."""
if not raw_positions:
return False
from lib.instance.instance_dashboard_lib import _resolve_options_source
for p in raw_positions:
if not isinstance(p, dict) or not _pos_nonzero(p):
continue
inst = str(p.get("instId") or p.get("inst_id") or "").strip()
if not inst:
continue
source, _, _ = _resolve_options_source(conn, inst)
if source == "option":
return True
return False
def block_hedge_plan_start_msg(
conn: Any,
*,
fetch_positions: Optional[Callable[[Any], Any]] = None,
exchange: Any = None,
raw_positions: list[dict[str, Any]] | None = None,
) -> Optional[str]:
"""若应拦截启动对冲计划,返回中文原因;否则 None."""
if not mutual_exclusive_enabled():
return None
rows = raw_positions
if rows is None:
if fetch_positions is None or exchange is None:
return None
try:
rows = fetch_positions(exchange) or []
except Exception:
return "获取期权持仓失败,暂禁止启动对冲计划"
try:
if has_standalone_option_position(conn, rows):
return "存在单独期权持仓,禁止启动对冲计划(可在 env「对冲与期权互斥门控」关闭)"
except Exception:
return "互斥门控校验失败,暂禁止启动对冲计划"
return None
+782
View File
@@ -0,0 +1,782 @@
"""对冲计划:情景测算与全仓建议仓(纯函数,无 IO)."""
from __future__ import annotations
from typing import Any, Optional
def _f(v: Any) -> Optional[float]:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def perp_coin_amount(*, contracts: float, contract_size: float) -> float:
return float(contracts) * float(contract_size or 1.0)
def perp_pnl(
*,
direction: str,
entry: float,
exit_px: float,
contracts: float,
contract_size: float,
) -> float:
coins = perp_coin_amount(contracts=contracts, contract_size=contract_size)
d = (direction or "long").strip().lower()
if d == "short":
return (float(entry) - float(exit_px)) * coins
return (float(exit_px) - float(entry)) * coins
def option_premium_total(*, ask: float, sheets: float, ct_mult: float) -> float:
"""卖一报价为每 1 币;权利金 = ask × 张数 × ct_mult."""
return float(ask) * float(sheets) * float(ct_mult or 0.01)
def option_expiry_pnl(
*,
opt_type: str,
strike: float,
spot: float,
sheets: float,
ct_mult: float,
premium_paid: float,
) -> float:
o = (opt_type or "").strip().upper()
intrinsic_per_coin = 0.0
if o in ("C", "CALL"):
intrinsic_per_coin = max(0.0, float(spot) - float(strike))
elif o in ("P", "PUT"):
intrinsic_per_coin = max(0.0, float(strike) - float(spot))
else:
return -float(premium_paid)
value = intrinsic_per_coin * float(sheets) * float(ct_mult or 0.01)
return value - float(premium_paid)
def spot_from_expiry_intrinsic_profit(
*,
opt_type: str,
strike: float,
sheets: float,
ct_mult: float,
premium_paid: float,
profit: float,
) -> float | None:
"""按到期实值反推现货价:使该腿到期盈亏 ≈ profit.
到期价值=实值×张数×乘数;盈亏=价值−权利金 → 实值/币=(profit+权利金)/(张数×乘数).
Call: spot=K+实值/币; Put: spot=K−实值/币.
"""
try:
k = float(strike)
n = float(sheets or 0)
ct = float(ct_mult or 0.01)
prem = float(premium_paid or 0)
pnl = float(profit)
except (TypeError, ValueError):
return None
denom = n * ct
if denom <= 0:
return None
need = (pnl + prem) / denom
if need < 0:
need = 0.0
o = (opt_type or "").strip().upper()
if o in ("C", "CALL"):
return round(k + need, 2)
if o in ("P", "PUT"):
return round(k - need, 2)
return None
def suggest_contracts_from_notional(
*,
notional: float,
entry: float,
contract_size: float,
) -> float:
if entry <= 0 or contract_size <= 0 or notional <= 0:
return 0.0
return float(notional) / (float(entry) * float(contract_size))
def floor_contracts_to_precision(contracts: float, decimals: int) -> float:
"""按交易所张数精度向下取整,避免建议张数超过可用保证金."""
import math
raw = float(contracts or 0.0)
if raw <= 0:
return 0.0
try:
d = int(decimals)
except (TypeError, ValueError):
d = 0
if d <= 0:
return float(math.floor(raw + 1e-12))
scale = 10**d
return math.floor(raw * scale + 1e-12) / scale
def option_unit_cost_usdc(*, ask: float, ct_mult: float) -> float:
"""单张权利金(USDC) = 卖一价 × ct_mult."""
a = _f(ask)
if a is None or a <= 0:
return 0.0
return float(a) * float(ct_mult or 0.01)
def resolve_oo_budget_usdc(
*,
trading_usdc: Any,
trade_budget_usdc: Any,
buffer_ratio: Any = 0.95,
) -> dict[str, Any]:
"""期期可用预算 = min(交易户×buffer, 单笔预算)."""
import math
trading = _f(trading_usdc)
cap = _f(trade_budget_usdc)
buf = _f(buffer_ratio)
if buf is None or buf <= 0:
buf = 0.95
if buf > 1:
buf = 1.0
trading_cap = None if trading is None else max(0.0, float(trading) * float(buf))
trade_cap = None if cap is None else max(0.0, float(cap))
if trading_cap is None and trade_cap is None:
return {
"ok": False,
"budget_usdc": 0.0,
"trading_cap": None,
"trade_budget_cap": None,
"buffer_ratio": float(buf),
"msg": "缺少交易户余额与单笔预算",
}
if trading_cap is None:
budget = float(trade_cap or 0.0)
elif trade_cap is None:
budget = float(trading_cap)
else:
budget = min(float(trading_cap), float(trade_cap))
budget = float(math.floor(budget * 1e6 + 1e-12) / 1e6)
return {
"ok": budget > 0,
"budget_usdc": budget,
"trading_cap": None if trading_cap is None else round(float(trading_cap), 6),
"trade_budget_cap": None if trade_cap is None else round(float(trade_cap), 6),
"buffer_ratio": float(buf),
"msg": "" if budget > 0 else "可用预算为 0",
}
def _cap_sheets_by_ask_depth(sheets: int, ask_sz: Any) -> int:
import math
n = max(0, int(sheets))
depth = _f(ask_sz)
if depth is None:
return n
if depth <= 0:
return 0
return min(n, int(math.floor(float(depth) + 1e-12)))
def _normalize_oo_sheets_mode(mode: str) -> str:
m = (mode or "same_sheets").strip().lower()
if m in ("long_bias", "bias_long", "long", "做多"):
return "long_bias"
if m in ("short_bias", "bias_short", "short", "做空"):
return "short_bias"
# 旧「均分」兼容:按预算 50/50(页面已移除)
if m in ("split", "equal_budget", "split_budget", "均分"):
return "split_budget"
return "same_sheets"
def _normalize_oo_bias_split_by(raw: Any) -> str:
v = str(raw or "budget").strip().lower()
if v in ("sheets", "qty", "quantity", "张数"):
return "sheets"
return "budget"
def _clamp_oo_bias_ratio(raw: Any, default: float = 0.7) -> float:
try:
r = float(raw)
except (TypeError, ValueError):
r = float(default)
if r <= 0 or r >= 1:
r = float(default)
return r
def _oo_call_put_leg_index(opt_type_a: str, opt_type_b: str) -> tuple[Optional[str], Optional[str], str]:
"""返回 (call_side, put_side, err);side 为 'a'/'b'."""
a = (opt_type_a or "").strip().upper()
b = (opt_type_b or "").strip().upper()
if a.startswith("C"):
a = "C"
elif a.startswith("P"):
a = "P"
if b.startswith("C"):
b = "C"
elif b.startswith("P"):
b = "P"
if {a, b} != {"C", "P"}:
return None, None, "做多/做空需一腿 Call、一腿 Put"
call_side = "a" if a == "C" else "b"
put_side = "b" if call_side == "a" else "a"
return call_side, put_side, ""
def suggest_oo_sheets(
*,
mode: str,
budget_usdc: float,
ask_a: float,
ct_mult_a: float = 0.01,
ask_sz_a: Any = None,
opt_type_a: str = "",
ask_b: float,
ct_mult_b: float = 0.01,
ask_sz_b: Any = None,
opt_type_b: str = "",
bias_split_by: str = "budget",
bias_ratio: float = 0.7,
) -> dict[str, Any]:
"""期期建议张数:same_sheets / long_bias / short_bias(及旧 split_budget)."""
import math
m = _normalize_oo_sheets_mode(mode)
split_by = _normalize_oo_bias_split_by(bias_split_by)
ratio = _clamp_oo_bias_ratio(bias_ratio)
budget = max(0.0, float(budget_usdc or 0.0))
cost_a = option_unit_cost_usdc(ask=ask_a, ct_mult=ct_mult_a)
cost_b = option_unit_cost_usdc(ask=ask_b, ct_mult=ct_mult_b)
def _fail(msg: str, n_a: int = 0, n_b: int = 0) -> dict[str, Any]:
return {
"mode": m,
"sheets_a": n_a,
"sheets_b": n_b,
"cost_a": round(cost_a, 8),
"cost_b": round(cost_b, 8),
"premium_est": round(cost_a * n_a + cost_b * n_b, 6),
"ok": False,
"msg": msg,
"bias_split_by": split_by,
"bias_ratio": ratio,
}
if budget <= 0:
return _fail("可用预算为 0")
if cost_a <= 0 or cost_b <= 0:
return _fail("缺少有效卖一价,无法建议张数")
pair = cost_a + cost_b
n_pair = int(math.floor(budget / pair + 1e-12)) if pair > 0 else 0
# 与同张数一致:先按预算得 n,再各自深度封顶后取 min
n_same = min(
_cap_sheets_by_ask_depth(n_pair, ask_sz_a),
_cap_sheets_by_ask_depth(n_pair, ask_sz_b),
)
if m == "same_sheets":
n_a = n_same
n_b = n_same
elif m == "split_budget":
half = budget / 2.0
n_a = int(math.floor(half / cost_a + 1e-12))
n_b = int(math.floor(half / cost_b + 1e-12))
n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a)
n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b)
else:
call_side, put_side, err = _oo_call_put_leg_index(opt_type_a, opt_type_b)
if err:
return _fail(err)
major_is_call = m == "long_bias"
if split_by == "sheets":
# 总张数 = 同张数两侧合计(每腿 n → 共 2n),再按比例拆到 Call/Put
total = int(n_same) * 2
if total < 2:
return _fail("同张数总规模不足 2,无法按比例拆分")
major_n = int(round(total * ratio))
major_n = max(1, min(major_n, total - 1))
minor_n = total - major_n
n_call = major_n if major_is_call else minor_n
n_put = minor_n if major_is_call else major_n
else:
maj_budget = budget * ratio
min_budget = budget * (1.0 - ratio)
cost_call = cost_a if call_side == "a" else cost_b
cost_put = cost_b if call_side == "a" else cost_a
if major_is_call:
n_call = int(math.floor(maj_budget / cost_call + 1e-12)) if cost_call > 0 else 0
n_put = int(math.floor(min_budget / cost_put + 1e-12)) if cost_put > 0 else 0
else:
n_put = int(math.floor(maj_budget / cost_put + 1e-12)) if cost_put > 0 else 0
n_call = int(math.floor(min_budget / cost_call + 1e-12)) if cost_call > 0 else 0
n_a = n_call if call_side == "a" else n_put
n_b = n_put if call_side == "a" else n_call
n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a)
n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b)
prem = cost_a * n_a + cost_b * n_b
ok = n_a >= 1 and n_b >= 1
msg = "" if ok else "预算不够开 1+1(或卖一深度不足)"
return {
"mode": m,
"sheets_a": n_a,
"sheets_b": n_b,
"cost_a": round(cost_a, 8),
"cost_b": round(cost_b, 8),
"premium_est": round(prem, 6),
"ok": ok,
"msg": msg,
"bias_split_by": split_by,
"bias_ratio": ratio,
}
def build_perp_options_preview(
*,
direction: str,
entry: float,
tp: float,
sl: float,
contracts: float,
contract_size: float,
opt_type: str,
strike: float,
sheets: float,
ct_mult: float,
premium_paid: float,
index_px: Optional[float] = None,
) -> dict[str, Any]:
"""
永期情景.
止盈账:永续止盈盈利 - 权利金.
止损账:期权到期内在(按 SL 价) - 永续止损亏损额.
"""
d = (direction or "long").strip().lower()
pnl_tp_perp = perp_pnl(
direction=d, entry=entry, exit_px=tp, contracts=contracts, contract_size=contract_size
)
pnl_sl_perp = perp_pnl(
direction=d, entry=entry, exit_px=sl, contracts=contracts, contract_size=contract_size
)
# 止盈统计口径
tp_total = float(pnl_tp_perp) - float(premium_paid)
# 止损:期权按 SL 价结算内在 - |永续亏损|
opt_at_sl = option_expiry_pnl(
opt_type=opt_type,
strike=strike,
spot=sl,
sheets=sheets,
ct_mult=ct_mult,
premium_paid=premium_paid,
)
sl_total = float(opt_at_sl) - abs(float(pnl_sl_perp)) if pnl_sl_perp < 0 else float(opt_at_sl) + float(
pnl_sl_perp
)
# 有符号相加更稳:期权盈亏 + 永续盈亏
sl_total_signed = float(opt_at_sl) + float(pnl_sl_perp)
spot = float(index_px) if index_px is not None else float(entry)
opt_flat = option_expiry_pnl(
opt_type=opt_type,
strike=strike,
spot=spot,
sheets=sheets,
ct_mult=ct_mult,
premium_paid=premium_paid,
)
flat_total = 0.0 + float(opt_flat)
opt_at_tp = option_expiry_pnl(
opt_type=opt_type,
strike=strike,
spot=tp,
sheets=sheets,
ct_mult=ct_mult,
premium_paid=premium_paid,
)
return {
"plan_type": "perp_options",
"direction": d,
"contracts": contracts,
"coin_amount": perp_coin_amount(contracts=contracts, contract_size=contract_size),
"premium_paid": round(float(premium_paid), 6),
"scenarios": [
{
"id": "tp",
"label": "止盈(计划结束口径)",
"spot": tp,
"perp_pnl": round(pnl_tp_perp, 4),
"options_pnl": round(-float(premium_paid), 4),
"total": round(tp_total, 4),
"note": "止盈盈利 权利金;期权可不强平",
},
{
"id": "sl",
"label": "止损(计划结束口径)",
"spot": sl,
"perp_pnl": round(pnl_sl_perp, 4),
"options_pnl": round(opt_at_sl, 4),
"total": round(sl_total_signed, 4),
"note": "期权盈利 − 永续亏损(有符号相加);期权须强平",
},
{
"id": "flat",
"label": "到期·现价附近",
"spot": spot,
"perp_pnl": 0.0,
"options_pnl": round(opt_flat, 4),
"total": round(flat_total, 4),
"note": "示意:永续未动,期权按到期内在",
},
{
"id": "expiry_tp",
"label": "到期·止盈价",
"spot": tp,
"perp_pnl": round(pnl_tp_perp, 4),
"options_pnl": round(opt_at_tp, 4),
"total": round(pnl_tp_perp + opt_at_tp, 4),
"note": "若期权拿到 TP 价到期(参考)",
},
{
"id": "expiry_sl",
"label": "到期·止损价",
"spot": sl,
"perp_pnl": round(pnl_sl_perp, 4),
"options_pnl": round(opt_at_sl, 4),
"total": round(pnl_sl_perp + opt_at_sl, 4),
"note": "与止损口径相近(期权用内在)",
},
],
"summary": {
"tp_total": round(tp_total, 4),
"sl_total": round(sl_total_signed, 4),
"premium_paid": round(float(premium_paid), 4),
"hedge_ratio_at_sl": _hedge_ratio(opt_at_sl, pnl_sl_perp),
},
}
def _hedge_ratio(opt_pnl: float, perp_pnl: float) -> Optional[float]:
loss = abs(float(perp_pnl)) if float(perp_pnl) < 0 else 0.0
if loss <= 1e-12:
return None
if float(opt_pnl) <= 0:
return 0.0
return round(float(opt_pnl) / loss * 100.0, 2)
def build_options_options_preview(
*,
target_price: float | None = None,
target_price_up: float | None = None,
target_price_down: float | None = None,
profit_rr: float | None = None,
index_px: float,
leg_a: dict[str, Any],
leg_b: dict[str, Any],
) -> dict[str, Any]:
"""期期情景:盈亏比达标 / 到期现价 / 最大保费损耗.
新口径优先 profit_rr(盈利金额/总权利金);若未传则兼容旧上/下破目标价.
残值按亏损腿本合约权利金的 20% 计.
"""
def _leg_pnl(leg: dict[str, Any], spot: float) -> float:
return option_expiry_pnl(
opt_type=str(leg.get("opt_type") or ""),
strike=float(leg["strike"]),
spot=spot,
sheets=float(leg.get("sheets") or 0),
ct_mult=float(leg.get("ct_mult") or 0.01),
premium_paid=float(leg.get("premium_paid") or 0),
)
prem_a = float(leg_a.get("premium_paid") or 0)
prem_b = float(leg_b.get("premium_paid") or 0)
prem = prem_a + prem_b
rr = float(profit_rr) if profit_rr is not None else None
# 新:盈亏比情景(不依赖指数上下破价)
if rr is not None and rr > 0:
# 盈利腿达 RR:盈利金额 = rr × 总权利金;亏损腿按全亏 / 本合约残值20%回收
win_profit = rr * prem
a_at_a = win_profit
b_at_a_full = -prem_b
b_at_a_res = -prem_b * 0.8 # 本合约回收 20%
b_at_b = win_profit
a_at_b_full = -prem_a
a_at_b_res = -prem_a * 0.8
spot_a = spot_from_expiry_intrinsic_profit(
opt_type=str(leg_a.get("opt_type") or ""),
strike=float(leg_a["strike"]),
sheets=float(leg_a.get("sheets") or 0),
ct_mult=float(leg_a.get("ct_mult") or 0.01),
premium_paid=prem_a,
profit=win_profit,
)
spot_b = spot_from_expiry_intrinsic_profit(
opt_type=str(leg_b.get("opt_type") or ""),
strike=float(leg_b["strike"]),
sheets=float(leg_b.get("sheets") or 0),
ct_mult=float(leg_b.get("ct_mult") or 0.01),
premium_paid=prem_b,
profit=win_profit,
)
a_flat = _leg_pnl(leg_a, index_px)
b_flat = _leg_pnl(leg_b, index_px)
flat_total = a_flat + b_flat
return {
"plan_type": "options_options",
"premium_paid": round(prem, 6),
"profit_rr": rr,
"target_price": None,
"target_price_up": None,
"target_price_down": None,
"winner_at_up": "a",
"winner_at_down": "b",
"winner_at_target": "a",
"scenarios": [
{
"id": "rr_leg_a_full",
"label": f"腿A达盈亏比{rr:g}(亏腿全损)",
"spot": spot_a,
"leg_a_pnl": round(a_at_a, 4),
"leg_b_pnl": round(b_at_a_full, 4),
"total": round(a_at_a + b_at_a_full, 4),
"note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏",
},
{
"id": "rr_leg_b_full",
"label": f"腿B达盈亏比{rr:g}(亏腿全损)",
"spot": spot_b,
"leg_a_pnl": round(a_at_b_full, 4),
"leg_b_pnl": round(b_at_b, 4),
"total": round(a_at_b_full + b_at_b, 4),
"note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏",
},
{
"id": "rr_leg_a_residual",
"label": f"腿A达盈亏比{rr:g}(亏腿残值20%)",
"spot": spot_a,
"leg_a_pnl": round(a_at_a, 4),
"leg_b_pnl": round(b_at_a_res, 4),
"total": round(a_at_a + b_at_a_res, 4),
"note": "现货同腿A达标反推;亏腿买一回收约本合约权利金20%",
},
{
"id": "expiry_flat",
"label": "到期·现价",
"spot": index_px,
"leg_a_pnl": round(a_flat, 4),
"leg_b_pnl": round(b_flat, 4),
"total": round(flat_total, 4),
"note": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值",
},
{
"id": "max_premium_loss",
"label": "最大保费损耗",
"spot": None,
"leg_a_pnl": round(-prem_a, 4),
"leg_b_pnl": round(-prem_b, 4),
"total": round(-prem, 4),
"note": "双腿权利金全部损失",
},
],
"summary": {
"profit_rr": rr,
"spot_at_rr_a": spot_a,
"spot_at_rr_b": spot_b,
"at_rr_a_full_total": round(a_at_a + b_at_a_full, 4),
"at_rr_b_full_total": round(a_at_b_full + b_at_b, 4),
"at_rr_a_residual_total": round(a_at_a + b_at_a_res, 4),
"at_target_up_total": round(a_at_a + b_at_a_full, 4),
"at_target_down_total": round(a_at_b_full + b_at_b, 4),
"at_target_total": round(a_at_a + b_at_a_full, 4),
"expiry_flat_total": round(flat_total, 4),
"premium_paid": round(prem, 6),
"expiry_is_loss": flat_total <= 0,
"rr_risk_premium": round(prem, 6),
"rr_at_up": round((a_at_a + b_at_a_full) / prem, 4) if prem > 0 else None,
"rr_at_down": round((a_at_b_full + b_at_b) / prem, 4) if prem > 0 else None,
},
}
# 兼容旧单目标:若未传上下目标则用 target_price 填两边
up = target_price_up if target_price_up is not None else target_price
down = target_price_down if target_price_down is not None else target_price
if up is None or down is None:
raise ValueError("缺少盈亏比或上破/下破目标价")
up_f = float(up)
down_f = float(down)
a_up = _leg_pnl(leg_a, up_f)
b_up = _leg_pnl(leg_b, up_f)
at_up = a_up + b_up
win_up = "a" if a_up >= b_up else "b"
a_dn = _leg_pnl(leg_a, down_f)
b_dn = _leg_pnl(leg_b, down_f)
at_dn = a_dn + b_dn
win_dn = "a" if a_dn >= b_dn else "b"
a_flat = _leg_pnl(leg_a, index_px)
b_flat = _leg_pnl(leg_b, index_px)
flat_total = a_flat + b_flat
expiry_loss = flat_total if flat_total <= 0 else flat_total
return {
"plan_type": "options_options",
"premium_paid": round(prem, 6),
"target_price": up_f, # 兼容旧字段,取上破
"target_price_up": up_f,
"target_price_down": down_f,
"winner_at_up": win_up,
"winner_at_down": win_dn,
"winner_at_target": win_up,
"scenarios": [
{
"id": "target_up",
"label": "上破目标",
"spot": up_f,
"leg_a_pnl": round(a_up, 4),
"leg_b_pnl": round(b_up, 4),
"total": round(at_up, 4),
"note": f"盈利方≈腿{win_up.upper()}(可平);亏损方默认到期",
},
{
"id": "target_down",
"label": "下破目标",
"spot": down_f,
"leg_a_pnl": round(a_dn, 4),
"leg_b_pnl": round(b_dn, 4),
"total": round(at_dn, 4),
"note": f"盈利方≈腿{win_dn.upper()}(可平);亏损方默认到期",
},
{
"id": "expiry_flat",
"label": "到期·现价(无突破)",
"spot": index_px,
"leg_a_pnl": round(a_flat, 4),
"leg_b_pnl": round(b_flat, 4),
"total": round(flat_total, 4),
"note": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值",
},
{
"id": "max_premium_loss",
"label": "最大保费损耗",
"spot": None,
"leg_a_pnl": round(-prem_a, 4),
"leg_b_pnl": round(-prem_b, 4),
"total": round(-prem, 4),
"note": "双腿权利金全部损失",
},
],
"summary": {
"at_target_up_total": round(at_up, 4),
"at_target_down_total": round(at_dn, 4),
"at_target_total": round(at_up, 4),
"expiry_flat_total": round(expiry_loss, 4),
"premium_paid": round(prem, 6),
"expiry_is_loss": flat_total <= 0,
# 盈亏比:盈利/全亏保费(风险=权利金全损)
"rr_risk_premium": round(prem, 6),
"rr_at_up": round(at_up / prem, 4) if prem > 0 else None,
"rr_at_down": round(at_dn / prem, 4) if prem > 0 else None,
},
}
def gate_status(
*,
hedge_enabled: bool,
sizing_mode: str,
plan_type: str,
options_enabled: bool,
live_order: bool = False,
live_trading: bool = False,
active_count: int = 0,
max_active: int = 1,
show_perp_options: bool = True,
show_options_options: bool = True,
mutual_exclusive: bool = True,
has_standalone_option: bool = False,
) -> dict[str, Any]:
from lib.trade.position_sizing_lib import is_full_margin_mode
full = is_full_margin_mode(sizing_mode)
pt = (plan_type or "").strip().lower()
can_preview = True
can_start = True
reasons: list[str] = []
if not hedge_enabled:
can_start = False
reasons.append("对冲计划未启用(HEDGE_PLAN_ENABLED)")
if not options_enabled:
can_preview = False
can_start = False
reasons.append("期权模块未启用")
if pt == "perp_options" and not show_perp_options:
can_preview = False
can_start = False
reasons.append("永期对冲已隐藏(HEDGE_PLAN_SHOW_PERP_OPTIONS)")
if pt == "options_options" and not show_options_options:
can_preview = False
can_start = False
reasons.append("期期对冲已隐藏(HEDGE_PLAN_SHOW_OPTIONS_OPTIONS)")
if not live_order:
can_start = False
reasons.append("未允许对冲真实下单(HEDGE_PLAN_LIVE_ORDER)")
if active_count >= max(1, int(max_active or 1)):
can_start = False
reasons.append(f"活跃计划已达上限({max_active})")
if mutual_exclusive and has_standalone_option:
can_start = False
reasons.append("存在单独期权持仓,禁止启动对冲计划(互斥门控)")
if pt == "perp_options":
if not full:
can_start = False
reasons.append("永期开仓仅全仓模式可用(当前可测算)")
if not live_trading:
can_start = False
reasons.append("未开启实盘(LIVE_TRADING_ENABLED)")
elif pt == "options_options":
pass
else:
can_start = False
reasons.append("未知计划类型")
if can_start:
reasons = []
return {
"hedge_enabled": hedge_enabled,
"options_enabled": options_enabled,
"sizing_mode": sizing_mode,
"is_full_margin": full,
"plan_type": pt,
"live_order": live_order,
"live_trading": live_trading,
"active_count": active_count,
"max_active": max_active,
"show_perp_options": bool(show_perp_options),
"show_options_options": bool(show_options_options),
"mutual_exclusive": bool(mutual_exclusive),
"has_standalone_option": bool(has_standalone_option),
"can_preview": can_preview,
"can_start": can_start,
"reasons": reasons,
}
+468
View File
@@ -0,0 +1,468 @@
"""对冲计划 SQLite 表."""
from __future__ import annotations
import sqlite3
from typing import Any, Optional
def init_hedge_plan_tables(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS hedge_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_type TEXT NOT NULL,
status TEXT NOT NULL,
underlying TEXT NOT NULL,
direction TEXT,
entry_mark REAL,
tp REAL,
sl REAL,
target_price REAL,
sizing_mode_at_open TEXT,
perp_size REAL,
margin REAL,
leverage REAL,
premium_total REAL,
realized_pnl_perp REAL,
realized_pnl_options REAL,
realized_pnl_total REAL,
stats_bucket TEXT,
close_reason TEXT,
wechat_start_sent INTEGER DEFAULT 0,
wechat_end_sent INTEGER DEFAULT 0,
note TEXT,
preview_json TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
opened_at TIMESTAMP,
closed_at TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS hedge_plan_legs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL,
leg_role TEXT NOT NULL,
symbol TEXT,
inst_id TEXT,
opt_type TEXT,
strike REAL,
side TEXT,
size REAL,
avg_open REAL,
premium REAL,
status TEXT,
linked_monitor_id INTEGER,
options_trade_id INTEGER,
exchange_ord_id TEXT,
realized_pnl REAL,
close_reason TEXT,
opened_at TIMESTAMP,
closed_at TIMESTAMP,
FOREIGN KEY(plan_id) REFERENCES hedge_plans(id)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_hedge_plans_status ON hedge_plans(status)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_hedge_plan_legs_plan ON hedge_plan_legs(plan_id)"
)
_ensure_column(conn, "hedge_plans", "target_price_up", "REAL")
_ensure_column(conn, "hedge_plans", "target_price_down", "REAL")
# 期期出场:盈利金额/总权利金(默认2);有值则走盈亏比监控,旧单仍用上/下破价
_ensure_column(conn, "hedge_plans", "profit_rr", "REAL")
# close_all=残值平(本合约权利金≤20%且有买一);hold_expiry=残腿持有至到期
_ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT")
# 永期「以期权为主」
_ensure_column(conn, "hedge_plans", "option_primary", "INTEGER")
_ensure_column(conn, "hedge_plans", "option_target_points", "REAL")
_ensure_column(conn, "hedge_plans", "perp_target_points", "REAL")
_ensure_column(conn, "hedge_plans", "option_perp_ratio", "REAL")
_ensure_column(conn, "hedge_plans", "premium_budget", "REAL")
_ensure_column(conn, "hedge_plans", "strike_interval", "REAL")
_ensure_column(conn, "hedge_plans", "min_option_hours", "REAL")
_ensure_column(conn, "hedge_plans", "option_moneyness", "TEXT")
_ensure_column(conn, "hedge_plans", "option_leverage", "REAL")
_ensure_column(conn, "hedge_plans", "perp_direction", "TEXT")
_ensure_column(conn, "hedge_plan_legs", "ct_mult", "REAL")
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
names: set[str] = set()
for r in rows:
try:
names.add(str(r["name"]))
except (TypeError, KeyError, IndexError):
names.add(str(r[1]))
if col not in names:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
_ACTIVE_STATUSES = ("opening", "active", "partial", "watching")
def count_active_plans(conn: sqlite3.Connection, plan_type: Optional[str] = None) -> int:
statuses = ",".join(f"'{s}'" for s in _ACTIVE_STATUSES)
if plan_type:
row = conn.execute(
f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses}) AND plan_type=?",
(plan_type,),
).fetchone()
else:
row = conn.execute(
f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses})"
).fetchone()
return int((row["c"] if row else 0) or 0)
def insert_plan(conn: sqlite3.Connection, row: dict[str, Any]) -> int:
cols = list(row.keys())
placeholders = ",".join(["?"] * len(cols))
conn.execute(
f"INSERT INTO hedge_plans ({','.join(cols)}) VALUES ({placeholders})",
[row[c] for c in cols],
)
return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
def insert_leg(conn: sqlite3.Connection, row: dict[str, Any]) -> int:
cols = list(row.keys())
placeholders = ",".join(["?"] * len(cols))
conn.execute(
f"INSERT INTO hedge_plan_legs ({','.join(cols)}) VALUES ({placeholders})",
[row[c] for c in cols],
)
return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
def update_plan(conn: sqlite3.Connection, plan_id: int, **fields: Any) -> None:
if not fields:
return
sets = ", ".join(f"{k}=?" for k in fields)
conn.execute(f"UPDATE hedge_plans SET {sets} WHERE id=?", [*fields.values(), plan_id])
def update_leg(conn: sqlite3.Connection, leg_id: int, **fields: Any) -> None:
if not fields:
return
sets = ", ".join(f"{k}=?" for k in fields)
conn.execute(f"UPDATE hedge_plan_legs SET {sets} WHERE id=?", [*fields.values(), int(leg_id)])
def missing_leg_role(legs: list[dict[str, Any]]) -> Optional[str]:
for leg in legs or []:
if str(leg.get("status") or "").strip().lower() == "pending":
role = str(leg.get("leg_role") or "").strip()
if role:
return role
return None
def list_plans(
conn: sqlite3.Connection,
*,
status: Optional[str] = None,
plan_type: Optional[str] = None,
underlying: Optional[str] = None,
limit: int = 50,
) -> list[dict[str, Any]]:
wheres: list[str] = []
args: list[Any] = []
if status:
wheres.append("status=?")
args.append(status)
if plan_type:
wheres.append("plan_type=?")
args.append(plan_type)
if underlying:
wheres.append("underlying=?")
args.append(underlying)
where = (" WHERE " + " AND ".join(wheres)) if wheres else ""
rows = conn.execute(
f"SELECT * FROM hedge_plans{where} ORDER BY id DESC LIMIT ?",
[*args, int(limit)],
).fetchall()
return [dict(r) for r in rows]
def get_plan(conn: sqlite3.Connection, plan_id: int) -> Optional[dict[str, Any]]:
row = conn.execute("SELECT * FROM hedge_plans WHERE id=?", (plan_id,)).fetchone()
return dict(row) if row else None
def get_plan_legs(conn: sqlite3.Connection, plan_id: int) -> list[dict[str, Any]]:
rows = conn.execute(
"SELECT * FROM hedge_plan_legs WHERE plan_id=? ORDER BY id", (plan_id,)
).fetchall()
return [dict(r) for r in rows]
def delete_plan(conn: sqlite3.Connection, plan_id: int) -> dict[str, Any]:
"""删除已结束/失败/取消的计划及其腿;活跃计划拒绝删除."""
plan = get_plan(conn, int(plan_id))
if not plan:
return {"ok": False, "msg": "计划不存在"}
st = str(plan.get("status") or "")
if st in ("opening", "active", "partial", "watching"):
return {"ok": False, "msg": "进行中的计划不可删除,请先结束"}
conn.execute("DELETE FROM hedge_plan_legs WHERE plan_id=?", (int(plan_id),))
conn.execute("DELETE FROM hedge_plans WHERE id=?", (int(plan_id),))
return {"ok": True, "deleted_id": int(plan_id)}
def legs_contract_summary(legs: list[dict[str, Any]]) -> str:
parts: list[str] = []
for leg in legs:
role = str(leg.get("leg_role") or "")
st = str(leg.get("status") or "").strip().lower()
if st == "pending":
suffix = "(待补)"
elif st in ("cancelled", "canceled"):
suffix = "(未成交)"
else:
suffix = ""
if role == "perp":
name = str(leg.get("symbol") or "永续")
parts.append(f"永续 {name}{suffix}")
else:
inst = str(leg.get("inst_id") or "")
ot = str(leg.get("opt_type") or "").upper()
strike = leg.get("strike")
label = inst or (f"{ot}{strike}" if ot or strike is not None else role)
parts.append(f"{label}{suffix}")
return " · ".join(parts) if parts else ""
def attach_legs_to_plans(conn: sqlite3.Connection, plans: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for p in plans:
legs = get_plan_legs(conn, int(p["id"]))
row = dict(p)
row["legs"] = legs
summary = legs_contract_summary(legs)
if str(p.get("status") or "") == "watching" and (not legs or summary == ""):
money = str(p.get("option_moneyness") or "otm")
money_lab = {"itm": "实/平", "atm": "平值", "otm": "虚值"}.get(money, money)
parts = [f"盯盘·{money_lab}"]
try:
if p.get("strike_interval") not in (None, ""):
parts.append(f"间隔{float(p.get('strike_interval')):g}")
except (TypeError, ValueError):
pass
try:
if p.get("option_leverage") not in (None, ""):
parts.append(f"杠杆≥{float(p.get('option_leverage')):g}")
except (TypeError, ValueError):
pass
summary = "·".join(parts)
row["contracts_summary"] = summary
row["missing_leg"] = missing_leg_role(legs)
out.append(row)
return out
def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
"""返回由进行中「期期对冲」托管的期权目标,仅供期权页只读展示。
这些目标由 hedge_plan_monitor_lib 执行,绝不能写入 options_target_monitors
否则两套监控会同时尝试平掉同一条期权腿。
"""
rows = conn.execute(
"""
SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down,
p.profit_rr, l.inst_id, l.opt_type
FROM hedge_plans p
JOIN hedge_plan_legs l ON l.plan_id = p.id
WHERE p.plan_type = 'options_options'
AND p.status IN ('opening', 'active', 'partial')
AND l.status = 'open'
AND l.inst_id IS NOT NULL
AND l.inst_id != ''
ORDER BY p.id DESC, l.id DESC
"""
).fetchall()
out: dict[str, dict[str, Any]] = {}
for raw in rows:
row = dict(raw)
inst_id = str(row.get("inst_id") or "")
opt_type = str(row.get("opt_type") or "").upper()
if not inst_id or inst_id in out:
continue
profit_rr = _sf(row.get("profit_rr"))
if profit_rr is not None and profit_rr > 0:
out[inst_id] = {
"plan_id": int(row["plan_id"]),
"inst_id": inst_id,
"underlying": row.get("underlying"),
"opt_type": opt_type,
"profit_rr": profit_rr,
"target_index": None,
"exit_mode": "profit_rr",
"managed_by": "hedge_plan",
}
continue
target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down")
target_f = _sf(target)
if target_f is None or target_f <= 0:
continue
out[inst_id] = {
"plan_id": int(row["plan_id"]),
"inst_id": inst_id,
"underlying": row.get("underlying"),
"opt_type": opt_type,
"target_index": target_f,
"plan_type": "options_options",
"managed_by": "hedge_plan",
}
return out
def active_hedge_option_inst_ids(conn: sqlite3.Connection) -> set[str]:
"""进行中对冲计划托管的期权合约,禁止单独期权页 close/target 拆组."""
rows = conn.execute(
"""
SELECT DISTINCT l.inst_id
FROM hedge_plan_legs l
JOIN hedge_plans p ON p.id = l.plan_id
WHERE p.status IN ('opening', 'active', 'partial')
AND l.status IN ('open', 'hold_to_expiry')
AND l.inst_id IS NOT NULL
AND TRIM(l.inst_id) != ''
AND (
l.leg_role LIKE 'option%'
OR (l.opt_type IS NOT NULL AND TRIM(l.opt_type) != '')
)
"""
).fetchall()
return {str(r[0]).strip() for r in rows if r and r[0]}
def _sf(v: Any) -> Optional[float]:
try:
if v is None or v == "":
return None
return float(v)
except (TypeError, ValueError):
return None
def _metrics_from_pnls(rows: list[dict[str, Any]]) -> dict[str, Any]:
"""对一组已结束计划计算胜率/盈亏比/最大盈亏/最大回撤."""
pnls: list[float] = []
timed: list[tuple[str, float]] = []
for r in rows:
pnl = _sf(r.get("realized_pnl_total"))
if pnl is None:
continue
pnls.append(pnl)
t = str(r.get("closed_at") or r.get("opened_at") or r.get("created_at") or "")
timed.append((t, pnl))
n = len(pnls)
if n == 0:
return {
"count": 0,
"wins": 0,
"losses": 0,
"win_rate": None,
"net_pnl": 0.0,
"avg_pnl": None,
"avg_premium": None,
"profit_factor": None,
"max_profit": None,
"max_loss": None,
"max_drawdown": None,
}
wins = [x for x in pnls if x > 0]
losses = [x for x in pnls if x < 0]
gross_win = sum(wins)
gross_loss = abs(sum(losses))
if gross_loss > 0:
profit_factor = round(gross_win / gross_loss, 4)
elif gross_win > 0:
profit_factor = None # 全胜,标无限
else:
profit_factor = 0.0
timed.sort(key=lambda x: x[0] or "")
cum = 0.0
peak = 0.0
mdd = 0.0
for _, p in timed:
cum += p
if cum > peak:
peak = cum
dd = peak - cum
if dd > mdd:
mdd = dd
premiums = [_sf(r.get("premium_total")) for r in rows]
premiums_f = [x for x in premiums if x is not None]
return {
"count": n,
"wins": len(wins),
"losses": len(losses),
"win_rate": round(len(wins) / n, 4),
"net_pnl": round(sum(pnls), 4),
"avg_pnl": round(sum(pnls) / n, 4),
"avg_premium": round(sum(premiums_f) / len(premiums_f), 4) if premiums_f else None,
"profit_factor": profit_factor,
"profit_factor_infinite": bool(gross_loss <= 0 and gross_win > 0),
"max_profit": round(max(pnls), 4),
"max_loss": round(min(pnls), 4),
"max_drawdown": round(mdd, 4),
}
def stats_summary(conn: sqlite3.Connection) -> dict[str, Any]:
reason_rows = conn.execute(
"""
SELECT plan_type, close_reason, COUNT(1) AS n,
COALESCE(SUM(realized_pnl_total), 0) AS pnl
FROM hedge_plans
WHERE status='closed'
GROUP BY plan_type, close_reason
"""
).fetchall()
closed_rows = [
dict(r)
for r in conn.execute(
"SELECT * FROM hedge_plans WHERE status='closed' ORDER BY COALESCE(closed_at, opened_at, created_at), id"
).fetchall()
]
active = count_active_plans(conn)
overall = _metrics_from_pnls(closed_rows)
by_type = {
"perp_options": _metrics_from_pnls(
[r for r in closed_rows if r.get("plan_type") == "perp_options"]
),
"options_options": _metrics_from_pnls(
[r for r in closed_rows if r.get("plan_type") == "options_options"]
),
}
# 永期止盈/止损分桶
po = [r for r in closed_rows if r.get("plan_type") == "perp_options"]
by_type["perp_options"]["buckets"] = {
"tp": _metrics_from_pnls([r for r in po if r.get("close_reason") == "perp_tp"]),
"sl": _metrics_from_pnls([r for r in po if r.get("close_reason") == "perp_sl"]),
}
oo = [r for r in closed_rows if r.get("plan_type") == "options_options"]
by_type["options_options"]["buckets"] = {
"expiry_loss": _metrics_from_pnls(
[r for r in oo if r.get("close_reason") == "oo_expiry_loss"]
),
"expiry_win": _metrics_from_pnls(
[r for r in oo if r.get("close_reason") == "oo_expiry_win"]
),
}
return {
"active": active,
"closed_count": overall["count"],
"closed_pnl_total": overall["net_pnl"],
"overall": overall,
"by_type": by_type,
"by_reason": [dict(r) for r in reason_rows],
}
+285
View File
@@ -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
File diff suppressed because it is too large Load Diff
+216
View File
@@ -0,0 +1,216 @@
"""对冲计划企业微信推送(起止必发,幂等落库标记)."""
from __future__ import annotations
from typing import Any, Callable, Optional
from lib.hedge_plan.hedge_plan_db import update_plan
def _fmt(v: Any, d: int = 2) -> str:
try:
if v is None or v == "":
return ""
return f"{float(v):.{d}f}"
except (TypeError, ValueError):
return str(v)
def _type_label(plan_type: str) -> str:
return "永期对冲" if (plan_type or "") == "perp_options" else "期期对冲"
def _dir_label(direction: str) -> str:
d = (direction or "").lower()
if d == "long":
return "做多"
if d == "short":
return "做空"
return ""
def build_hedge_start_message(plan: dict[str, Any], *, legs: Optional[list[dict[str, Any]]] = None) -> str:
pt = plan.get("plan_type") or ""
lines = [
f"🟢 对冲计划启动 #{plan.get('id')}",
f"📌 类型:{_type_label(pt)}",
f"🪙 标的:{plan.get('underlying') or ''}",
]
if pt == "perp_options":
lines.extend(
[
f"📈 方向:{_dir_label(plan.get('direction') or '')}",
f"💵 开仓参考:{_fmt(plan.get('entry_mark'))}",
f"🎯 止盈:{_fmt(plan.get('tp'))}|止损:{_fmt(plan.get('sl'))}",
f"📦 永续张数:{_fmt(plan.get('perp_size'), 4)}|杠杆:{_fmt(plan.get('leverage'), 0)}x",
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
]
)
else:
rr = plan.get("profit_rr")
if rr not in (None, ""):
lines.extend(
[
f"🎯 盈亏比:{_fmt(rr)} (盈利金额/总权利金)",
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
]
)
else:
lines.extend(
[
f"🎯 上破:{_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
f"|下破:{_fmt(plan.get('target_price_down') or plan.get('target_price'))}",
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
]
)
if legs:
for leg in legs:
role = leg.get("leg_role") or ""
if role == "perp":
lines.append(f"· 永续腿 {leg.get('symbol') or ''} ×{_fmt(leg.get('size'), 4)}")
else:
lines.append(
f"· {role} {(leg.get('opt_type') or '')} K{_fmt(leg.get('strike'), 0)} "
f"×{_fmt(leg.get('size'), 0)}{leg.get('inst_id') or ''}"
)
lines.append("📎 独立模块推送,不进普通交易复盘")
return "\n".join(lines)
def build_hedge_end_message(plan: dict[str, Any]) -> str:
reason = plan.get("close_reason") or ""
total = plan.get("realized_pnl_total")
try:
tv = float(total) if total is not None else None
except (TypeError, ValueError):
tv = None
head = "🔴" if (tv is not None and tv < 0) else "🟢"
reason_map = {
"perp_tp": "永续止盈(期权默认不平)",
"perp_sl": "永续止损(期权强制平)",
"target_win_leg": "期期已平盈利腿(中间态)",
"target_up_win_leg": "期期上破·已平盈利腿",
"target_down_win_leg": "期期下破·已平盈利腿",
"profit_rr_win_leg": "期期盈亏比达标·已平盈利腿",
"oo_rest_closing": "期期残值平·清亏损腿中",
"oo_rest_closed": "期期残值平·两腿已平",
"oo_expiry_loss": "期期到期无盈利·总亏损",
"oo_expiry_win": "期期到期仍盈利",
"expiry": "到期收口",
"manual": "人工结束",
"partial_fail": "半腿失败收尾",
"cancelled": "已取消",
}
lines = [
f"{head} 对冲计划结束 #{plan.get('id')}",
f"📌 类型:{_type_label(plan.get('plan_type') or '')}",
f"🪙 标的:{plan.get('underlying') or ''}",
f"📎 原因:{reason_map.get(reason, reason)}",
f"💰 合计≈U:{_fmt(total)}",
f"· 永续分项:{_fmt(plan.get('realized_pnl_perp'))} USDT",
f"· 期权分项:{_fmt(plan.get('realized_pnl_options'))} USDC(≈U 1:1)",
f"⏱ 开仓:{plan.get('opened_at') or ''}|结束:{plan.get('closed_at') or ''}",
]
return "\n".join(lines)
def build_hedge_alert_message(
*,
title: str,
plan_id: Any = None,
detail: str = "",
) -> str:
lines = [f"⚠️ 对冲计划告警{(' #' + str(plan_id)) if plan_id else ''}", f"📌 {title}"]
if detail:
lines.append(str(detail)[:800])
return "\n".join(lines)
def notify_hedge(
cfg: dict[str, Any],
content: str,
) -> bool:
send: Optional[Callable[[str], Any]] = cfg.get("send_wechat")
if not callable(send):
return False
try:
send(content)
return True
except Exception:
return False
def notify_plan_start(
cfg: dict[str, Any],
conn: Any,
plan: dict[str, Any],
legs: Optional[list[dict[str, Any]]] = None,
) -> bool:
if int(plan.get("wechat_start_sent") or 0):
return False
ok = notify_hedge(cfg, build_hedge_start_message(plan, legs=legs))
if ok and plan.get("id") is not None:
update_plan(conn, int(plan["id"]), wechat_start_sent=1)
plan["wechat_start_sent"] = 1
return ok
def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> bool:
if int(plan.get("wechat_end_sent") or 0):
return False
# 中间态 target_win_leg 不算正式结束推送(用告警)
if (plan.get("close_reason") or "") in (
"target_win_leg",
"target_up_win_leg",
"target_down_win_leg",
"profit_rr_win_leg",
"oo_rest_closing",
) and (plan.get("status") or "") != "closed":
cr = str(plan.get("close_reason") or "")
if "profit_rr" in cr:
side = "盈亏比达标"
elif "up" in cr:
side = "上破"
elif "down" in cr:
side = "下破"
else:
side = "目标"
mode = (plan.get("oo_close_mode") or "").strip().lower()
if mode in ("close_all", "全平", "残值平"):
rest_txt = "另一腿残值平(本合约权利金≤20%且有买一,失败重试)"
else:
rest_txt = "另一腿到期平(持有至到期结算)"
rr = plan.get("profit_rr")
if rr not in (None, ""):
detail = f"盈亏比 {_fmt(rr)} (盈利金额/总权利金)"
else:
detail = (
f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
f"|下破 {_fmt(plan.get('target_price_down') or plan.get('target_price'))}"
)
notify_hedge(
cfg,
build_hedge_alert_message(
title=f"期期{side}已平盈利腿 · {rest_txt}",
plan_id=plan.get("id"),
detail=detail,
),
)
return True
ok = notify_hedge(cfg, build_hedge_end_message(plan))
if ok and plan.get("id") is not None:
update_plan(conn, int(plan["id"]), wechat_end_sent=1)
plan["wechat_end_sent"] = 1
return ok
def notify_partial_fail(cfg: dict[str, Any], *, plan_type: str, msg: str, results: Any = None) -> bool:
detail = msg
if results:
try:
detail = f"{msg}\n路径结果:{results}"[:800]
except Exception:
pass
return notify_hedge(
cfg,
build_hedge_alert_message(title=f"{_type_label(plan_type)}半腿失败", detail=detail),
)
@@ -0,0 +1,527 @@
"""永期「以期权为主」:定仓、方向映射、目标位与净利口径(纯函数为主)."""
from __future__ import annotations
import math
import os
from typing import Any, Optional
PREMIUM_EXEC_FACTOR = 0.95
DEFAULT_MIN_HOURS = 36.0
DEFAULT_STRIKE_INTERVAL = 15.0
DEFAULT_PERP_LEVERAGE = 100
DEFAULT_OPT_LEVERAGE_ITM_ATM = 100.0
DEFAULT_OPT_LEVERAGE_OTM = 200.0
DEFAULT_RATIO_ITM_ATM = 2.0
DEFAULT_RATIO_OTM = 4.0
OTM_LEV_FLOOR = 180.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 is_option_primary(body_or_plan: dict[str, Any] | None) -> bool:
if not body_or_plan:
return False
v = body_or_plan.get("option_primary")
if v in (True, 1, "1", "true", "yes", "on"):
return True
try:
return int(v or 0) == 1
except (TypeError, ValueError):
return False
def fee_rate() -> float:
try:
return max(0.0, float(os.getenv("HEDGE_PLAN_FEE_RATE") or os.getenv("OKX_TAKER_FEE") or "0.0005"))
except (TypeError, ValueError):
return 0.0005
def floor2(v: float) -> float:
"""ETH 数量向下取两位小数."""
if v <= 0:
return 0.0
return math.floor(float(v) * 100.0 + 1e-12) / 100.0
def opt_type_for_view(direction: str) -> str:
"""看法做多→Call,做空→Put."""
return "P" if str(direction or "").strip().lower() == "short" else "C"
def perp_direction_for_view(direction: str) -> str:
"""看法做多→永续空,做空→永续多."""
return "long" if str(direction or "").strip().lower() == "short" else "short"
def default_opt_leverage(moneyness: str) -> float:
m = (moneyness or "").strip().lower()
return DEFAULT_OPT_LEVERAGE_OTM if m == "otm" else DEFAULT_OPT_LEVERAGE_ITM_ATM
def default_ratio(moneyness: str) -> float:
m = (moneyness or "").strip().lower()
return DEFAULT_RATIO_OTM if m == "otm" else DEFAULT_RATIO_ITM_ATM
def effective_min_opt_leverage(moneyness: str, configured: Any) -> float:
cfg = _sf(configured)
base = cfg if cfg is not None and cfg > 0 else default_opt_leverage(moneyness)
if (moneyness or "").strip().lower() == "otm":
return max(base, OTM_LEV_FLOOR)
return base
def hours_to_expiry_from_ms(exp_ms: Any, *, now_ms: Optional[float] = None) -> Optional[float]:
exp = _sf(exp_ms)
if exp is None or exp <= 0:
return None
# OKX exp 多为毫秒
if exp < 1e12:
exp *= 1000.0
now = now_ms if now_ms is not None else __import__("time").time() * 1000.0
return (exp - now) / 3600000.0
def target_hit(*, view_side: str, index_px: float, strike: float, points: float) -> bool:
"""相对 K 的点数目标:做多 index≥K+N;做空 index≤KN.点数须 >0."""
n = float(points or 0)
k = float(strike)
s = float(index_px)
if n <= 0 or k <= 0 or s <= 0:
return False
side = str(view_side or "").strip().lower()
if side == "short":
return s <= (k - n)
return s >= (k + n)
def option_bid_liquidity_ok(bid: Any, bid_sz: Any, *, need_sheets: float = 0) -> tuple[bool, str]:
b = _sf(bid)
if b is None or b <= 0:
return False, "暂无买一报价,无法平期权"
sz = _sf(bid_sz)
if sz is not None and sz <= 0:
return False, "买一深度为 0,无法平期权"
need = float(need_sheets or 0)
if need > 0 and sz is not None and sz + 1e-12 < need:
return False, f"买一深度不足(需 {need:g} 张,买一 {sz:g})"
return True, ""
def size_from_premium(
*,
premium_budget: float,
ask: float,
ct_mult: float,
ratio: float,
contract_size: float,
exec_factor: float = PREMIUM_EXEC_FACTOR,
) -> dict[str, Any]:
"""权利金×0.95 → ETH 两位小数 → 期权张 → 永续跟比例."""
budget = float(premium_budget or 0)
a = float(ask or 0)
ct = float(ct_mult or 0.01)
r = float(ratio or 0)
cs = float(contract_size or 0.01)
usable = budget * float(exec_factor or PREMIUM_EXEC_FACTOR)
if budget <= 0 or a <= 0 or ct <= 0 or r <= 0 or cs <= 0:
return {
"ok": False,
"msg": "定仓参数无效",
"usable_premium": round(usable, 4),
"eth_qty": 0.0,
"sheets": 0.0,
"perp_eth": 0.0,
"contracts": 0.0,
}
# ask 为每 1 币权利金;ETH 数量 = usable / ask
eth_qty = floor2(usable / a)
if eth_qty <= 0:
return {
"ok": False,
"msg": "权利金不足以买入 0.01 ETH 名义期权",
"usable_premium": round(usable, 4),
"eth_qty": 0.0,
"sheets": 0.0,
"perp_eth": 0.0,
"contracts": 0.0,
}
sheets = eth_qty / ct
# 张数向下取整到整数张(OKX 期权常见整张)
sheets_i = float(math.floor(sheets + 1e-12))
if sheets_i <= 0:
return {
"ok": False,
"msg": "换算期权张数不足 1 张",
"usable_premium": round(usable, 4),
"eth_qty": eth_qty,
"sheets": 0.0,
"perp_eth": 0.0,
"contracts": 0.0,
}
# 用整张回写 ETH,保持与下单一致
eth_qty = round(sheets_i * ct, 2)
perp_eth = eth_qty / r
contracts = perp_eth / cs
premium_est = a * sheets_i * ct
return {
"ok": True,
"msg": "",
"usable_premium": round(usable, 4),
"eth_qty": eth_qty,
"sheets": sheets_i,
"perp_eth": round(perp_eth, 6),
"contracts": contracts,
"premium_est": round(premium_est, 4),
"ratio": r,
"exec_factor": float(exec_factor or PREMIUM_EXEC_FACTOR),
}
def estimate_combo_net_pnl(
*,
view_side: str,
strike: float,
index_px: float,
ask_open: float,
bid: float,
sheets: float,
ct_mult: float,
perp_direction: str,
perp_entry: float,
perp_mark: float,
contracts: float,
contract_size: float,
fee: Optional[float] = None,
) -> dict[str, Any]:
"""组合净利(扣费);平仓/卖出手续费按买入费率估算."""
fr = fee if fee is not None else fee_rate()
ct = float(ct_mult or 0.01)
sh = float(sheets or 0)
a = float(ask_open or 0)
b = float(bid or 0)
premium = a * sh * ct
opt_proceeds = b * sh * ct
opt_open_fee = premium * fr
opt_close_fee = opt_proceeds * fr # 卖出费用按买入费率
opt_net = opt_proceeds - premium - opt_open_fee - opt_close_fee
coins = float(contracts or 0) * float(contract_size or 0.01)
entry = float(perp_entry or 0)
mark = float(perp_mark or 0)
pd = str(perp_direction or "").strip().lower()
if pd == "short":
perp_gross = (entry - mark) * coins
else:
perp_gross = (mark - entry) * coins
perp_notional_open = abs(entry * coins)
perp_notional_close = abs(mark * coins)
perp_open_fee = perp_notional_open * fr
perp_close_fee = perp_notional_close * fr
perp_net = perp_gross - perp_open_fee - perp_close_fee
total = opt_net + perp_net
return {
"opt_net": round(opt_net, 4),
"perp_net": round(perp_net, 4),
"net": round(total, 4),
"fee_rate": fr,
"premium": round(premium, 4),
"opt_proceeds": round(opt_proceeds, 4),
}
def validate_option_primary_moneyness(
*,
opt_type: str,
strike: Any,
index_px: Any,
ask: Any = None,
moneyness: str = "atm",
strike_interval: Any = DEFAULT_STRIKE_INTERVAL,
min_hours: Any = DEFAULT_MIN_HOURS,
hours_to_expiry: Any = None,
min_opt_leverage: Any = None,
) -> Optional[str]:
from lib.hedge_plan.hedge_plan_moneyness_lib import (
classify_moneyness,
is_atm_or_otm,
is_itm_or_atm,
normalize_opt_type,
)
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 "行权价或指数无效"
m_want = (moneyness or "atm").strip().lower()
m_got = classify_moneyness(opt_type=o, strike=k, index_px=s)
if m_want == "itm":
if not is_itm_or_atm(opt_type=o, strike=k, index_px=s):
return "所选须为实值或平值"
elif m_want == "atm":
# 平值:距指数在间隔内即可(不强制 classify==atm)
pass
elif m_want == "otm":
if m_got == "itm":
return "虚值模式不可选实值"
if not is_atm_or_otm(opt_type=o, strike=k, index_px=s):
return "虚值模式须选虚值或平值档"
else:
return "期权类型(实/平/虚)无效"
interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL)
if interval > 0 and abs(k - s) > interval + 1e-9:
return f"行权价偏离指数 {abs(k - s):.1f} > 间隔 {interval:.0f}"
min_h = float(_sf(min_hours) or DEFAULT_MIN_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"
a = _sf(ask)
min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else m_got or "atm", min_opt_leverage)
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_option_primary_watch(body: dict[str, Any]) -> Optional[str]:
"""盯盘启动校验:只要参数,不要求已选具体合约."""
need = (
"direction",
"exchange_symbol",
"premium_budget",
"option_target_points",
"perp_target_points",
"option_perp_ratio",
"option_leverage",
)
for k in need:
if body.get(k) in (None, ""):
return f"缺少字段: {k}"
try:
if float(body["premium_budget"]) <= 0:
return "权利金须大于 0"
if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0:
return "目标位点数须大于 0"
if float(body["option_perp_ratio"]) <= 0:
return "期权永续比例须大于 0"
if float(body["option_leverage"]) <= 0:
return "期权杠杆须大于 0"
lev_perp = _sf(body.get("leverage"))
if lev_perp is not None and lev_perp <= 0:
return "永续杠杆须大于 0"
except (TypeError, ValueError):
return "数值字段无效"
direction = str(body.get("direction") or "").strip().lower()
if direction not in ("long", "short"):
return "方向须为 long 或 short"
moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower()
if moneyness not in ("itm", "atm", "otm"):
return "期权类型(实/平/虚)无效"
return None
def validate_option_primary_start(body: dict[str, Any]) -> Optional[str]:
need = (
"direction",
"contracts",
"opt_inst_id",
"sheets",
"exchange_symbol",
"premium_budget",
"option_target_points",
"perp_target_points",
"option_perp_ratio",
)
for k in need:
if body.get(k) in (None, ""):
return f"缺少字段: {k}"
try:
if float(body["contracts"]) <= 0 or float(body["sheets"]) <= 0:
return "张数必须大于 0"
if float(body["premium_budget"]) <= 0:
return "权利金须大于 0"
if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0:
return "目标位点数须大于 0"
if float(body["option_perp_ratio"]) <= 0:
return "期权永续比例须大于 0"
except (TypeError, ValueError):
return "数值字段无效"
direction = str(body.get("direction") or "").strip().lower()
if direction not in ("long", "short"):
return "方向须为 long 或 short"
opt_type = str(body.get("opt_type") or "").strip().upper()
if not opt_type:
inst = str(body.get("opt_inst_id") or "")
if inst.upper().endswith("-P"):
opt_type = "P"
elif inst.upper().endswith("-C"):
opt_type = "C"
want = opt_type_for_view(direction)
if opt_type != want:
return f"以期权为主时做{'' if direction == 'long' else ''}须用 {'Call' if want == 'C' else 'Put'}"
moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower()
from lib.hedge_plan.hedge_plan_moneyness_lib import parse_strike_from_inst
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") or body.get("entry")
return validate_option_primary_moneyness(
opt_type=opt_type,
strike=strike,
index_px=index_px,
ask=body.get("ask"),
moneyness=moneyness,
strike_interval=body.get("strike_interval", DEFAULT_STRIKE_INTERVAL),
min_hours=body.get("min_option_hours", DEFAULT_MIN_HOURS),
hours_to_expiry=body.get("hours_to_expiry"),
min_opt_leverage=body.get("option_leverage") or body.get("min_opt_leverage"),
)
def pick_option_primary_candidate(
chain: dict[str, Any],
*,
direction: str,
moneyness: str = "otm",
strike_interval: Any = DEFAULT_STRIKE_INTERVAL,
min_hours: Any = DEFAULT_MIN_HOURS,
min_opt_leverage: Any = None,
) -> Optional[dict[str, Any]]:
"""从期权链挑最近达标合约(间隔+虚实值+杠杆门)."""
from lib.hedge_plan.hedge_plan_moneyness_lib import classify_moneyness
want = opt_type_for_view(direction)
m_want = (moneyness or "otm").strip().lower()
interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL)
min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS)
try:
idx = float(chain.get("index_px") or 0)
except (TypeError, ValueError):
idx = 0.0
if idx <= 0:
return None
best: Optional[dict[str, Any]] = None
best_dist: Optional[float] = None
for exp in chain.get("expiries") or []:
h = hours_to_expiry_from_ms(exp.get("exp_time"))
if min_h > 0 and h is not None and h < min_h:
continue
for c in exp.get("contracts") or []:
if str(c.get("opt_type") or "").upper() != want:
continue
try:
k = float(c.get("strike") or 0)
ask = float(c.get("ask") or 0)
except (TypeError, ValueError):
continue
if k <= 0 or ask <= 0:
continue
if interval > 0 and abs(k - idx) > interval + 1e-9:
continue
m_got = classify_moneyness(opt_type=want, strike=k, index_px=idx)
if m_want == "itm" and m_got not in ("itm", "atm"):
continue
if m_want == "atm" and m_got != "atm":
continue
if m_want == "otm" and m_got == "itm":
continue
min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else (m_got or "atm"), min_opt_leverage)
if min_lev > 0 and idx / ask < min_lev - 1e-9:
continue
dist = abs(k - idx)
if best is None or best_dist is None or dist < best_dist:
best = {
**dict(c),
"hours_to_expiry": h,
"exp_time": exp.get("exp_time"),
"moneyness": m_got,
"index_px": idx,
"leverage": round(idx / ask, 1),
}
best_dist = dist
return best
def build_option_primary_preview(body: dict[str, Any]) -> dict[str, Any]:
"""情景:期权目标 / 永续目标粗估净利."""
view = str(body.get("direction") or "long").lower()
strike = float(body["strike"])
n = float(body.get("option_target_points") or 0)
m = float(body.get("perp_target_points") or 0)
ask = float(body.get("ask") or 0)
sheets = float(body.get("sheets") or 0)
ct = float(body.get("ct_mult") or 0.01)
contracts = float(body.get("contracts") or 0)
cs = float(body.get("contract_size") or 0.01)
entry = float(body.get("entry") or body.get("index_px") or 0)
perp_dir = perp_direction_for_view(view)
# 粗估到点时期权卖价:按内在价值近似(下限 0)
def intrinsic(spot: float) -> float:
o = opt_type_for_view(view)
if o == "C":
return max(0.0, spot - strike)
return max(0.0, strike - spot)
scenarios = []
for label, pts, reason in (
("期权目标", n, "opt_target_points"),
("永续目标", m, "perp_target_points"),
):
spot = strike + pts if view != "short" else strike - pts
bid_est = max(ask * 0.5, intrinsic(spot) * 0.85) # 保守估价
net = estimate_combo_net_pnl(
view_side=view,
strike=strike,
index_px=spot,
ask_open=ask,
bid=bid_est,
sheets=sheets,
ct_mult=ct,
perp_direction=perp_dir,
perp_entry=entry,
perp_mark=spot,
contracts=contracts,
contract_size=cs,
)
scenarios.append(
{
"label": label,
"reason": reason,
"index": spot,
"perp_pnl": net["perp_net"],
"options_pnl": net["opt_net"],
"total": net["net"],
"note": "扣费净利估价;平仓费按买入费率",
}
)
premium = ask * sheets * ct
return {
"plan_type": "perp_options",
"option_primary": True,
"summary": {
"premium_paid": round(premium, 4),
"usable_premium": round(float(body.get("premium_budget") or 0) * PREMIUM_EXEC_FACTOR, 4),
"opt_target_total": scenarios[0]["total"] if scenarios else None,
"perp_target_total": scenarios[1]["total"] if len(scenarios) > 1 else None,
"perp_direction": perp_dir,
"opt_type": opt_type_for_view(view),
},
"scenarios": scenarios,
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+217
View File
@@ -0,0 +1,217 @@
"""对冲计划结算辅助:到期内在价值与期权腿收口."""
from __future__ import annotations
import os
import time
from datetime import datetime
from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo
from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history
from lib.hedge_plan.hedge_plan_calc_lib import option_expiry_pnl
_APP_TZ = ZoneInfo((os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai")
def _sf(v: Any) -> Optional[float]:
try:
if v is None or v == "":
return None
return float(v)
except (TypeError, ValueError):
return None
def leg_exp_ms(leg: dict[str, Any]) -> Optional[int]:
return normalize_option_exp_ms(leg.get("exp_time"), str(leg.get("inst_id") or ""))
def leg_is_expired(leg: dict[str, Any], *, now_ms: Optional[int] = None) -> bool:
exp = leg_exp_ms(leg)
if exp is None:
return False
now = int(now_ms if now_ms is not None else time.time() * 1000)
return now >= int(exp)
def settle_option_leg_at_spot(leg: dict[str, Any], spot: float) -> float:
"""按到期结算口径估算腿盈亏(USDC)."""
premium = float(leg.get("premium") or 0)
strike = _sf(leg.get("strike"))
if strike is None:
return -premium
sheets = float(leg.get("size") or 1)
# ct_mult 未入库时默认 0.01
ct = float(leg.get("ct_mult") or 0.01)
return float(
option_expiry_pnl(
opt_type=str(leg.get("opt_type") or "P"),
strike=float(strike),
spot=float(spot),
sheets=sheets,
ct_mult=ct,
premium_paid=premium,
)
)
def all_option_legs_expired(legs: list[dict[str, Any]], *, now_ms: Optional[int] = None) -> bool:
opts = [
x
for x in legs
if str(x.get("leg_role") or "").startswith("option")
and str(x.get("status") or "") in ("open", "hold_to_expiry")
]
if not opts:
return False
return all(leg_is_expired(x, now_ms=now_ms) for x in opts)
def _parse_opened_ms(raw: Any) -> Optional[int]:
"""墙钟开仓时间 → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC."""
if raw is None or raw == "":
return None
s = str(raw).strip()
if not s:
return None
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M:%f", 26), ("%Y-%m-%d %H:%M", 16)):
try:
dt = datetime.strptime(s[:ln], fmt).replace(tzinfo=_APP_TZ)
return int(dt.timestamp() * 1000)
except ValueError:
continue
return None
def resolve_option_leg_realized_pnl(
*,
ex: Any = None,
leg: dict[str, Any],
fallback: Optional[float] = None,
fetch_history_fn: Optional[Callable[[str], list[dict[str, Any]]]] = None,
hist_rows: Optional[list[dict[str, Any]]] = None,
) -> tuple[Optional[float], str]:
"""
期权腿已实现盈亏:优先 OKX positions-history realizedPnl.
返回 (pnl, source) source=exchange|fallback|none.
"""
inst_id = str(leg.get("inst_id") or "").strip()
open_ms = _parse_opened_ms(leg.get("opened_at"))
rows = hist_rows
if rows is None and inst_id:
try:
if callable(fetch_history_fn):
rows = fetch_history_fn(inst_id)
elif ex is not None:
from lib.exchange.okx_options_lib import fetch_option_position_history
rows = fetch_option_position_history(ex, inst_id)
except Exception:
rows = None
if rows:
close_ms = _parse_opened_ms(leg.get("closed_at"))
sheets = _sf(leg.get("size")) or _sf(leg.get("sheets"))
info = resolve_option_close_from_history(
rows, open_ms=open_ms, close_ms=close_ms, sheets=sheets
)
pnl = _sf((info or {}).get("realized_pnl")) if info else None
if pnl is not None:
return round(float(pnl), 4), "exchange"
if fallback is not None:
return round(float(fallback), 4), "fallback"
return None, "none"
def backfill_hedge_option_legs_realized_pnl(
conn: Any,
hist_rows: list[dict[str, Any]],
*,
update_plan_fn: Optional[Callable[..., Any]] = None,
) -> dict[str, int]:
"""用交易所历史覆盖已平期权腿盈亏,并重算已结束计划合计."""
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_plan
by_inst: dict[str, list[dict[str, Any]]] = {}
for raw in hist_rows or []:
if not isinstance(raw, dict):
continue
inst = str(raw.get("instId") or "").strip()
if inst:
by_inst.setdefault(inst, []).append(raw)
legs = conn.execute(
"""
SELECT * FROM hedge_plan_legs
WHERE status = 'closed'
AND inst_id IS NOT NULL AND TRIM(inst_id) != ''
AND (leg_role LIKE 'option%' OR opt_type IS NOT NULL)
ORDER BY id DESC
LIMIT 400
"""
).fetchall()
updated_legs = 0
touched_plans: set[int] = set()
for row in legs:
leg = dict(row)
inst = str(leg.get("inst_id") or "").strip()
if not inst or inst not in by_inst:
continue
pnl, src = resolve_option_leg_realized_pnl(
leg=leg,
hist_rows=by_inst[inst],
fallback=None,
)
if src != "exchange" or pnl is None:
continue
local = _sf(leg.get("realized_pnl"))
if local is not None and abs(local - pnl) < 1e-6:
continue
conn.execute(
"UPDATE hedge_plan_legs SET realized_pnl=? WHERE id=?",
(pnl, int(leg["id"])),
)
updated_legs += 1
touched_plans.add(int(leg["plan_id"]))
updated_plans = 0
updater = update_plan_fn or update_plan
for pid in touched_plans:
plan = get_plan(conn, pid)
if not plan or str(plan.get("status") or "") != "closed":
continue
plan_legs = get_plan_legs(conn, pid)
opt_sum = 0.0
for lg in plan_legs:
role = str(lg.get("leg_role") or "")
if not (role.startswith("option") or lg.get("opt_type")):
continue
if str(lg.get("status") or "") != "closed":
continue
opt_sum += float(_sf(lg.get("realized_pnl")) or 0.0)
perp = float(_sf(plan.get("realized_pnl_perp")) or 0.0)
ptype = str(plan.get("plan_type") or "")
if ptype == "options_options":
total = opt_sum
kwargs: dict[str, Any] = {
"realized_pnl_options": round(opt_sum, 4),
"realized_pnl_total": round(total, 4),
}
else:
total = perp + opt_sum
kwargs = {
"realized_pnl_perp": round(perp, 4),
"realized_pnl_options": round(opt_sum, 4),
"realized_pnl_total": round(total, 4),
}
old_total = _sf(plan.get("realized_pnl_total"))
old_opts = _sf(plan.get("realized_pnl_options"))
if (
old_total is not None
and abs(old_total - total) < 1e-6
and old_opts is not None
and abs(old_opts - opt_sum) < 1e-6
):
continue
updater(conn, pid, **kwargs)
updated_plans += 1
return {"legs": updated_legs, "plans": updated_plans}
+103
View File
@@ -0,0 +1,103 @@
"""OKX 期权/对冲三选一模式(env: OKX_TRADE_MODE).
options → 仅单独期权(隐藏对冲导航与对冲 env 配置)
perp_options → 仅永期对冲(不可单独开期权;对冲组数上限 MAX_ACTIVE_HEDGE_PLANS)
options_options → 仅期期对冲(同上)
"""
from __future__ import annotations
import os
from typing import Optional
MODE_OPTIONS = "options"
MODE_PERP = "perp_options"
MODE_OO = "options_options"
VALID_MODES = frozenset({MODE_OPTIONS, MODE_PERP, MODE_OO})
_ALIASES = {
"option": MODE_OPTIONS,
"standalone": MODE_OPTIONS,
"期权": MODE_OPTIONS,
"单独期权": MODE_OPTIONS,
"po": MODE_PERP,
"perp": MODE_PERP,
"永期": MODE_PERP,
"永期对冲": MODE_PERP,
"oo": MODE_OO,
"期期": MODE_OO,
"期期对冲": MODE_OO,
}
def _env_bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None or str(raw).strip() == "":
return default
return str(raw).strip().lower() in ("1", "true", "yes", "on")
def normalize_okx_trade_mode(raw: Optional[str]) -> str:
s = str(raw or "").strip().lower()
if s in VALID_MODES:
return s
if s in _ALIASES:
return _ALIASES[s]
return ""
def legacy_infer_okx_trade_mode() -> str:
"""未配置 OKX_TRADE_MODE 时,按旧开关推断,避免已有部署行为突变."""
if not _env_bool("HEDGE_PLAN_ENABLED", False):
return MODE_OPTIONS
show_po = _env_bool("HEDGE_PLAN_SHOW_PERP_OPTIONS", True)
show_oo = _env_bool("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", True)
if show_po and not show_oo:
return MODE_PERP
if show_oo and not show_po:
return MODE_OO
if show_po:
return MODE_PERP
if show_oo:
return MODE_OO
return MODE_OPTIONS
def get_okx_trade_mode() -> str:
m = normalize_okx_trade_mode(os.getenv("OKX_TRADE_MODE"))
if m:
return m
return legacy_infer_okx_trade_mode()
def hedge_module_enabled() -> bool:
return get_okx_trade_mode() in (MODE_PERP, MODE_OO)
def show_perp_options() -> bool:
return get_okx_trade_mode() == MODE_PERP
def show_options_options() -> bool:
return get_okx_trade_mode() == MODE_OO
def standalone_options_open_allowed() -> bool:
return get_okx_trade_mode() == MODE_OPTIONS
def mode_label(mode: Optional[str] = None) -> str:
m = mode or get_okx_trade_mode()
return {
MODE_OPTIONS: "单独期权",
MODE_PERP: "永期对冲",
MODE_OO: "期期对冲",
}.get(m, m or "")
def block_standalone_open_by_mode_msg() -> Optional[str]:
if standalone_options_open_allowed():
return None
return (
f"当前交易模式为「{mode_label()}」,不可单独开期权;"
"请在 env「交易模式」切换为「单独期权」"
)
@@ -0,0 +1,408 @@
<div class="hedge-plan-page-wrap" style="grid-column:1/-1" id="hedge-plan-root"
data-default-underly="{{ options_default_underly | default('ETH') }}"
data-hedge-enabled="{{ '1' if hedge_plan_enabled else '0' }}"
data-options-enabled="{{ '1' if options_enabled else '0' }}"
data-show-perp="{{ '1' if hedge_plan_show_perp_options else '0' }}"
data-show-oo="{{ '1' if hedge_plan_show_options_options else '0' }}"
data-oo-close-mode-enabled="{{ '1' if hedge_plan_oo_close_mode_enabled else '0' }}"
data-option-primary="{{ '1' if hedge_plan_option_primary|default(true) else '0' }}"
data-budget-buffer="{{ hedge_plan_budget_buffer | default(0.95) }}"
data-sizing-mode="{{ position_sizing_mode | default('risk') }}"
data-is-full-margin="{{ '1' if position_sizing_mode == 'full_margin' else '0' }}">
{% if not hedge_plan_enabled %}
<div class="flash" style="margin-bottom:12px">对冲计划未启用:请在 <code>env配置 → 对冲计划</code> 打开 <code>HEDGE_PLAN_ENABLED</code>(可热更).</div>
{% endif %}
{% if not options_enabled %}
<div class="flash" style="margin-bottom:12px">期权模块未启用,无法拉期权链.请先配置期权账户.</div>
{% endif %}
{% if hedge_plan_enabled and not hedge_plan_show_perp_options and not hedge_plan_show_options_options %}
<div class="flash" style="margin-bottom:12px">永期与期期 Tab 均已隐藏:请在 env「期权/对冲模式」切换交易模式;进行中/历史仍可查看.</div>
{% endif %}
<div class="card hp-head-card">
<div class="hp-head-row">
<h2 class="hp-title">对冲计划 <span class="muted hp-title-sub">测算 · 下单</span>
<a class="muted" href="/options/guide" target="_blank" rel="noopener" style="font-size:13px;font-weight:500;margin-left:8px">期权开平仓与监控说明</a>
</h2>
<button type="button" class="btn-secondary" id="hp-refresh" title="刷新永续行情与期权链">刷新行情</button>
</div>
<div class="hp-tabs" role="tablist" aria-label="对冲计划分类">
{% if hedge_plan_show_perp_options %}
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="perp_options">永期对冲</button>
{% endif %}
{% if hedge_plan_show_options_options %}
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="options_options">期期对冲</button>
{% endif %}
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="active">进行中的计划</button>
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="history">历史记录</button>
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="stats">统计分析</button>
</div>
<p class="muted" id="hp-gate-line"></p>
<p class="muted hp-acct-hint" id="hp-acct-hint">永续腿→合约账户 · 期权腿→期权账户</p>
</div>
<div id="hp-tab-perp_options" class="hp-tab-panel" role="tabpanel">
<div class="options-dual-grid" id="hp-po-layout">
<div class="card hp-po-perp-card">
<h2>
<span id="hp-po-mode-badge" class="hp-po-mode-badge">以期权为主</span>
<span id="hp-po-card-title">执行参数</span>
· <span id="hp-perp-uly-label">ETH</span>
<span class="muted hp-acct-tag" id="hp-perp-acct-tag">合约账户</span>
</h2>
<details class="tip-collapse hp-rule-collapse">
<summary class="tip-collapse-summary">规则说明</summary>
<div class="tip-collapse-body rule-tip">
<p><strong>账户</strong>:永续腿走<strong>合约账户</strong>(USDT);期权腿走<strong>期权账户</strong>(USDC)。两账户分开下单、资金不互通。</p>
<p><strong>模式</strong>:在 env <code>HEDGE_PLAN_OPTION_PRIMARY</code> 切换(true=以期权为主 / false=保险模式);标题前标识当前模式。</p>
<p><strong>保险模式</strong>:做多配 Put、做空配 Call;左填开仓/止盈止损;仅实值/平值;交易所 TP/SL 出场。</p>
<p><strong>以期权为主</strong>:填参后点「策略启动」进入<strong>盯盘</strong>(非现场开仓);杠杆/间隔达标后自动先开期权再市价永续。右侧列表仅展示达标候选。</p>
</div>
</details>
<div class="form-row hp-uly-row">
<button type="button" class="btn-secondary hp-uly-btn active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary hp-uly-btn" data-uly="BTC">BTC</button>
</div>
<div class="hp-po-top">
<div class="hp-oo-seg hp-po-dir-seg" role="group" aria-label="方向">
<button type="button" class="btn-secondary hp-po-dir is-selected" data-dir="long" title="做多=买Call+永续空"><span class="hp-oo-check" aria-hidden="true"></span>做多</button>
<button type="button" class="btn-secondary hp-po-dir" data-dir="short" title="做空=买Put+永续多"><span class="hp-oo-check" aria-hidden="true"></span>做空</button>
</div>
</div>
<div class="hp-po-fields hidden" id="hp-po-fields-insurance" hidden>
<label class="hp-po-field">
<span class="hp-po-field-lab">开仓价 <em>USDT</em></span>
<input type="number" step="any" id="hp-entry" placeholder="入场价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">张数 <em>合约张</em></span>
<input type="number" step="any" id="hp-contracts" placeholder="数量" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field hp-po-field--tp">
<span class="hp-po-field-lab">止盈 <em>USDT</em></span>
<input type="number" step="any" id="hp-tp" placeholder="目标价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field hp-po-field--sl">
<span class="hp-po-field-lab">止损 <em>USDT</em></span>
<input type="number" step="any" id="hp-sl" placeholder="保护价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
<div id="hp-po-fields-option-primary">
<section class="hp-po-section" aria-labelledby="hp-po-sec-capital">
<h3 id="hp-po-sec-capital" class="hp-po-section-title">资金与杠杆配置</h3>
<div class="hp-po-fields hp-po-fields--section hp-po-fields--capital">
<label class="hp-po-field">
<span class="hp-po-field-lab">权利金 <em>USDC</em></span>
<input type="number" step="any" id="hp-premium-budget" placeholder="预算(执行×0.95)" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">永续杠杆</span>
<input type="number" step="1" id="hp-perp-leverage" value="100" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">期权杠杆 <em>启动校验</em></span>
<input type="number" step="1" id="hp-opt-leverage" value="100" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
</section>
<section class="hp-po-section" aria-labelledby="hp-po-sec-select">
<h3 id="hp-po-sec-select" class="hp-po-section-title">选约条件</h3>
<div class="hp-po-fields hp-po-fields--section hp-po-fields--select">
<label class="hp-po-field">
<span class="hp-po-field-lab">到期时间 <em>最短h</em></span>
<input type="number" step="1" id="hp-min-hours" value="36" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">期权间隔 <em></em></span>
<input type="number" step="any" id="hp-strike-interval" value="15" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field hp-po-field--type">
<span class="hp-po-field-lab">类型</span>
<select id="hp-money-select" aria-label="虚实值类型">
<option value="otm" selected>虚值</option>
<option value="itm">实值/平值</option>
<option value="atm">仅平值</option>
</select>
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">比例 <em>期权:永续</em></span>
<input type="number" step="any" id="hp-opt-perp-ratio" value="2" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
</section>
<section class="hp-po-section" aria-labelledby="hp-po-sec-exit">
<h3 id="hp-po-sec-exit" class="hp-po-section-title">出场条件</h3>
<div class="hp-po-fields hp-po-fields--section">
<label class="hp-po-field">
<span class="hp-po-field-lab">期权目标位 <em>相对K</em></span>
<input type="number" step="any" id="hp-opt-target-pts" placeholder="点数" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">永续目标位 <em>相对K</em></span>
<input type="number" step="any" id="hp-perp-target-pts" placeholder="点数" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
</section>
</div>
<div class="hp-po-summary">
<div id="hp-perp-pnl-line" class="hp-po-pnl"></div>
<div id="hp-sizing-line" class="muted hp-po-sizing"></div>
</div>
</div>
<div class="card hp-po-right-card">
<div class="hp-po-right-stack">
<div class="hp-po-inner-card hp-po-perp-quote-card">
<h2>永续行情 <span class="muted hp-acct-tag">合约账户</span></h2>
<div class="hp-po-quote-head">
<span id="hp-po-mark" class="hp-po-mark" aria-live="polite">标记 —</span>
</div>
<p id="hp-perp-quote" class="muted hp-po-meta">加载中…</p>
<p id="hp-po-perp-quote-right" class="muted hp-po-meta" hidden></p>
</div>
<div class="hp-po-inner-card hp-opt-card">
<h2>期权 · <span id="hp-opt-type-label">Call</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>
<span class="hp-po-ins-money" id="hp-po-ins-money" hidden>
<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>
</span>
<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>
<div class="options-strike-table-wrap hp-strike-table-wrap--6">
<table class="options-strike-table" id="hp-strike-table">
<thead>
<tr>
<th>行权价</th>
<th>实虚值</th>
<th title="指数÷卖一">杠杆</th>
<th>卖一/张</th>
<th>买一/张</th>
<th>操作</th>
</tr>
</thead>
<tbody id="hp-strike-tbody">
<tr><td colspan="6" class="muted">请刷新期权链</td></tr>
</tbody>
</table>
</div>
<div class="form-row hp-pick-row">
<label>已选 <code id="hp-sel-inst"></code></label>
<label>张数 <span class="hp-unit">期权张</span> <input type="number" step="1" min="1" id="hp-sheets" value="1" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<span class="muted" id="hp-premium-line"></span>
</div>
<div id="hp-opt-bal-line" class="muted hp-po-meta hp-opt-bal-line"></div>
</div>
</div>
<div class="form-row hp-action-row hp-po-action-row">
<span id="hp-po-strategy-status" class="hp-po-strategy-status" aria-live="polite"></span>
<button type="button" class="primary" id="hp-preview-btn" title="以期权为主=盯盘启动">策略启动</button>
</div>
</div>
</div>
</div>
<div id="hp-tab-options_options" class="hp-tab-panel hidden" role="tabpanel" hidden>
<div class="options-dual-grid" id="hp-oo-layout">
<div class="card">
<h2>期期参数 · <span id="hp-oo-uly-label">ETH</span> <span class="muted hp-acct-tag">期权账户</span></h2>
<details class="tip-collapse hp-rule-collapse">
<summary class="tip-collapse-summary">规则说明</summary>
<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>:左填<strong>盈亏比</strong>(盈利金额÷总权利金,默认2)与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。出场:盈利腿达盈亏比即平;亏损腿「残值平」=本合约权利金跌至20%且有买一时平,「到期平」=持有至到期。</p>
</div>
</details>
<div class="form-row hp-uly-row">
<button type="button" class="btn-secondary hp-uly-btn-oo active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button>
</div>
<div class="form-row hp-target-row hp-oo-target-row">
<label title="盈利金额 / 总权利金">盈亏比 <input type="number" step="0.1" min="0.1" id="hp-profit-rr" value="2" placeholder="默认2" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<span id="hp-oo-index" class="hp-oo-index" aria-live="polite">指数 —</span>
</div>
<div class="hp-oo-controls">
<div class="hp-oo-ctrl">
<span class="hp-oo-ctrl-lab">张数</span>
<div class="hp-oo-seg" role="group" aria-label="自动张数">
<button type="button" class="btn-secondary hp-oo-size-mode is-selected" data-oo-size="same_sheets" title="两腿同张数,总权利金≤预算"><span class="hp-oo-check" aria-hidden="true"></span>同张数</button>
<button type="button" class="btn-secondary hp-oo-size-mode" data-oo-size="long_bias" title="偏多:Call 占比更高(比例见 env)"><span class="hp-oo-check" aria-hidden="true"></span>做多</button>
<button type="button" class="btn-secondary hp-oo-size-mode" data-oo-size="short_bias" title="偏空:Put 占比更高(比例见 env)"><span class="hp-oo-check" aria-hidden="true"></span>做空</button>
</div>
</div>
<div class="hp-oo-ctrl" id="hp-oo-close-mode-row">
<span class="hp-oo-ctrl-lab" title="仅控制盈利腿平掉后的另一腿">平仓</span>
<div class="hp-oo-seg" role="group" aria-label="平仓模式">
<button type="button" class="btn-secondary hp-oo-close-mode is-selected" data-oo-close="close_all" title="盈利腿平后:亏损腿本合约权利金跌至20%且有买一时平掉(失败重试)"><span class="hp-oo-check" aria-hidden="true"></span>残值平</button>
<button type="button" class="btn-secondary hp-oo-close-mode" data-oo-close="hold_expiry" title="盈利腿平后另一腿持有至到期"><span class="hp-oo-check" aria-hidden="true"></span>到期平</button>
</div>
</div>
</div>
<p class="muted hp-oo-meta" id="hp-oo-budget-line"></p>
<div id="hp-oo-legs" class="hp-oo-legs">
<div class="hp-oo-leg-row" data-leg="a">
<div class="muted" id="hp-oo-leg-a-info">腿A: 尚未选用</div>
<label>张数 <span class="hp-unit">期权张</span>
<input type="number" step="1" min="0" id="hp-oo-sheets-a" value="1" disabled autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
<div class="hp-oo-leg-row" data-leg="b">
<div class="muted" id="hp-oo-leg-b-info">腿B: 尚未选用</div>
<label>张数 <span class="hp-unit">期权张</span>
<input type="number" step="1" min="0" id="hp-oo-sheets-b" value="1" disabled autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
<p class="muted" id="hp-oo-prem-line"></p>
</div>
</div>
<div class="card hp-opt-card">
<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 is-selected active" data-oo-money="atm_otm" title="平值+虚值" aria-pressed="true"><span class="hp-oo-check" aria-hidden="true"></span>平/虚</button>
<button type="button" class="btn-secondary hp-oo-money-btn" data-oo-money="atm" title="仅平值" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true"></span>仅平值</button>
<button type="button" class="btn-secondary hp-oo-money-btn" data-oo-money="otm" title="仅虚值" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true"></span>仅虚值</button>
<button type="button" class="btn-secondary hp-oo-recommend-btn" id="hp-oo-recommend-atm" data-oo-rec="atm_straddle" title="最近平值 Call+Put" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true"></span>推荐跨式</button>
<button type="button" class="btn-secondary hp-oo-recommend-btn" id="hp-oo-recommend-otm" data-oo-rec="double_otm" title="最近虚值 Call+Put" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true"></span>推荐双虚</button>
<button type="button" class="btn-secondary" id="hp-oo-load-chain">刷新链</button>
<button type="button" class="btn-secondary hp-oo-expand-btn" id="hp-oo-expand-all" title="展开该到期全部平值/虚值行权价;若当前为「仅平值」会自动切到「平/虚」" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true"></span>显示全部</button>
</div>
<div class="options-strike-table-wrap hp-oo-table-wrap" id="hp-oo-table-wrap">
<table class="options-strike-table options-strike-table--t" id="hp-oo-table">
<thead>
<tr>
<th colspan="4" class="opt-t-head-call">Call</th>
<th class="opt-t-head-mid">行权</th>
<th colspan="4" class="opt-t-head-put">Put</th>
</tr>
<tr>
<th>卖一/张</th><th title="行权价÷卖一">杠杆</th><th>实虚值</th><th>选用</th>
<th>K</th>
<th>实虚值</th><th>卖一/张</th><th title="行权价÷卖一">杠杆</th><th>选用</th>
</tr>
</thead>
<tbody id="hp-oo-tbody">
<tr><td colspan="9" class="muted">请刷新期权链</td></tr>
</tbody>
</table>
</div>
<div class="hp-oo-transfer hp-oo-transfer--compact" id="hp-oo-transfer">
<div class="hp-oo-transfer-bals muted">
<span>资金 <strong id="hp-oo-funding-usdc"></strong></span>
<span class="hp-oo-transfer-sep">·</span>
<span>交易 <strong id="hp-oo-trading-usdc"></strong></span>
<span class="hp-oo-transfer-unit">USDC</span>
<span class="muted" id="hp-oo-xfer-msg"></span>
</div>
<div class="form-row hp-oo-transfer-form" autocomplete="off">
{# 诱饵账号框:避免浏览器把划转数量当成登录用户名填 dekun #}
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<select id="hp-oo-xfer-dir" aria-label="划转方向" autocomplete="off">
<option value="funding_to_trading" selected>资金 → 交易</option>
<option value="trading_to_funding">交易 → 资金</option>
</select>
<input type="number" id="hp-oo-xfer-amount" name="cm_hp_xfer_amt" min="0.01" step="0.01" placeholder="数量"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly />
<button type="button" class="btn-secondary btn-sm" id="hp-oo-xfer-all">全部</button>
<button type="button" class="btn-primary btn-sm" id="hp-oo-xfer-btn">划转</button>
</div>
</div>
<div class="form-row hp-action-row">
<button type="button" class="primary" id="hp-preview-btn-oo">计算</button>
</div>
</div>
</div>
</div>
<div id="hp-tab-active" class="hp-tab-panel hidden" role="tabpanel" hidden>
<div class="card">
<h2>进行中的计划</h2>
<p class="muted">仅显示已启动但尚未结束的计划;可查看每条腿的当前记录状态。</p>
<div class="options-strike-table-wrap">
<table class="options-strike-table hp-history-table">
<thead>
<tr>
<th>ID</th><th>类型</th><th>标的</th><th>合约</th><th>状态</th><th>目标/止盈止损</th><th>开仓</th><th>操作</th>
</tr>
</thead>
<tbody id="hp-active-tbody">
<tr><td colspan="8" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="hp-tab-history" class="hp-tab-panel hidden" role="tabpanel" hidden>
<div class="card">
<h2>历史记录</h2>
<p class="muted">独立对冲计划历史(与普通交易记录分离)。点「成交细节」查看合约名与成交字段。</p>
<div class="options-strike-table-wrap">
<table class="options-strike-table hp-history-table">
<thead>
<tr>
<th>ID</th><th>类型</th><th>标的</th><th>合约</th><th>状态</th><th>合计≈U</th><th>原因</th><th>开仓</th><th>结束</th><th>操作</th>
</tr>
</thead>
<tbody id="hp-history-tbody">
<tr><td colspan="10" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="hp-tab-stats" class="hp-tab-panel hidden" role="tabpanel" hidden>
<div class="card">
<h2>统计分析</h2>
<p class="muted">按永期 / 期期分别统计:胜率、盈亏比、最大盈利、最大亏损、最大回撤(按结束时间累积)</p>
<div id="hp-stats-box" class="muted">加载中…</div>
</div>
</div>
<div id="hp-detail-modal" class="hp-modal-backdrop" hidden>
<div class="hp-modal" role="dialog" aria-modal="true" aria-labelledby="hp-detail-title">
<div class="hp-modal-head">
<h3 id="hp-detail-title">成交细节</h3>
<button type="button" class="btn-secondary" id="hp-detail-close">关闭</button>
</div>
<div id="hp-detail-body" class="hp-modal-body muted">加载中…</div>
</div>
</div>
<div id="hp-preview-modal" class="hp-modal-backdrop" hidden>
<div class="hp-modal hp-preview-modal" role="dialog" aria-modal="true" aria-labelledby="hp-preview-title">
<div class="hp-modal-head">
<h3 id="hp-preview-title">情景测算</h3>
<button type="button" class="btn-secondary" id="hp-preview-cancel-x" aria-label="关闭">关闭</button>
</div>
<div id="hp-preview-summary" class="muted hp-preview-summary"></div>
<div class="options-strike-table-wrap">
<table class="options-strike-table" id="hp-result-table">
<thead>
<tr>
<th>情景</th>
<th>现货价</th>
<th id="hp-preview-mid-th">永续/腿盈亏</th>
<th>期权盈亏</th>
<th>合计≈U</th>
<th>说明</th>
</tr>
</thead>
<tbody id="hp-result-tbody">
<tr><td colspan="6" class="muted">计算中…</td></tr>
</tbody>
</table>
</div>
<div class="form-row hp-preview-actions">
<button type="button" class="btn-secondary" id="hp-preview-cancel">取消</button>
<button type="button" class="primary" id="hp-preview-start" disabled title="需开启 HEDGE_PLAN_LIVE_ORDER 等门禁">启动计划</button>
</div>
</div>
</div>
</div>
<script src="/static/hedge_plan.js?v=46"></script>