From 83ce50b24ee6a8f18cd3ee8d1bab12bc61fae58d Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 30 Jul 2026 08:28:50 +0800 Subject: [PATCH] Add OKX three-way trade mode for options vs hedge. Env OKX_TRADE_MODE selects standalone options, perp hedge, or OO hedge; hide the other module UI and use group or position limits per mode. Co-authored-by: Cursor --- crypto_monitor_okx/.env.example | 10 +- crypto_monitor_okx/app.py | 19 ++- lib/env/env_schema.py | 7 + lib/env/env_ui_manifest.py | 179 ++++++++++++++------ lib/hedge_plan/hedge_plan_register.py | 12 +- lib/hedge_plan/okx_trade_mode_lib.py | 103 +++++++++++ lib/instance/instance_dashboard_register.py | 12 +- lib/options/options_position_limit_lib.py | 8 + lib/options/options_register.py | 34 ++++ lib/options/templates/options_panel.html | 7 +- tests/test_okx_trade_mode.py | 95 +++++++++++ 11 files changed, 417 insertions(+), 69 deletions(-) create mode 100644 lib/hedge_plan/okx_trade_mode_lib.py create mode 100644 tests/test_okx_trade_mode.py diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index da1f2b0..b500b45 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -111,7 +111,10 @@ OKX_OPTIONS_API_PASSPHRASE= OKX_OPTIONS_ACCOUNT_LABEL=主账户·期权 OKX_OPTIONS_TRADE_BUDGET_USDC=10 OKX_OPTIONS_BUDGET_BUFFER=0.95 -# 期权同时持仓上限(笔,按交易所合约笔数);0=不限制;期期需≥2(或 0);同合约加仓不占新笔数;热更 +# 交易模式三选一(热更):options=单独期权 / perp_options=永期对冲 / options_options=期期对冲 +# 选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权,仓位按「对冲组数上限」 +OKX_TRADE_MODE=options +# 仅单独期权模式:期权同时持仓上限(笔);0=不限制;同合约加仓不占新笔数;热更 OKX_OPTIONS_MAX_ACTIVE_POSITIONS=0 OKX_OPTIONS_DEFAULT_UNDERLY=ETH # 期权链仅显示卖一深度≥1张的合约(估算卖一/无深度不显示);false 则显示全部 @@ -128,10 +131,10 @@ OKX_OPTIONS_ALLOW_MARKET_CLOSE=false OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12 # ============================================================================= -# 对冲计划(仅 OKX;前端 env「对冲计划」;详见 docs/对冲计划开发方案.md) +# 对冲计划(仅 OKX;由 OKX_TRADE_MODE 控制是否启用;详见 docs/对冲计划开发方案.md) # ============================================================================= +# 以下三项已由 OKX_TRADE_MODE 取代,保留兼容旧部署(未配置 TRADE_MODE 时仍可读) HEDGE_PLAN_ENABLED=false -# 页面 Tab 显示(默认全部显示,可单独关闭;不影响已有进行中/历史计划) HEDGE_PLAN_SHOW_PERP_OPTIONS=true HEDGE_PLAN_SHOW_OPTIONS_OPTIONS=true HEDGE_PLAN_LIVE_ORDER=false @@ -149,6 +152,7 @@ HEDGE_PLAN_OO_BIAS_RATIO=0.7 HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE=true # 半腿失败改手动补开(默认 true):不自动平已成腿,计划挂 partial,页面补开;开启时下方自动平强制无效 HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL=true +# 对冲组数上限(默认 1;opening/active/partial 计入);仅永期/期期模式生效;热更 MAX_ACTIVE_HEDGE_PLANS=1 HEDGE_PLAN_MONITOR_POLL_SECONDS=15 # 半腿失败自动平期权;若 MANUAL_COMPLETE_ON_PARTIAL=true 则运行时强制无效(建议一并写成 false) diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index ee7daa3..c87f0e2 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -6714,6 +6714,10 @@ def render_main_page(page="trade", embed_mode=None): from lib.instance.instance_display_prefs_lib import display_prefs_template_context _display_ctx = display_prefs_template_context(get_db) + from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode + + _okx_trade_mode = get_okx_trade_mode() + _hedge_mode_on = _okx_trade_mode in ("perp_options", "options_options") template_ctx = dict( page=page, key=key_list, @@ -6803,12 +6807,12 @@ def render_main_page(page="trade", embed_mode=None): exchange_display=EXCHANGE_DISPLAY_NAME, options_enabled=OKX_OPTIONS_ENABLED, options_nav_visible=True, - hedge_plan_enabled=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"), - hedge_plan_nav_visible=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"), - hedge_plan_show_perp_options=os.getenv("HEDGE_PLAN_SHOW_PERP_OPTIONS", "true").lower() - in ("1", "true", "yes", "on"), - hedge_plan_show_options_options=os.getenv("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "true").lower() - in ("1", "true", "yes", "on"), + okx_trade_mode=_okx_trade_mode, + options_open_allowed=_okx_trade_mode == "options", + hedge_plan_enabled=_hedge_mode_on, + hedge_plan_nav_visible=_hedge_mode_on, + hedge_plan_show_perp_options=_okx_trade_mode == "perp_options", + hedge_plan_show_options_options=_okx_trade_mode == "options_options", hedge_plan_oo_close_mode_enabled=os.getenv("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", "true").lower() in ("1", "true", "yes", "on"), hedge_plan_budget_buffer=float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"), @@ -9110,6 +9114,7 @@ def _dashboard_enrich_orders(items): return enrich_order_items_with_marks(items, get_price=get_price) +from lib.hedge_plan.okx_trade_mode_lib import hedge_module_enabled from lib.instance.instance_dashboard_register import register_instance_dashboard_routes register_instance_dashboard_routes( @@ -9118,7 +9123,7 @@ register_instance_dashboard_routes( get_db=get_db, fetch_options_positions=_dashboard_fetch_options_positions, enrich_orders=_dashboard_enrich_orders, - hedge_enabled=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"), + hedge_enabled=hedge_module_enabled, ) diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py index 13dc8db..3c5eaee 100644 --- a/lib/env/env_schema.py +++ b/lib/env/env_schema.py @@ -91,6 +91,8 @@ HOT_RELOAD_EXACT = frozenset({ "HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "OKX_OPTIONS_MAX_ACTIVE_POSITIONS", + "OKX_TRADE_MODE", + "MAX_ACTIVE_HEDGE_PLANS", "HEDGE_PLAN_LIVE_ORDER", "HEDGE_PLAN_OPEN_ORDER", "HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", @@ -145,6 +147,11 @@ SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = { ("budget", "预算金额"), ("sheets", "张数"), ), + "OKX_TRADE_MODE": ( + ("options", "单独期权"), + ("perp_options", "永期对冲"), + ("options_options", "期期对冲"), + ), } _SELECT_ALIASES: dict[str, dict[str, str]] = { diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index 9c1654e..b8618c9 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -119,6 +119,18 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [ }, ] +_MODE_SECTION: dict[str, Any] = { + "title": "期权/对冲模式", + "exchanges": frozenset({"okx"}), + "fields": [ + ( + "OKX_TRADE_MODE", + "交易模式", + "三选一:单独期权 / 永期对冲 / 期期对冲.选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权", + ), + ], +} + _OPTIONS_SECTION: dict[str, Any] = { "title": "期权账户", "exchanges": frozenset({"okx"}), @@ -133,7 +145,7 @@ _OPTIONS_SECTION: dict[str, Any] = { ( "OKX_OPTIONS_MAX_ACTIVE_POSITIONS", "期权持仓上限(笔)", - "默认 0=不限制;按交易所期权合约笔数计数.期期对冲一次需 2 笔,上限设 1 时无法开期期(须≥2 或 0).同合约加仓不占新笔数.", + "仅「单独期权」模式生效;默认 0=不限制;按交易所期权合约笔数计数,同合约加仓不占新笔数", ), ("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"), ( @@ -144,55 +156,69 @@ _OPTIONS_SECTION: dict[str, Any] = { ], } +# 对冲公共字段(不含已由 OKX_TRADE_MODE 取代的 ENABLED/SHOW/MUTUAL) +_HEDGE_COMMON_FIELDS: list[tuple[str, str, str]] = [ + ("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘 LIVE_TRADING_ENABLED 同开才可启动"), + ( + "MAX_ACTIVE_HEDGE_PLANS", + "对冲组数上限", + "默认 1;同时进行中的对冲计划组数(opening/active/partial),可改", + ), + ("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"), + ( + "HEDGE_PLAN_BUDGET_BUFFER", + "对冲预算缓冲比例", + "默认 0.95;仅对冲计划;与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立", + ), + ( + "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", + "半腿失败改手动补开", + "默认 true;开启时半腿失败不自动平,计划挂 partial,页面可补开;并强制关闭下方自动平", + ), + ( + "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", + "半腿失败时自动平期权", + "默认 true;若上方「半腿失败改手动补开」开启则本项强制无效", + ), +] + +_HEDGE_PO_FIELDS: list[tuple[str, str, str]] = [ + ("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"), + ("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"), + ("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"), +] + +_HEDGE_OO_FIELDS: list[tuple[str, str, str]] = [ + ("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "期期只平盈利腿", "达目标价只平盈利方"), + ( + "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", + "期期平仓模式(方案C)", + "默认 true;开启后页面可选「到期平/全平」;关闭则固定到期平", + ), + ( + "HEDGE_PLAN_OO_BIAS_SPLIT_BY", + "期期做多做空拆分口径", + "默认预算金额;budget=按权利金预算分两腿;sheets=先算同张数再按比例拆", + ), + ( + "HEDGE_PLAN_OO_BIAS_RATIO", + "期期做多做空主腿占比", + "默认 0.7(即 7:3);做多主腿=Call,做空主腿=Put;须在 0~1 之间", + ), +] + +# 兼容旧测试/全量字段列表(写 env 时仍允许这些键,但 UI 按模式过滤) _HEDGE_PLAN_SECTION: dict[str, Any] = { "title": "对冲计划", "exchanges": frozenset({"okx"}), "fields": [ - ("HEDGE_PLAN_ENABLED", "启用对冲计划", "关闭则隐藏导航且不可开仓"), - ("HEDGE_PLAN_SHOW_PERP_OPTIONS", "显示永期对冲", "默认 true;关闭后隐藏永期 Tab,不可测算/开仓"), - ("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "显示期期对冲", "默认 true;关闭后隐藏期期 Tab,不可测算/开仓"), - ("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘 LIVE_TRADING_ENABLED 同开才可启动永期"), - ("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"), - ("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"), - ("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"), - ("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "期期只平盈利腿", "达目标价只平盈利方"), - ( - "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", - "期期平仓模式(方案C)", - "默认 true;开启后页面可选「到期平/全平」(盈利腿平后另一腿);关闭则固定到期平", - ), - ( - "HEDGE_PLAN_OO_BIAS_SPLIT_BY", - "期期做多做空拆分口径", - "默认预算金额;budget=按权利金预算按比例分两腿;sheets=先算同张数总张数(2n)再按比例拆", - ), - ( - "HEDGE_PLAN_OO_BIAS_RATIO", - "期期做多做空主腿占比", - "默认 0.7(即 7:3);做多主腿=Call,做空主腿=Put;须在 0~1 之间", - ), - ( - "HEDGE_PLAN_BUDGET_BUFFER", - "对冲预算缓冲比例", - "默认 0.95;仅对冲计划(期期可用预算=交易户×本比例);与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立", - ), - ( - "HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", - "对冲与期权互斥门控", - "默认 true;开启时:有对冲计划则不可单独开期权,有单独期权则不可启动对冲;关闭后两边可同时开", - ), - ( - "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", - "半腿失败改手动补开", - "默认 true;开启时半腿失败不自动平,计划挂 partial,页面可补开永续/腿B;并强制关闭下方自动平", - ), - ("MAX_ACTIVE_HEDGE_PLANS", "最大同时活跃计划数", "建议 1"), - ("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"), - ( - "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", - "半腿失败时自动平期权", - "默认 true;若上方「半腿失败改手动补开」开启则本项强制无效(不会自动平)", - ), + ("HEDGE_PLAN_ENABLED", "启用对冲计划", "已由「交易模式」取代,一般无需再改"), + ("HEDGE_PLAN_SHOW_PERP_OPTIONS", "显示永期对冲", "已由「交易模式」取代"), + ("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "显示期期对冲", "已由「交易模式」取代"), + ("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", "对冲与期权互斥门控", "已由「交易模式」三选一取代"), + *_HEDGE_COMMON_FIELDS, + *_HEDGE_PO_FIELDS, + *_HEDGE_OO_FIELDS, ], } @@ -212,6 +238,8 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = { "HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "true", "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED": "true", "OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "0", + "OKX_TRADE_MODE": "options", + "MAX_ACTIVE_HEDGE_PLANS": "1", "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true", "HEDGE_PLAN_OO_BIAS_SPLIT_BY": "budget", "HEDGE_PLAN_OO_BIAS_RATIO": "0.7", @@ -293,24 +321,69 @@ def _build_field( return out -def ui_sections_for_exchange(exchange_key: str) -> list[dict[str, Any]]: +def _okx_mode_for_env_ui() -> str: + try: + from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode + + return get_okx_trade_mode() + except Exception: + return "options" + + +def _options_fields_for_mode(mode: str) -> list[tuple[str, str, str]]: + fields = list(_OPTIONS_SECTION["fields"]) + if mode != "options": + fields = [f for f in fields if f[0] != "OKX_OPTIONS_MAX_ACTIVE_POSITIONS"] + return fields + + +def _hedge_fields_for_mode(mode: str) -> list[tuple[str, str, str]]: + if mode == "perp_options": + return [*_HEDGE_COMMON_FIELDS, *_HEDGE_PO_FIELDS] + if mode == "options_options": + return [*_HEDGE_COMMON_FIELDS, *_HEDGE_OO_FIELDS] + return [] + + +def ui_sections_for_exchange( + exchange_key: str, + *, + mode: str | None = None, +) -> list[dict[str, Any]]: ex = (exchange_key or "").strip().lower() sections: list[dict[str, Any]] = [] live_fields = _EXCHANGE_LIVE_FIELDS.get(ex, _EXCHANGE_LIVE_FIELDS["okx"]) sections.append({"title": "交易所与实盘", "fields": live_fields}) sections.extend(_SHARED_SECTIONS) - if ex in _OPTIONS_SECTION.get("exchanges", frozenset()): - sections.append(_OPTIONS_SECTION) - if ex in _HEDGE_PLAN_SECTION.get("exchanges", frozenset()): - sections.append(_HEDGE_PLAN_SECTION) + if ex in _MODE_SECTION.get("exchanges", frozenset()): + from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode + + m = normalize_okx_trade_mode(mode) if mode else "" + if not m: + m = _okx_mode_for_env_ui() + sections.append(_MODE_SECTION) + sections.append({"title": "期权账户", "fields": _options_fields_for_mode(m)}) + hedge_fields = _hedge_fields_for_mode(m) + if hedge_fields: + title = "对冲计划·永期" if m == "perp_options" else "对冲计划·期期" + sections.append({"title": title, "fields": hedge_fields}) return sections def ui_allowed_keys(exchange_key: str) -> frozenset[str]: + """可写键=当前模式可见字段 + 模式切换键 + 遗留对冲开关(兼容旧脚本写入).""" keys: set[str] = set() for sec in ui_sections_for_exchange(exchange_key): for item in sec["fields"]: keys.add(item[0]) + ex = (exchange_key or "").strip().lower() + if ex == "okx": + keys.add("OKX_TRADE_MODE") + # 允许写入遗留键,避免旧自动化/手改失败;页面不再展示 + for item in _HEDGE_PLAN_SECTION["fields"]: + keys.add(item[0]) + for item in _OPTIONS_SECTION["fields"]: + keys.add(item[0]) return frozenset(keys) @@ -323,7 +396,9 @@ def build_env_ui_payload( env_lines = read_env_lines(env_path) values = env_get_all(env_lines) groups: list[dict[str, Any]] = [] - for sec in ui_sections_for_exchange(exchange_key): + for sec in ui_sections_for_exchange( + exchange_key, mode=values.get("OKX_TRADE_MODE") or "" + ): fields = [ _build_field(key, label, note, schema, values) for key, label, note in sec["fields"] diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index a73e212..139eb17 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -108,15 +108,21 @@ def _build_cfg(app_module: Any) -> dict[str, Any]: def _hedge_enabled() -> bool: - return _env_bool("HEDGE_PLAN_ENABLED", False) + from lib.hedge_plan.okx_trade_mode_lib import hedge_module_enabled + + return hedge_module_enabled() def _show_perp_options() -> bool: - return _env_bool("HEDGE_PLAN_SHOW_PERP_OPTIONS", True) + from lib.hedge_plan.okx_trade_mode_lib import show_perp_options + + return show_perp_options() def _show_options_options() -> bool: - return _env_bool("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", True) + from lib.hedge_plan.okx_trade_mode_lib import show_options_options + + return show_options_options() def _oo_close_mode_enabled() -> bool: diff --git a/lib/hedge_plan/okx_trade_mode_lib.py b/lib/hedge_plan/okx_trade_mode_lib.py new file mode 100644 index 0000000..2da692c --- /dev/null +++ b/lib/hedge_plan/okx_trade_mode_lib.py @@ -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「交易模式」切换为「单独期权」" + ) diff --git a/lib/instance/instance_dashboard_register.py b/lib/instance/instance_dashboard_register.py index cbfe20a..0520a66 100644 --- a/lib/instance/instance_dashboard_register.py +++ b/lib/instance/instance_dashboard_register.py @@ -12,19 +12,27 @@ def register_instance_dashboard_routes( login_required: Callable, get_db: Callable, fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None, - hedge_enabled: bool = False, + hedge_enabled: bool | Callable[[], bool] = False, enrich_orders: Optional[Callable[[list[dict[str, Any]]], list[dict[str, Any]]]] = None, ) -> None: from lib.instance.instance_dashboard_cache import instance_dashboard_store from lib.instance.instance_dashboard_lib import build_instance_dashboard_payload + def _hedge_on() -> bool: + if callable(hedge_enabled): + try: + return bool(hedge_enabled()) + except Exception: + return False + return bool(hedge_enabled) + def _build() -> dict[str, Any]: conn = get_db() try: payload = build_instance_dashboard_payload( conn, fetch_options_positions=fetch_options_positions, - hedge_enabled=bool(hedge_enabled), + hedge_enabled=_hedge_on(), ) if callable(enrich_orders) and payload.get("ok") and isinstance(payload.get("orders"), dict): items = list(payload["orders"].get("items") or []) diff --git a/lib/options/options_position_limit_lib.py b/lib/options/options_position_limit_lib.py index 9f89e52..93a2205 100644 --- a/lib/options/options_position_limit_lib.py +++ b/lib/options/options_position_limit_lib.py @@ -73,6 +73,14 @@ def option_position_limit_block_msg( - 期期两腿应一次传入两个 inst_id,在开仓前预检,避免上限=1 时开出半边仓 - 拉持仓失败:拒绝开仓(避免绕过上限) """ + try: + from lib.hedge_plan.okx_trade_mode_lib import standalone_options_open_allowed + + # 对冲模式用 MAX_ACTIVE_HEDGE_PLANS 管「组数」,不占用期权笔数上限 + if max_active is None and not standalone_options_open_allowed(): + return None + except Exception: + pass mx = options_max_active_positions() if max_active is None else int(max_active) if mx <= 0: return None diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 247bfcf..0a8edd8 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -456,6 +456,32 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: pass ask = q.get("ask") ask_sz = q.get("ask_sz") + try: + from lib.hedge_plan.okx_trade_mode_lib import block_standalone_open_by_mode_msg + + mode_block = block_standalone_open_by_mode_msg() + if mode_block: + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": mode_block, + "quote_per_unit": ask, + "premium_per_sheet": None, + "sizing": { + "ok": False, + "msg": mode_block, + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + "available_usdc": available_usdc, + "budget_full_usdc": budget if mode == "budget_full" else None, + } + ) + except Exception: + pass can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) if not can_open: # 合约可报价,但不可开仓:返回参考标记价供展示 @@ -566,6 +592,14 @@ 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.okx_trade_mode_lib import block_standalone_open_by_mode_msg + + mode_block = block_standalone_open_by_mode_msg() + if mode_block: + return jsonify({"ok": False, "msg": mode_block, "can_open": False}) + except Exception: + pass try: from lib.hedge_plan.hedge_options_exclusive_lib import block_standalone_option_open_msg diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index d99f142..9d1b23a 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -6,10 +6,13 @@ {% if not options_enabled %}
期权 API 未启用:请在 crypto_monitor_okx/.env 设置 OKX_OPTIONS_ENABLED=true 及主账户 OKX_OPTIONS_API_*,然后 pm2 restart crypto_okx --update-env.
{% endif %} + {% if options_enabled and options_open_allowed is defined and not options_open_allowed %} +
当前交易模式为对冲(永期/期期),不可单独开期权;持仓可在此查看/平仓.切换请到 env「交易模式」.
+ {% endif %}
-
-

期权下单

+
+

期权下单{% if options_open_allowed is defined and not options_open_allowed %} (对冲模式已禁用开仓){% endif %}

开仓规则说明
diff --git a/tests/test_okx_trade_mode.py b/tests/test_okx_trade_mode.py new file mode 100644 index 0000000..bb1e20e --- /dev/null +++ b/tests/test_okx_trade_mode.py @@ -0,0 +1,95 @@ +"""OKX_TRADE_MODE 三选一.""" + +import os +import unittest +from unittest.mock import patch + +from lib.hedge_plan.okx_trade_mode_lib import ( + MODE_OO, + MODE_OPTIONS, + MODE_PERP, + block_standalone_open_by_mode_msg, + get_okx_trade_mode, + hedge_module_enabled, + show_options_options, + show_perp_options, + standalone_options_open_allowed, +) + + +class OkxTradeModeTests(unittest.TestCase): + def test_explicit_modes(self): + for mode, hedge, po, oo in ( + (MODE_OPTIONS, False, False, False), + (MODE_PERP, True, True, False), + (MODE_OO, True, False, True), + ): + with patch.dict(os.environ, {"OKX_TRADE_MODE": mode}, clear=False): + self.assertEqual(get_okx_trade_mode(), mode) + self.assertEqual(hedge_module_enabled(), hedge) + self.assertEqual(show_perp_options(), po) + self.assertEqual(show_options_options(), oo) + self.assertEqual(standalone_options_open_allowed(), mode == MODE_OPTIONS) + + def test_legacy_infer_options(self): + with patch.dict( + os.environ, + {"HEDGE_PLAN_ENABLED": "false"}, + clear=False, + ): + os.environ.pop("OKX_TRADE_MODE", None) + self.assertEqual(get_okx_trade_mode(), MODE_OPTIONS) + + def test_legacy_infer_perp(self): + with patch.dict( + os.environ, + { + "HEDGE_PLAN_ENABLED": "true", + "HEDGE_PLAN_SHOW_PERP_OPTIONS": "true", + "HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "false", + }, + clear=False, + ): + os.environ.pop("OKX_TRADE_MODE", None) + self.assertEqual(get_okx_trade_mode(), MODE_PERP) + + def test_block_standalone_in_hedge_mode(self): + with patch.dict(os.environ, {"OKX_TRADE_MODE": MODE_OO}): + msg = block_standalone_open_by_mode_msg() + self.assertIsNotNone(msg) + self.assertIn("期期", msg or "") + + +class EnvUiModeSectionsTests(unittest.TestCase): + def test_options_mode_hides_hedge_section(self): + from lib.env.env_ui_manifest import ui_sections_for_exchange + + titles = [s["title"] for s in ui_sections_for_exchange("okx", mode="options")] + self.assertIn("期权/对冲模式", titles) + self.assertIn("期权账户", titles) + self.assertTrue(all("对冲" not in t for t in titles if t != "期权/对冲模式")) + + def test_perp_mode_shows_po_fields(self): + from lib.env.env_ui_manifest import ui_sections_for_exchange + + secs = {s["title"]: s["fields"] for s in ui_sections_for_exchange("okx", mode="perp_options")} + self.assertIn("对冲计划·永期", secs) + keys = {f[0] for f in secs["对冲计划·永期"]} + self.assertIn("MAX_ACTIVE_HEDGE_PLANS", keys) + self.assertIn("HEDGE_PLAN_OPEN_ORDER", keys) + self.assertNotIn("HEDGE_PLAN_OO_BIAS_RATIO", keys) + opt_keys = {f[0] for f in secs["期权账户"]} + self.assertNotIn("OKX_OPTIONS_MAX_ACTIVE_POSITIONS", opt_keys) + + def test_oo_mode_shows_oo_fields(self): + from lib.env.env_ui_manifest import ui_sections_for_exchange + + secs = {s["title"]: s["fields"] for s in ui_sections_for_exchange("okx", mode="options_options")} + self.assertIn("对冲计划·期期", secs) + keys = {f[0] for f in secs["对冲计划·期期"]} + self.assertIn("HEDGE_PLAN_OO_BIAS_RATIO", keys) + self.assertNotIn("HEDGE_PLAN_OPEN_ORDER", keys) + + +if __name__ == "__main__": + unittest.main()