diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index ff228b1..da1f2b0 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -111,7 +111,7 @@ OKX_OPTIONS_API_PASSPHRASE= OKX_OPTIONS_ACCOUNT_LABEL=主账户·期权 OKX_OPTIONS_TRADE_BUDGET_USDC=10 OKX_OPTIONS_BUDGET_BUFFER=0.95 -# 期权同时持仓上限(笔,按交易所合约笔数);0=不限制;同合约加仓不占新笔数;热更 +# 期权同时持仓上限(笔,按交易所合约笔数);0=不限制;期期需≥2(或 0);同合约加仓不占新笔数;热更 OKX_OPTIONS_MAX_ACTIVE_POSITIONS=0 OKX_OPTIONS_DEFAULT_UNDERLY=ETH # 期权链仅显示卖一深度≥1张的合约(估算卖一/无深度不显示);false 则显示全部 diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index 69efbf7..9c1654e 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -133,7 +133,7 @@ _OPTIONS_SECTION: dict[str, Any] = { ( "OKX_OPTIONS_MAX_ACTIVE_POSITIONS", "期权持仓上限(笔)", - "默认 0=不限制;按交易所当前期权合约笔数计数,达上限禁止新开买(同合约加仓仍允许)", + "默认 0=不限制;按交易所期权合约笔数计数.期期对冲一次需 2 笔,上限设 1 时无法开期期(须≥2 或 0).同合约加仓不占新笔数.", ), ("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"), ( diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py index dac047d..969aa5e 100644 --- a/lib/hedge_plan/hedge_plan_orders_lib.py +++ b/lib/hedge_plan/hedge_plan_orders_lib.py @@ -690,7 +690,24 @@ def execute_options_options_start( results: list[dict[str, Any]] = [] leg_a = body.get("leg_a") or {} leg_b = body.get("leg_b") or {} - a_res = _buy_option(cfg, inst_id=str(leg_a.get("inst_id") or ""), sheets=float(leg_a.get("sheets") or 1), dry_run=dry_run) + inst_a = str(leg_a.get("inst_id") or "") + inst_b = str(leg_b.get("inst_id") or "") + from lib.options.options_position_limit_lib import option_position_limit_block_msg + + pos_limit_msg = option_position_limit_block_msg( + cfg.get("exchange_options"), + opening_inst_ids=[inst_a, inst_b], + fetch_positions=cfg.get("fetch_option_positions"), + ) + if pos_limit_msg: + return { + "ok": False, + "msg": pos_limit_msg, + "path": path, + "results": [], + "refresh": refresh, + } + a_res = _buy_option(cfg, inst_id=inst_a, sheets=float(leg_a.get("sheets") or 1), dry_run=dry_run) results.append({"step": "options_buy_limit", "leg": "a", **a_res}) if not a_res.get("ok"): return { @@ -700,7 +717,7 @@ def execute_options_options_start( "results": results, "refresh": refresh, } - b_res = _buy_option(cfg, inst_id=str(leg_b.get("inst_id") or ""), sheets=float(leg_b.get("sheets") or 1), dry_run=dry_run) + b_res = _buy_option(cfg, inst_id=inst_b, sheets=float(leg_b.get("sheets") or 1), dry_run=dry_run) results.append({"step": "options_buy_limit", "leg": "b", **b_res}) if not b_res.get("ok"): if not dry_run and partial_auto_close_enabled(): diff --git a/lib/options/options_position_limit_lib.py b/lib/options/options_position_limit_lib.py index b7d005b..9f89e52 100644 --- a/lib/options/options_position_limit_lib.py +++ b/lib/options/options_position_limit_lib.py @@ -2,7 +2,7 @@ from __future__ import annotations import os -from typing import Any, Optional +from typing import Any, Optional, Sequence def options_max_active_positions() -> int: @@ -41,17 +41,36 @@ def _inst_already_open(rows: list[dict[str, Any]], inst_id: str) -> bool: return False +def _normalize_inst_ids( + opening_inst_id: str = "", + opening_inst_ids: Optional[Sequence[str]] = None, +) -> list[str]: + out: list[str] = [] + seen: set[str] = set() + for raw in list(opening_inst_ids or []) + ([opening_inst_id] if opening_inst_id else []): + iid = str(raw or "").strip() + if not iid or iid in seen: + continue + seen.add(iid) + out.append(iid) + return out + + def option_position_limit_block_msg( ex: Any, *, opening_inst_id: str = "", + opening_inst_ids: Optional[Sequence[str]] = None, + new_positions: Optional[int] = None, max_active: Optional[int] = None, fetch_positions=None, ) -> Optional[str]: """若禁止新开买期权则返回中文原因,否则 None. - max_active<=0:不限制 - - 加仓已有合约(opening_inst_id 已在持仓中):不占新笔数,放行 + - opening_inst_ids:本次要开的合约;已在持仓中的不占新笔数 + - new_positions:显式指定还需新占几笔(默认按 opening_inst_ids 推算) + - 期期两腿应一次传入两个 inst_id,在开仓前预检,避免上限=1 时开出半边仓 - 拉持仓失败:拒绝开仓(避免绕过上限) """ mx = options_max_active_positions() if max_active is None else int(max_active) @@ -69,8 +88,26 @@ def option_position_limit_block_msg( if rows is None: return f"无法获取期权持仓,暂不可开仓(上限 {mx} 笔)" active = count_live_option_positions(rows) - if _inst_already_open(rows, opening_inst_id): + ids = _normalize_inst_ids(opening_inst_id, opening_inst_ids) + + if new_positions is None: + if ids: + already = sum(1 for i in ids if _inst_already_open(rows, i)) + need = max(0, len(ids) - already) + else: + need = 1 + else: + need = max(0, int(new_positions)) + if need <= 1 and len(ids) == 1 and _inst_already_open(rows, ids[0]): + return None + + if need <= 0: return None - if active >= mx: - return f"期权持仓已达上限({active}/{mx}),请先平仓后再开" - return None + if active + need <= mx: + return None + if need >= 2: + return ( + f"期期对冲需新开 {need} 笔期权,当前已有 {active} 笔、上限 {mx};" + f"请将 OKX_OPTIONS_MAX_ACTIVE_POSITIONS 设为 0(不限制)或不小于 {active + need},或先平仓" + ) + return f"期权持仓已达上限({active}/{mx}),请先平仓后再开" diff --git a/tests/test_options_position_limit.py b/tests/test_options_position_limit.py index b2f3026..94cc951 100644 --- a/tests/test_options_position_limit.py +++ b/tests/test_options_position_limit.py @@ -56,6 +56,36 @@ class OptionsPositionLimitTests(unittest.TestCase): ) self.assertIsNone(msg) + def test_oo_needs_two_slots_when_max_one(self): + msg = option_position_limit_block_msg( + object(), + opening_inst_ids=["ETH-C", "ETH-P"], + max_active=1, + fetch_positions=lambda _ex: [], + ) + self.assertIsNotNone(msg) + self.assertIn("期期", msg or "") + + def test_oo_ok_when_max_two_empty(self): + msg = option_position_limit_block_msg( + object(), + opening_inst_ids=["ETH-C", "ETH-P"], + max_active=2, + fetch_positions=lambda _ex: [], + ) + self.assertIsNone(msg) + + def test_oo_block_when_one_slot_left(self): + rows = [{"instId": "OTHER", "pos": "1"}] + msg = option_position_limit_block_msg( + object(), + opening_inst_ids=["ETH-C", "ETH-P"], + max_active=2, + fetch_positions=lambda _ex: rows, + ) + self.assertIsNotNone(msg) + self.assertIn("期期", msg or "") + def test_fail_closed_when_fetch_none(self): msg = option_position_limit_block_msg( object(),