Add mutual-exclusion gate between hedge plans and standalone options.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-19 08:51:46 +08:00
parent 899a2de931
commit e11c13747a
8 changed files with 236 additions and 0 deletions
+2
View File
@@ -135,6 +135,8 @@ HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false
HEDGE_PLAN_OO_CLOSE_WINNER_ONLY=true
# 方案C:期期页面显示「平仓模式」(到期平/全平);关则固定到期平.默认开启,页面默认选全平
HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true
# 对冲与单独期权互斥(默认 true):有对冲计划不可单独开期权;有单独期权不可启动对冲;false=可同时开
HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE=true
MAX_ACTIVE_HEDGE_PLANS=1
HEDGE_PLAN_MONITOR_POLL_SECONDS=15
HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=true
+1
View File
@@ -91,6 +91,7 @@ HOT_RELOAD_EXACT = frozenset({
"HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS",
"HEDGE_PLAN_OO_CLOSE_WINNER_ONLY",
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE",
"MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_MONITOR_POLL_SECONDS",
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
+6
View File
@@ -146,6 +146,11 @@ _HEDGE_PLAN_SECTION: dict[str, Any] = {
"期期平仓模式(方案C)",
"默认 true;开启后页面可选「到期平/全平」(盈利腿平后另一腿);关闭则固定到期平",
),
(
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE",
"对冲与期权互斥门控",
"默认 true;开启时:有对冲计划则不可单独开期权,有单独期权则不可启动对冲;关闭后两边可同时开",
),
("MAX_ACTIVE_HEDGE_PLANS", "最大同时活跃计划数", "建议 1"),
("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"),
("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", "半腿失败时自动平期权", ""),
@@ -163,6 +168,7 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
"HEDGE_PLAN_SHOW_PERP_OPTIONS": "true",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "true",
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true",
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true",
}
@@ -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 None
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 None
try:
if has_standalone_option_position(conn, rows):
return "存在单独期权持仓,禁止启动对冲计划(可在 env「对冲与期权互斥门控」关闭)"
except Exception:
return None
return None
+7
View File
@@ -476,6 +476,8 @@ def gate_status(
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
@@ -505,6 +507,9 @@ def gate_status(
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
@@ -531,6 +536,8 @@ def gate_status(
"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,
+19
View File
@@ -143,18 +143,35 @@ def _max_active() -> int:
def _gates_dict(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]:
active = 0
has_standalone = False
mutual = True
try:
from lib.hedge_plan.hedge_options_exclusive_lib import (
has_standalone_option_position,
mutual_exclusive_enabled,
)
from lib.hedge_plan.hedge_plan_db import count_active_plans, init_hedge_plan_tables
mutual = mutual_exclusive_enabled()
conn = cfg["get_db"]()
try:
init_hedge_plan_tables(conn)
active = count_active_plans(conn)
if mutual:
try:
from lib.exchange.okx_options_lib import fetch_option_positions
ex = cfg.get("exchange_options") or cfg.get("exchange")
raw = fetch_option_positions(ex) if ex is not None else []
has_standalone = has_standalone_option_position(conn, raw or [])
except Exception:
has_standalone = False
conn.commit()
finally:
conn.close()
except Exception:
active = 0
has_standalone = False
return gate_status(
hedge_enabled=_hedge_enabled(),
sizing_mode=load_position_sizing_mode(),
@@ -166,6 +183,8 @@ def _gates_dict(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]:
max_active=_max_active(),
show_perp_options=_show_perp_options(),
show_options_options=_show_options_options(),
mutual_exclusive=mutual,
has_standalone_option=has_standalone,
)
+12
View File
@@ -509,6 +509,18 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
ex, err = _require_options_ex(cfg)
if ex is None:
return jsonify({"ok": False, "msg": err})
try:
from lib.hedge_plan.hedge_options_exclusive_lib import block_standalone_option_open_msg
conn_gate = cfg["get_db"]()
try:
block_msg = block_standalone_option_open_msg(conn_gate)
finally:
conn_gate.close()
if block_msg:
return jsonify({"ok": False, "msg": block_msg})
except Exception:
pass
data = request.get_json(silent=True) or {}
inst_id = (data.get("inst_id") or "").strip()
mode = (data.get("mode") or "budget_full").strip()
+103
View File
@@ -0,0 +1,103 @@
"""对冲计划与单独期权互斥门控."""
from __future__ import annotations
import os
import sqlite3
import unittest
from unittest import mock
from lib.hedge_plan.hedge_options_exclusive_lib import (
block_hedge_plan_start_msg,
block_standalone_option_open_msg,
has_standalone_option_position,
mutual_exclusive_enabled,
)
from lib.hedge_plan.hedge_plan_calc_lib import gate_status
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables
class HedgeOptionsExclusiveTests(unittest.TestCase):
def test_mutual_default_true(self):
with mock.patch.dict(os.environ, {}, clear=False):
os.environ.pop("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", None)
self.assertTrue(mutual_exclusive_enabled())
def test_mutual_can_disable(self):
with mock.patch.dict(os.environ, {"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "false"}):
self.assertFalse(mutual_exclusive_enabled())
def test_block_open_when_active_plan(self):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
init_hedge_plan_tables(conn)
conn.execute(
"INSERT INTO hedge_plans (plan_type, underlying, status) "
"VALUES ('options_options', 'ETH', 'active')"
)
conn.commit()
with mock.patch.dict(os.environ, {"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true"}):
msg = block_standalone_option_open_msg(conn)
self.assertIsNotNone(msg)
self.assertIn("对冲计划", msg or "")
with mock.patch.dict(os.environ, {"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "false"}):
self.assertIsNone(block_standalone_option_open_msg(conn))
conn.close()
def test_standalone_position_detection(self):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
init_hedge_plan_tables(conn)
raw = [{"instId": "ETH-USD_UM-260719-1890-C", "pos": "1"}]
with mock.patch(
"lib.instance.instance_dashboard_lib._resolve_options_source",
return_value=("option", "纯期权", None),
):
self.assertTrue(has_standalone_option_position(conn, raw))
with mock.patch(
"lib.instance.instance_dashboard_lib._resolve_options_source",
return_value=("options_options", "期期对冲", 2),
):
self.assertFalse(has_standalone_option_position(conn, raw))
conn.close()
def test_block_hedge_start_msg(self):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
init_hedge_plan_tables(conn)
raw = [{"instId": "ETH-USD_UM-260719-1890-C", "pos": "2"}]
with mock.patch.dict(os.environ, {"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true"}):
with mock.patch(
"lib.instance.instance_dashboard_lib._resolve_options_source",
return_value=("option", "纯期权", None),
):
msg = block_hedge_plan_start_msg(conn, raw_positions=raw)
self.assertIsNotNone(msg)
self.assertIn("单独期权", msg or "")
conn.close()
def test_gate_status_blocks_start(self):
g = gate_status(
hedge_enabled=True,
sizing_mode="full_margin",
plan_type="options_options",
options_enabled=True,
live_order=True,
mutual_exclusive=True,
has_standalone_option=True,
)
self.assertFalse(g["can_start"])
self.assertTrue(any("单独期权" in r for r in g["reasons"]))
g2 = gate_status(
hedge_enabled=True,
sizing_mode="full_margin",
plan_type="options_options",
options_enabled=True,
live_order=True,
mutual_exclusive=False,
has_standalone_option=True,
)
self.assertTrue(g2["can_start"])
if __name__ == "__main__":
unittest.main()