From 94c65cbd6e889f3a340bbd1fb091a5738e1d822a Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 30 Jul 2026 08:12:50 +0800 Subject: [PATCH] Add OKX options max active positions env gate. Enforce concurrent option contract count on standalone and hedge buys; editable in env UI with hot reload (0 = unlimited). Co-authored-by: Cursor --- crypto_monitor_okx/.env.example | 2 + lib/env/env_schema.py | 1 + lib/env/env_ui_manifest.py | 6 ++ lib/hedge_plan/hedge_plan_orders_lib.py | 9 +++ lib/options/options_hub_lib.py | 2 + lib/options/options_position_limit_lib.py | 76 +++++++++++++++++++++++ lib/options/options_register.py | 36 +++++++++++ tests/test_options_position_limit.py | 71 +++++++++++++++++++++ 8 files changed, 203 insertions(+) create mode 100644 lib/options/options_position_limit_lib.py create mode 100644 tests/test_options_position_limit.py diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index b0a96f2..ff228b1 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -111,6 +111,8 @@ OKX_OPTIONS_API_PASSPHRASE= OKX_OPTIONS_ACCOUNT_LABEL=主账户·期权 OKX_OPTIONS_TRADE_BUDGET_USDC=10 OKX_OPTIONS_BUDGET_BUFFER=0.95 +# 期权同时持仓上限(笔,按交易所合约笔数);0=不限制;同合约加仓不占新笔数;热更 +OKX_OPTIONS_MAX_ACTIVE_POSITIONS=0 OKX_OPTIONS_DEFAULT_UNDERLY=ETH # 期权链仅显示卖一深度≥1张的合约(估算卖一/无深度不显示);false 则显示全部 OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED=true diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py index 8ce3bfc..13dc8db 100644 --- a/lib/env/env_schema.py +++ b/lib/env/env_schema.py @@ -90,6 +90,7 @@ HOT_RELOAD_EXACT = frozenset({ "HEDGE_PLAN_SHOW_PERP_OPTIONS", "HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", + "OKX_OPTIONS_MAX_ACTIVE_POSITIONS", "HEDGE_PLAN_LIVE_ORDER", "HEDGE_PLAN_OPEN_ORDER", "HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index 4d001de..69efbf7 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -130,6 +130,11 @@ _OPTIONS_SECTION: dict[str, Any] = { ("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""), ("OKX_OPTIONS_TRADE_BUDGET_USDC", "单笔预算(USDC)", ""), ("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95"), + ( + "OKX_OPTIONS_MAX_ACTIVE_POSITIONS", + "期权持仓上限(笔)", + "默认 0=不限制;按交易所当前期权合约笔数计数,达上限禁止新开买(同合约加仓仍允许)", + ), ("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"), ( "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", @@ -206,6 +211,7 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = { "HEDGE_PLAN_SHOW_PERP_OPTIONS": "true", "HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "true", "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED": "true", + "OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "0", "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true", "HEDGE_PLAN_OO_BIAS_SPLIT_BY": "budget", "HEDGE_PLAN_OO_BIAS_RATIO": "0.7", diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py index d4816f4..dac047d 100644 --- a/lib/hedge_plan/hedge_plan_orders_lib.py +++ b/lib/hedge_plan/hedge_plan_orders_lib.py @@ -125,6 +125,15 @@ def _buy_option( "ref_ask": q.get("ref_ask"), "can_open": False, } + from lib.options.options_position_limit_lib import option_position_limit_block_msg + + pos_limit_msg = option_position_limit_block_msg( + ex, + opening_inst_id=inst_id, + fetch_positions=cfg.get("fetch_option_positions"), + ) + if pos_limit_msg: + return {"ok": False, "msg": pos_limit_msg, "quote": q, "can_open": False} sheets_i = max(1, int(round(float(sheets)))) capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets_i, ask_sz, min_sz=1) if capped is None: diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py index f70777d..bc3c774 100644 --- a/lib/options/options_hub_lib.py +++ b/lib/options/options_hub_lib.py @@ -16,6 +16,7 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: if not ok: return {"ok": False, "enabled": True, "msg": reason or "期权 API 未配置"} try: + from lib.options.options_position_limit_lib import options_max_active_positions from lib.options.options_positions_lib import build_display_option_positions raw = cfg["fetch_option_positions"](ex) @@ -93,6 +94,7 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: "stats": {}, "trade_budget": cfg.get("trade_budget"), "account_label": cfg.get("account_label") or "OKX期权", + "max_active_positions": options_max_active_positions(), } except Exception as e: return {"ok": False, "enabled": True, "msg": str(e)} diff --git a/lib/options/options_position_limit_lib.py b/lib/options/options_position_limit_lib.py new file mode 100644 index 0000000..b7d005b --- /dev/null +++ b/lib/options/options_position_limit_lib.py @@ -0,0 +1,76 @@ +"""OKX 期权持仓笔数上限(env: OKX_OPTIONS_MAX_ACTIVE_POSITIONS).""" +from __future__ import annotations + +import os +from typing import Any, Optional + + +def options_max_active_positions() -> int: + """同时持有的期权合约笔数上限;0=不限制.热更读 env.""" + raw = os.getenv("OKX_OPTIONS_MAX_ACTIVE_POSITIONS", "0") + try: + v = int(float(str(raw).strip())) + except (TypeError, ValueError): + return 0 + return max(0, v) + + +def count_live_option_positions(rows: Optional[list[dict[str, Any]]]) -> int: + if not rows: + return 0 + n = 0 + for r in rows: + if not isinstance(r, dict): + continue + try: + pos = float(r.get("pos") or 0) + except (TypeError, ValueError): + continue + if abs(pos) >= 1e-12: + n += 1 + return n + + +def _inst_already_open(rows: list[dict[str, Any]], inst_id: str) -> bool: + want = (inst_id or "").strip() + if not want: + return False + for r in rows: + if str(r.get("instId") or r.get("inst_id") or "").strip() == want: + return True + return False + + +def option_position_limit_block_msg( + ex: Any, + *, + opening_inst_id: str = "", + max_active: Optional[int] = None, + fetch_positions=None, +) -> Optional[str]: + """若禁止新开买期权则返回中文原因,否则 None. + + - max_active<=0:不限制 + - 加仓已有合约(opening_inst_id 已在持仓中):不占新笔数,放行 + - 拉持仓失败:拒绝开仓(避免绕过上限) + """ + mx = options_max_active_positions() if max_active is None else int(max_active) + if mx <= 0: + return None + fetch = fetch_positions + if fetch is None: + from lib.exchange.okx_options_lib import fetch_option_positions + + fetch = fetch_option_positions + try: + rows = fetch(ex) + except Exception: + rows = None + if rows is None: + return f"无法获取期权持仓,暂不可开仓(上限 {mx} 笔)" + active = count_live_option_positions(rows) + if _inst_already_open(rows, opening_inst_id): + return None + if active >= mx: + return f"期权持仓已达上限({active}/{mx}),请先平仓后再开" + return None diff --git a/lib/options/options_register.py b/lib/options/options_register.py index c7ee833..247bfcf 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -478,6 +478,33 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: "budget_full_usdc": budget if mode == "budget_full" else None, } ) + from lib.options.options_position_limit_lib import option_position_limit_block_msg + + pos_limit_msg = option_position_limit_block_msg( + ex, + opening_inst_id=inst_id, + fetch_positions=cfg.get("fetch_option_positions"), + ) + if pos_limit_msg: + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": pos_limit_msg, + "quote_per_unit": ask, + "premium_per_sheet": None, + "sizing": { + "ok": False, + "msg": pos_limit_msg, + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + "available_usdc": available_usdc, + "budget_full_usdc": budget if mode == "budget_full" else None, + } + ) sizing = calc_order_size( quote_per_unit=float(ask), ct_mult=float(ct_mult), @@ -582,6 +609,15 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: "ref_ask": q.get("ref_ask"), } ) + from lib.options.options_position_limit_lib import option_position_limit_block_msg + + pos_limit_msg = option_position_limit_block_msg( + ex, + opening_inst_id=inst_id, + fetch_positions=cfg.get("fetch_option_positions"), + ) + if pos_limit_msg: + return jsonify({"ok": False, "msg": pos_limit_msg, "can_open": False}) ct_mult = float(q.get("ct_mult") or 0.01) min_sz = int(q.get("min_sz") or 1) eth_amount = None diff --git a/tests/test_options_position_limit.py b/tests/test_options_position_limit.py new file mode 100644 index 0000000..b2f3026 --- /dev/null +++ b/tests/test_options_position_limit.py @@ -0,0 +1,71 @@ +"""OKX 期权持仓笔数上限.""" + +import os +import unittest +from unittest.mock import patch + +from lib.options.options_position_limit_lib import ( + count_live_option_positions, + option_position_limit_block_msg, + options_max_active_positions, +) + + +class OptionsPositionLimitTests(unittest.TestCase): + def test_default_unlimited(self): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("OKX_OPTIONS_MAX_ACTIVE_POSITIONS", None) + self.assertEqual(options_max_active_positions(), 0) + + def test_parse_max(self): + with patch.dict(os.environ, {"OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "2"}): + self.assertEqual(options_max_active_positions(), 2) + + def test_count_live(self): + rows = [{"instId": "A", "pos": "1"}, {"instId": "B", "pos": "0"}, {"instId": "C", "pos": "-2"}] + self.assertEqual(count_live_option_positions(rows), 2) + + def test_block_when_at_limit(self): + rows = [{"instId": "ETH-C", "pos": "1"}, {"instId": "ETH-P", "pos": "2"}] + msg = option_position_limit_block_msg( + object(), + opening_inst_id="ETH-NEW", + max_active=2, + fetch_positions=lambda _ex: rows, + ) + self.assertIsNotNone(msg) + self.assertIn("上限", msg or "") + + def test_allow_add_to_existing(self): + rows = [{"instId": "ETH-C", "pos": "1"}, {"instId": "ETH-P", "pos": "2"}] + msg = option_position_limit_block_msg( + object(), + opening_inst_id="ETH-C", + max_active=2, + fetch_positions=lambda _ex: rows, + ) + self.assertIsNone(msg) + + def test_allow_under_limit(self): + rows = [{"instId": "ETH-C", "pos": "1"}] + msg = option_position_limit_block_msg( + object(), + opening_inst_id="ETH-P", + max_active=2, + fetch_positions=lambda _ex: rows, + ) + self.assertIsNone(msg) + + def test_fail_closed_when_fetch_none(self): + msg = option_position_limit_block_msg( + object(), + opening_inst_id="ETH-C", + max_active=1, + fetch_positions=lambda _ex: None, + ) + self.assertIsNotNone(msg) + self.assertIn("无法获取", msg or "") + + +if __name__ == "__main__": + unittest.main()