From 5b346a57607ca7208a315003bc519cf6e8219f8d Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 20 Jul 2026 10:09:32 +0800 Subject: [PATCH] Hedge start: re-quote ask and resize OO sheets; add HEDGE_PLAN_BUDGET_BUFFER. Co-authored-by: Cursor --- lib/common/static/hedge_plan.js | 16 +- lib/env/env_schema.py | 1 + lib/env/env_ui_manifest.py | 6 + lib/hedge_plan/hedge_plan_orders_lib.py | 217 +++++++++++++++++- lib/hedge_plan/hedge_plan_register.py | 5 +- .../templates/hedge_plan_panel.html | 2 +- tests/test_hedge_plan_orders.py | 24 +- tests/test_hedge_start_refresh.py | 125 ++++++++++ 8 files changed, 382 insertions(+), 14 deletions(-) create mode 100644 tests/test_hedge_start_refresh.py diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js index 04576a2..9f794a2 100644 --- a/lib/common/static/hedge_plan.js +++ b/lib/common/static/hedge_plan.js @@ -487,9 +487,9 @@ else if (b.msg) parts.push(b.msg); line.textContent = parts.join(" · "); line.title = - "预算=min(交易×" + + "对冲预算=min(交易×" + fmt(b.buf, 2) + - ", 单笔)" + + ", 单笔) · 缓冲 HEDGE_PLAN_BUDGET_BUFFER" + (b.tradingCap != null ? " · 交易×缓冲 " + fmt(b.tradingCap, 2) : "") + (b.tradeCap != null ? " · 单笔 " + fmt(b.tradeCap, 2) : ""); line.classList.toggle("hp-oo-budget-warn", !!(extra && extra.msg) || !b.ok); @@ -1692,6 +1692,7 @@ target_price_down: down, target_price: up, oo_close_mode: state.ooCloseModeEnabled ? state.ooCloseMode : "hold_expiry", + oo_sheets_mode: state.ooSheetsMode || "same_sheets", leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")), }; @@ -1731,19 +1732,28 @@ }); setGateLine(d.gates); closePreviewModal(); + const refreshHint = + d.refresh && d.refresh.msg ? "\n" + String(d.refresh.msg) : ""; if (d.partial) { alert( (d.msg || "半腿失败,已挂待补") + (d.plan_id ? "\n计划 #" + d.plan_id : "") + + refreshHint + "\n请到「进行中的计划」补开缺失腿" ); state.tab = "active"; syncTabUI(); void loadActivePlans(); } else { - alert("计划已启动 #" + (d.plan_id || "") + (d.dry_run ? " (dry_run)" : "")); + alert( + "计划已启动 #" + + (d.plan_id || "") + + (d.dry_run ? " (dry_run)" : "") + + refreshHint + ); } void loadGates(); + if (isOo) void loadChain(); } catch (e) { alert(e.message || String(e)); syncPreviewStartBtn(); diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py index 81086d8..24aa0d4 100644 --- a/lib/env/env_schema.py +++ b/lib/env/env_schema.py @@ -94,6 +94,7 @@ HOT_RELOAD_EXACT = frozenset({ "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", "HEDGE_PLAN_OO_BIAS_SPLIT_BY", "HEDGE_PLAN_OO_BIAS_RATIO", + "HEDGE_PLAN_BUDGET_BUFFER", "HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", "MAX_ACTIVE_HEDGE_PLANS", diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index 378869d..dcc9d00 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -165,6 +165,11 @@ _HEDGE_PLAN_SECTION: dict[str, Any] = { "期期做多做空主腿占比", "默认 0.7(即 7:3);做多主腿=Call,做空主腿=Put;须在 0~1 之间", ), + ( + "HEDGE_PLAN_BUDGET_BUFFER", + "对冲预算缓冲比例", + "默认 0.95;仅对冲计划(期期可用预算=交易户×本比例);与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立", + ), ( "HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", "对冲与期权互斥门控", @@ -199,6 +204,7 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = { "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true", "HEDGE_PLAN_OO_BIAS_SPLIT_BY": "budget", "HEDGE_PLAN_OO_BIAS_RATIO": "0.7", + "HEDGE_PLAN_BUDGET_BUFFER": "0.95", "HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true", "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "true", } diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py index d42ce52..d4816f4 100644 --- a/lib/hedge_plan/hedge_plan_orders_lib.py +++ b/lib/hedge_plan/hedge_plan_orders_lib.py @@ -350,6 +350,201 @@ def _park_partial( return out +def _hedge_budget_buffer(cfg: dict[str, Any] | None = None) -> float: + """对冲专用预算缓冲;默认 0.95.与 OKX_OPTIONS_BUDGET_BUFFER 独立.""" + raw = None + if cfg is not None: + raw = cfg.get("budget_buffer") + if raw is None or raw == "": + raw = os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95" + try: + buf = float(raw) + except (TypeError, ValueError): + buf = 0.95 + if buf <= 0: + buf = 0.95 + if buf > 1: + buf = 1.0 + return float(buf) + + +def _oo_bias_settings(cfg: dict[str, Any] | None = None) -> tuple[str, float]: + from lib.hedge_plan.hedge_plan_calc_lib import _clamp_oo_bias_ratio, _normalize_oo_bias_split_by + + split = None + ratio = None + if cfg is not None: + split = cfg.get("oo_bias_split_by") + ratio = cfg.get("oo_bias_ratio") + if split in (None, ""): + split = os.getenv("HEDGE_PLAN_OO_BIAS_SPLIT_BY") or "budget" + if ratio in (None, ""): + ratio = os.getenv("HEDGE_PLAN_OO_BIAS_RATIO") or "0.7" + return _normalize_oo_bias_split_by(split), _clamp_oo_bias_ratio(ratio) + + +def refresh_oo_sizing_before_start(cfg: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]: + """启动前再拉两腿卖一,按对冲预算缓冲重算张数;就地写回 body.leg_*. + + 方案 A:成交价与张数均基于点击启动瞬间的最新卖一/余额. + """ + from lib.exchange.okx_options_lib import fetch_options_trading_usdc, option_buy_liquidity_ok + from lib.hedge_plan.hedge_plan_calc_lib import resolve_oo_budget_usdc, suggest_oo_sheets + + leg_a = dict(body.get("leg_a") or {}) + leg_b = dict(body.get("leg_b") or {}) + inst_a = str(leg_a.get("inst_id") or "").strip() + inst_b = str(leg_b.get("inst_id") or "").strip() + if not inst_a or not inst_b: + return {"ok": False, "msg": "缺少期权合约"} + quote_fn = cfg.get("quote_option_contract") + ex = cfg.get("exchange_options") + if not callable(quote_fn) or ex is None: + return {"ok": False, "msg": "期权报价能力未就绪"} + + qa = quote_fn(ex, inst_a) + if not qa.get("ok"): + return {"ok": False, "msg": qa.get("msg") or "腿A报价失败", "quote_a": qa} + qb = quote_fn(ex, inst_b) + if not qb.get("ok"): + return {"ok": False, "msg": qb.get("msg") or "腿B报价失败", "quote_b": qb} + + for tag, q in (("A", qa), ("B", qb)): + can_open, block_msg = option_buy_liquidity_ok(q.get("ask"), q.get("ask_sz")) + if not can_open: + return { + "ok": False, + "msg": f"腿{tag}: {block_msg or '暂无卖一深度,无法买入'}", + "quote_a": qa, + "quote_b": qb, + } + + trading = fetch_options_trading_usdc(ex) + buf = _hedge_budget_buffer(cfg) + budget_info = resolve_oo_budget_usdc( + trading_usdc=trading, + trade_budget_usdc=cfg.get("trade_budget_usdc"), + buffer_ratio=buf, + ) + if not budget_info.get("ok"): + return { + "ok": False, + "msg": budget_info.get("msg") or "可用预算不足", + "budget": budget_info, + "quote_a": qa, + "quote_b": qb, + } + + mode = str(body.get("oo_sheets_mode") or "same_sheets") + split_by, bias_ratio = _oo_bias_settings(cfg) + opt_a = str( + leg_a.get("opt_type") + or (qa.get("meta") or {}).get("optType") + or qa.get("opt_type") + or "" + ) + opt_b = str( + leg_b.get("opt_type") + or (qb.get("meta") or {}).get("optType") + or qb.get("opt_type") + or "" + ) + sug = suggest_oo_sheets( + mode=mode, + budget_usdc=float(budget_info["budget_usdc"]), + ask_a=float(qa["ask"]), + ct_mult_a=float(qa.get("ct_mult") or leg_a.get("ct_mult") or 0.01), + ask_sz_a=qa.get("ask_sz"), + opt_type_a=opt_a, + ask_b=float(qb["ask"]), + ct_mult_b=float(qb.get("ct_mult") or leg_b.get("ct_mult") or 0.01), + ask_sz_b=qb.get("ask_sz"), + opt_type_b=opt_b, + bias_split_by=split_by, + bias_ratio=bias_ratio, + ) + if not sug.get("ok"): + return { + "ok": False, + "msg": sug.get("msg") or "按最新卖一无法建议张数", + "sizing": sug, + "budget": budget_info, + "quote_a": qa, + "quote_b": qb, + } + + prev_a = leg_a.get("sheets") + prev_b = leg_b.get("sheets") + leg_a["sheets"] = int(sug["sheets_a"]) + leg_a["ask"] = float(qa["ask"]) + leg_a["ask_sz"] = qa.get("ask_sz") + leg_a["ct_mult"] = float(qa.get("ct_mult") or leg_a.get("ct_mult") or 0.01) + if opt_a: + leg_a["opt_type"] = opt_a + leg_b["sheets"] = int(sug["sheets_b"]) + leg_b["ask"] = float(qb["ask"]) + leg_b["ask_sz"] = qb.get("ask_sz") + leg_b["ct_mult"] = float(qb.get("ct_mult") or leg_b.get("ct_mult") or 0.01) + if opt_b: + leg_b["opt_type"] = opt_b + body["leg_a"] = leg_a + body["leg_b"] = leg_b + return { + "ok": True, + "buffer_ratio": buf, + "budget": budget_info, + "sizing": sug, + "quote_a": qa, + "quote_b": qb, + "prev_sheets_a": prev_a, + "prev_sheets_b": prev_b, + "sheets_a": int(sug["sheets_a"]), + "sheets_b": int(sug["sheets_b"]), + "ask_a": float(qa["ask"]), + "ask_b": float(qb["ask"]), + "premium_est": sug.get("premium_est"), + "msg": ( + f"已按最新卖一重算: A {sug['sheets_a']}张@{qa['ask']} + " + f"B {sug['sheets_b']}张@{qb['ask']} · 预估 {sug.get('premium_est')}U" + ), + } + + +def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]: + """永期启动前再拉保险腿卖一(张数沿用页面值,不按预算重算).""" + from lib.exchange.okx_options_lib import option_buy_liquidity_ok + + inst = str(body.get("opt_inst_id") or "").strip() + if not inst: + return {"ok": False, "msg": "缺少期权合约"} + quote_fn = cfg.get("quote_option_contract") + ex = cfg.get("exchange_options") + if not callable(quote_fn) or ex is None: + return {"ok": False, "msg": "期权报价能力未就绪"} + q = quote_fn(ex, inst) + if not q.get("ok"): + return {"ok": False, "msg": q.get("msg") or "期权报价失败", "quote": q} + can_open, block_msg = option_buy_liquidity_ok(q.get("ask"), q.get("ask_sz")) + if not can_open: + return { + "ok": False, + "msg": block_msg or "暂无卖一深度,无法买入", + "quote": q, + } + body["ask"] = float(q["ask"]) + body["ask_sz"] = q.get("ask_sz") + if q.get("ct_mult") is not None: + body["ct_mult"] = float(q.get("ct_mult") or 0.01) + return { + "ok": True, + "ask": float(q["ask"]), + "ask_sz": q.get("ask_sz"), + "sheets": body.get("sheets"), + "quote": q, + "msg": f"已按最新卖一: {body.get('sheets')}张@{q['ask']}", + } + + def execute_perp_options_start( cfg: dict[str, Any], body: dict[str, Any], @@ -357,6 +552,9 @@ def execute_perp_options_start( dry_run: bool = False, persist: Optional[Callable[..., Any]] = None, ) -> dict[str, Any]: + refresh = refresh_po_option_quote_before_start(cfg, body) + if not refresh.get("ok"): + return {"ok": False, "msg": refresh.get("msg") or "启动前刷新卖一失败", "refresh": refresh} path = build_po_path_plan(body) results: list[dict[str, Any]] = [] opt_res: Optional[dict[str, Any]] = None @@ -461,6 +659,7 @@ def execute_perp_options_start( "results": results, "option": opt_res, "perp": perp_res, + "refresh": refresh, "opened_at": _now(), } if persist and not dry_run: @@ -475,6 +674,9 @@ def execute_options_options_start( dry_run: bool = False, persist: Optional[Callable[..., Any]] = None, ) -> dict[str, Any]: + refresh = refresh_oo_sizing_before_start(cfg, body) + if not refresh.get("ok"): + return {"ok": False, "msg": refresh.get("msg") or "启动前刷新卖一/张数失败", "refresh": refresh} path = build_oo_path_plan(body) results: list[dict[str, Any]] = [] leg_a = body.get("leg_a") or {} @@ -482,7 +684,13 @@ def execute_options_options_start( 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) results.append({"step": "options_buy_limit", "leg": "a", **a_res}) if not a_res.get("ok"): - return {"ok": False, "msg": a_res.get("msg") or "腿A开仓失败", "path": path, "results": results} + return { + "ok": False, + "msg": a_res.get("msg") or "腿A开仓失败", + "path": path, + "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) results.append({"step": "options_buy_limit", "leg": "b", **b_res}) if not b_res.get("ok"): @@ -497,9 +705,10 @@ def execute_options_options_start( "path": path, "results": results, "partial": True, + "refresh": refresh, } if not dry_run and manual_complete_on_partial() and persist: - return _park_partial( + out_p = _park_partial( cfg, plan_type="options_options", body=body, @@ -512,6 +721,8 @@ def execute_options_options_start( leg_a=a_res, leg_b=None, ) + out_p["refresh"] = refresh + return out_p msg = b_res.get("msg") or "腿B开仓失败" if not dry_run: _notify_partial(cfg, "options_options", msg, results) @@ -521,6 +732,7 @@ def execute_options_options_start( "path": path, "results": results, "partial": True, + "refresh": refresh, } out = { "ok": True, @@ -530,6 +742,7 @@ def execute_options_options_start( "results": results, "leg_a": a_res, "leg_b": b_res, + "refresh": refresh, "opened_at": _now(), } if persist and not dry_run: diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index 2eaa27e..5f7b6eb 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -98,7 +98,10 @@ def _build_cfg(app_module: Any) -> dict[str, Any]: "perp_account_label": (os.getenv("OKX_ACCOUNT_LABEL") or "合约账户").strip(), "options_account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "期权账户").strip(), "trade_budget_usdc": float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC") or "10"), - "budget_buffer": float(os.getenv("OKX_OPTIONS_BUDGET_BUFFER") or "0.95"), + # 对冲专用缓冲;与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立 + "budget_buffer": float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"), + "oo_bias_split_by": _oo_bias_split_by(), + "oo_bias_ratio": _oo_bias_ratio(), "live_trading": _env_bool("LIVE_TRADING_ENABLED", False), "send_wechat": getattr(app_module, "send_wechat_msg", None), } diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index 84c61e2..c974940 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -301,4 +301,4 @@ - + diff --git a/tests/test_hedge_plan_orders.py b/tests/test_hedge_plan_orders.py index 0605ef7..34c94db 100644 --- a/tests/test_hedge_plan_orders.py +++ b/tests/test_hedge_plan_orders.py @@ -133,15 +133,15 @@ class TestHedgePlanOrderPath(unittest.TestCase): def test_dry_run_oo(self): quote = MagicMock( - return_value={ + side_effect=lambda _ex, inst_id: { "ok": True, "ask": 10, - "ask_sz": 5, + "ask_sz": 50, "can_open": True, "ct_mult": 0.01, "tick_sz": "0.1", "strike": 1800, - "meta": {"optType": "C"}, + "meta": {"optType": "C" if inst_id == "A" else "P"}, } ) cfg = { @@ -149,17 +149,27 @@ class TestHedgePlanOrderPath(unittest.TestCase): "quote_option_contract": quote, "place_option_limit_order": MagicMock(), "td_mode_for_option_buy": lambda x: "isolated", + "trade_budget_usdc": 10, + "budget_buffer": 0.95, } body = { "target_price": 1900, "target_price_up": 1950, "target_price_down": 1750, - "leg_a": {"inst_id": "A", "sheets": 1}, - "leg_b": {"inst_id": "B", "sheets": 1}, + "oo_sheets_mode": "same_sheets", + "leg_a": {"inst_id": "A", "sheets": 1, "opt_type": "C"}, + "leg_b": {"inst_id": "B", "sheets": 1, "opt_type": "P"}, } - out = execute_options_options_start(cfg, body, dry_run=True) - self.assertTrue(out["ok"]) + from unittest import mock + + with mock.patch( + "lib.exchange.okx_options_lib.fetch_options_trading_usdc", + return_value=100.0, + ): + out = execute_options_options_start(cfg, body, dry_run=True) + self.assertTrue(out["ok"], out) self.assertEqual(len(out["results"]), 2) + self.assertTrue(out.get("refresh", {}).get("ok")) def test_buy_rejects_without_ask_depth(self): from lib.hedge_plan.hedge_plan_orders_lib import _buy_option diff --git a/tests/test_hedge_start_refresh.py b/tests/test_hedge_start_refresh.py new file mode 100644 index 0000000..3e7ff65 --- /dev/null +++ b/tests/test_hedge_start_refresh.py @@ -0,0 +1,125 @@ +"""对冲启动:再拉卖一 + 对冲专用预算缓冲重算张数.""" +from __future__ import annotations + +import os +import unittest +from unittest import mock +from unittest.mock import MagicMock + +from lib.hedge_plan.hedge_plan_orders_lib import ( + _hedge_budget_buffer, + execute_options_options_start, + refresh_oo_sizing_before_start, + refresh_po_option_quote_before_start, +) + + +class TestHedgeStartRefresh(unittest.TestCase): + def test_hedge_budget_buffer_independent(self): + with mock.patch.dict( + os.environ, + { + "HEDGE_PLAN_BUDGET_BUFFER": "0.9", + "OKX_OPTIONS_BUDGET_BUFFER": "0.5", + }, + ): + self.assertAlmostEqual(_hedge_budget_buffer(None), 0.9) + self.assertAlmostEqual(_hedge_budget_buffer({"budget_buffer": 0.88}), 0.88) + + def test_refresh_oo_resizes_from_fresh_ask(self): + def quote(_ex, inst_id): + if inst_id == "A": + return { + "ok": True, + "ask": 20, + "ask_sz": 100, + "ct_mult": 0.01, + "meta": {"optType": "C"}, + } + return { + "ok": True, + "ask": 20, + "ask_sz": 100, + "ct_mult": 0.01, + "meta": {"optType": "P"}, + } + + cfg = { + "exchange_options": object(), + "quote_option_contract": quote, + "trade_budget_usdc": 10, + "budget_buffer": 0.95, + } + body = { + "oo_sheets_mode": "same_sheets", + "leg_a": {"inst_id": "A", "sheets": 1, "opt_type": "C"}, + "leg_b": {"inst_id": "B", "sheets": 1, "opt_type": "P"}, + } + # unit cost = 20*0.01=0.2 each → pair 0.4; budget min(100*0.95,10)=10 → n=25 + with mock.patch( + "lib.exchange.okx_options_lib.fetch_options_trading_usdc", + return_value=100.0, + ): + out = refresh_oo_sizing_before_start(cfg, body) + self.assertTrue(out.get("ok"), out) + self.assertEqual(body["leg_a"]["sheets"], 25) + self.assertEqual(body["leg_b"]["sheets"], 25) + self.assertEqual(body["leg_a"]["ask"], 20) + self.assertEqual(out["sheets_a"], 25) + + def test_execute_oo_start_uses_refreshed_sheets(self): + quote = MagicMock( + side_effect=lambda _ex, inst_id: { + "ok": True, + "ask": 10, + "ask_sz": 50, + "ct_mult": 0.01, + "tick_sz": "0.1", + "meta": {"optType": "C" if inst_id == "A" else "P"}, + } + ) + cfg = { + "exchange_options": object(), + "quote_option_contract": quote, + "place_option_limit_order": MagicMock(), + "td_mode_for_option_buy": lambda x: "isolated", + "trade_budget_usdc": 4, + "budget_buffer": 0.95, + } + body = { + "oo_sheets_mode": "same_sheets", + "leg_a": {"inst_id": "A", "sheets": 1, "opt_type": "C"}, + "leg_b": {"inst_id": "B", "sheets": 1, "opt_type": "P"}, + } + # cost 0.1+0.1=0.2; budget 4 → 20 sheets each + with mock.patch( + "lib.exchange.okx_options_lib.fetch_options_trading_usdc", + return_value=100.0, + ): + out = execute_options_options_start(cfg, body, dry_run=True) + self.assertTrue(out["ok"], out) + self.assertEqual(body["leg_a"]["sheets"], 20) + self.assertEqual(out["results"][0]["sheets"], 20) + self.assertIn("refresh", out) + self.assertTrue(out["refresh"]["ok"]) + + def test_refresh_po_keeps_sheets(self): + quote = MagicMock( + return_value={ + "ok": True, + "ask": 12.5, + "ask_sz": 8, + "ct_mult": 0.01, + "meta": {"optType": "P"}, + } + ) + cfg = {"exchange_options": object(), "quote_option_contract": quote} + body = {"opt_inst_id": "X", "sheets": 3} + out = refresh_po_option_quote_before_start(cfg, body) + self.assertTrue(out["ok"]) + self.assertEqual(body["ask"], 12.5) + self.assertEqual(body["sheets"], 3) + + +if __name__ == "__main__": + unittest.main()