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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-30 08:12:50 +08:00
parent 644dcdf092
commit 94c65cbd6e
8 changed files with 203 additions and 0 deletions
+2
View File
@@ -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
+1
View File
@@ -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",
+6
View File
@@ -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",
+9
View File
@@ -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:
+2
View File
@@ -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)}
+76
View File
@@ -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
+36
View File
@@ -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
+71
View File
@@ -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()