diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index a36f8c7..fe4c0c6 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -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 diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py index bd7671f..88d784d 100644 --- a/lib/env/env_schema.py +++ b/lib/env/env_schema.py @@ -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", diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index b5c2574..6058061 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -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", } diff --git a/lib/hedge_plan/hedge_options_exclusive_lib.py b/lib/hedge_plan/hedge_options_exclusive_lib.py new file mode 100644 index 0000000..c983d3b --- /dev/null +++ b/lib/hedge_plan/hedge_options_exclusive_lib.py @@ -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 diff --git a/lib/hedge_plan/hedge_plan_calc_lib.py b/lib/hedge_plan/hedge_plan_calc_lib.py index 0889d35..5008f3b 100644 --- a/lib/hedge_plan/hedge_plan_calc_lib.py +++ b/lib/hedge_plan/hedge_plan_calc_lib.py @@ -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, diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index d984fe2..ea76b52 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -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, ) diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 57ffe34..8d79382 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -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() diff --git a/tests/test_hedge_options_exclusive.py b/tests/test_hedge_options_exclusive.py new file mode 100644 index 0000000..3e132f8 --- /dev/null +++ b/tests/test_hedge_options_exclusive.py @@ -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()