From 4686cf049a2ea6997a86958e962ccbe259e87fa6 Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 16 Jul 2026 12:48:53 +0800 Subject: [PATCH] Require real ask depth for options and hedge opens. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Block mark-as-ask opens, show reference mark when no depth, cap sheets to ask size; leave close paths unchanged. Document in docs/更新文档.md. Co-authored-by: Cursor --- crypto_monitor_okx/更新文档.md | 6 ++ docs/更新文档.md | 46 ++++++++++ lib/common/static/options_panel.js | 58 ++++++++++-- lib/exchange/okx_options_lib.py | 54 ++++++++++-- lib/hedge_plan/hedge_plan_orders_lib.py | 26 +++++- .../templates/hedge_plan_panel.html | 2 +- lib/options/options_register.py | 88 +++++++++++++++++-- lib/options/templates/options_panel.html | 5 +- tests/test_hedge_plan_orders.py | 53 +++++++++++ tests/test_option_buy_liquidity.py | 22 +++++ 10 files changed, 339 insertions(+), 21 deletions(-) create mode 100644 docs/更新文档.md create mode 100644 tests/test_option_buy_liquidity.py diff --git a/crypto_monitor_okx/更新文档.md b/crypto_monitor_okx/更新文档.md index c7dc14f..aaa80a8 100644 --- a/crypto_monitor_okx/更新文档.md +++ b/crypto_monitor_okx/更新文档.md @@ -90,3 +90,9 @@ - 详见 `.env.example` 中 OKX(`OKX_*`)与通用风控项. - 代码更新后请 **重启 OKX 监控进程**;旧库行不做批量回填,展示字段有则用之,无则回退. + +--- + +## 共享更新记录 + +自 2026-07-16 起,期权/对冲等共享逻辑的变更统一记在仓库根目录 **[docs/更新文档.md](../docs/更新文档.md)**(含原因、改动文件、目标、验收)。最新一条:期权/对冲开仓仅认真实卖一深度。 diff --git a/docs/更新文档.md b/docs/更新文档.md new file mode 100644 index 0000000..bc7f9ef --- /dev/null +++ b/docs/更新文档.md @@ -0,0 +1,46 @@ +# 更新文档(仓库级) + +自 2026-07-16 起:**凡修改或更新功能,必须在本文件追加一条记录**,写明原因、改动位置、目标与交付验收。实例目录下旧版说明可保留,但共享逻辑(`lib/`)以本文为准。 + +--- + +## 2026-07-16 · 期权/对冲开仓仅认真实卖一深度 + +### 修改原因 + +此前报价在无盘口卖一时会用**标记价顶进 `ask`**,界面仍显示「限价买入 @ 卖一」,造成误以为在吃卖一;深度实值合约还容易「链上有 `~` 价、点选却失败或按估算价下单」。需要与产品规则对齐:**开仓只吃真实卖一,且必须有卖一量**。 + +### 修改的地方(明确清单) + +| 文件 | 改动摘要 | +|------|----------| +| `lib/exchange/okx_options_lib.py` | 新增 `option_buy_liquidity_ok` / `cap_option_buy_sheets_to_ask_depth`;`quote_option_contract` **不再**用 mark 填充开仓 `ask`;返回 `can_open` / `ref_ask` / `open_block_msg` / `ask_source` | +| `lib/options/options_register.py` | `/api/options/quote` 开仓 sizing 仅在 `can_open` 时计算,张数 cap 到卖一深度;`/api/options/open` 服务端再验深度并 cap 张数 | +| `lib/common/static/options_panel.js` | 面板展示参考标记价;无深度禁用开仓按钮与说明文案;开仓前校验 `can_open` | +| `lib/options/templates/options_panel.html` | 提示文案;增加「参考标记价」字段;脚本 `?v=37` | +| `lib/hedge_plan/hedge_plan_orders_lib.py` | **仅** `_buy_option`(买入开仓)同步深度门禁与张数 cap;**未改** `_sell_option` 平仓 | +| `lib/hedge_plan/templates/hedge_plan_panel.html` | 单位说明补充开仓规则 | +| `tests/test_option_buy_liquidity.py` | 新增门禁/深度 cap 单测 | +| `tests/test_hedge_plan_orders.py` | mock 补 `ask_sz`;无深度拒绝 / 深度 cap 用例 | + +**铁律:未改动任何平仓规则**(期权买一平仓、`_sell_option`、close_preview / close 执行路径逻辑保持原样;报价里买一仍可用 mark 补展示,仅服务平仓读 bid)。 + +### 达成的目标 + +1. 开仓条件:`askPx` 有效 **且** `askSz > 0`。 +2. 无卖一/无深度:可展示参考标记价 `ref_ask`/`mark`,明确「不可用于开仓」,按钮禁用。 +3. 有深度时:限价买 @ 真实卖一;张数不超过卖一深度(向下取整)。 +4. 期权面板与对冲计划买入路径规则一致。 + +### 交付之后的验收 + +1. **有卖一深度**:选合约 → 卖一显示 `价/量` → 按钮「限价买入 @ 卖一」可点 → 下单张数 ≤ 卖一量。 +2. **无卖一或深度为 0**:卖一为 `—`;参考标记价显示 `~xx (不可开仓)`;红色说明含「仅供参考,不可用于开仓」;按钮为「暂无卖一深度,无法开仓」且不可点;直接调 open API 应返回失败文案。 +3. **链上 `~` 估算**:仍可浏览;点选后若无真实深度,不得用估算价成交。 +4. **平仓**:持仓「买一平仓」行为与改前一致(抽测一条即可)。 +5. **对冲计划**:执行买入腿时无深度应失败并提示;有深度 dry_run/实盘张数不超过卖一量。 +6. 单测:`python -m pytest tests/test_option_buy_liquidity.py tests/test_hedge_plan_orders.py -q` 通过。 + +### 未纳入本次(另单) + +硬刷新链可能毁掉下单面板、限频 fallback tick、CSS `?v=` 缓存等,见会话审计清单,不在本条范围。 diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index ff46fd2..2a0dd53 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -701,7 +701,7 @@ const targetLevEl = document.getElementById("opt-est-leverage"); const targetEl = document.getElementById("opt-target-idx"); const q = state.orderQuote; - if (!q || !q.ok) { + if (!q || !q.ok || !q.can_open) { if (levEl) levEl.textContent = "—"; if (valueEl) valueEl.textContent = "—"; if (profitEl) { @@ -905,14 +905,31 @@ function fillOrderPanel(d) { state.orderQuote = d && d.ok ? d : null; const sz = d.sizing || {}; + const canOpen = !!(d && d.ok && d.can_open); document.getElementById("opt-order-inst").textContent = d.inst_id || state.selectedInst || ""; - document.getElementById("opt-order-ask").textContent = fmtPxSz(d.ask, d.ask_sz); + const askEl = document.getElementById("opt-order-ask"); + if (askEl) { + askEl.textContent = canOpen ? fmtPxSz(d.ask, d.ask_sz) : "—"; + } const bidEl = document.getElementById("opt-order-bid"); if (bidEl) bidEl.textContent = fmtPxSz(d.bid, d.bid_sz); - document.getElementById("opt-order-sheets").textContent = sz.sheets != null ? sz.sheets : "—"; - document.getElementById("opt-order-eth").textContent = sz.eth_amount != null ? sz.eth_amount : "—"; + const refEl = document.getElementById("opt-order-ref-ask"); + if (refEl) { + if (canOpen) { + refEl.textContent = "—"; + } else if (d.ref_ask != null && !Number.isNaN(Number(d.ref_ask))) { + refEl.textContent = fmtPxSz(d.ref_ask, null, true) + " (不可开仓)"; + } else if (d.mark != null && !Number.isNaN(Number(d.mark))) { + refEl.textContent = fmtPxSz(d.mark, null, true) + " (不可开仓)"; + } else { + refEl.textContent = "—"; + } + } + document.getElementById("opt-order-sheets").textContent = canOpen && sz.sheets != null ? sz.sheets : "—"; + document.getElementById("opt-order-eth").textContent = canOpen && sz.eth_amount != null ? sz.eth_amount : "—"; updateUnderlyingLabel(); - document.getElementById("opt-order-premium").textContent = sz.total_premium != null ? fmtUsdc(sz.total_premium) + " USDC" : "—"; + document.getElementById("opt-order-premium").textContent = + canOpen && sz.total_premium != null ? fmtUsdc(sz.total_premium) + " USDC" : "—"; const beEl = document.getElementById("opt-order-expiry-be"); const distEl = document.getElementById("opt-order-dist-be"); if (beEl) { @@ -922,13 +939,31 @@ distEl.textContent = fmtDist(d.dist_expiry_be); distEl.className = "v " + distBeClass(d.dist_expiry_be); } + const openBtn = document.getElementById("opt-open-btn"); + if (openBtn) { + openBtn.disabled = !canOpen || sz.ok === false; + openBtn.textContent = canOpen ? "限价买入 @ 卖一" : "暂无卖一深度,无法开仓"; + } const msgEl = document.getElementById("opt-order-msg"); if (!d.ok) { msgEl.textContent = d.msg || "报价失败"; msgEl.classList.add("opt-error"); + } else if (!canOpen) { + const ref = d.ref_ask != null ? d.ref_ask : d.mark; + let tip = d.msg || d.open_block_msg || "当前无卖一深度,无法按卖一限价买入"; + if (ref != null && !Number.isNaN(Number(ref))) { + tip += "。参考标记价 ~" + Number(ref).toFixed(4).replace(/\.?0+$/, "") + "(仅供参考,不可用于开仓)"; + } else { + tip += "。无可用参考标记价"; + } + msgEl.textContent = tip; + msgEl.classList.add("opt-error"); } else if (sz.ok === false) { msgEl.textContent = sz.msg || ""; msgEl.classList.add("opt-error"); + } else if (sz.ask_depth_capped) { + msgEl.textContent = sz.msg || "已按卖一深度限制张数"; + msgEl.classList.remove("opt-error"); } else { msgEl.textContent = ""; msgEl.classList.remove("opt-error"); @@ -1051,6 +1086,15 @@ alert("请先选择合约"); return; } + const q = state.orderQuote; + if (!q || !q.ok || !q.can_open) { + alert((q && (q.msg || q.open_block_msg)) || "暂无卖一深度,无法按卖一开仓"); + return; + } + if (q.sizing && q.sizing.ok === false) { + alert(q.sizing.msg || "张数无效"); + return; + } const btn = document.getElementById("opt-open-btn"); btn.disabled = true; try { @@ -1091,7 +1135,9 @@ alert(d.msg || "下单失败"); } } finally { - btn.disabled = false; + const latest = state.orderQuote; + btn.disabled = !(latest && latest.ok && latest.can_open && !(latest.sizing && latest.sizing.ok === false)); + btn.textContent = (latest && latest.can_open) ? "限价买入 @ 卖一" : "暂无卖一深度,无法开仓"; } } diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 68f10a7..7f58ec8 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -779,6 +779,38 @@ def build_option_chain( return out +def option_buy_liquidity_ok(ask: Any, ask_sz: Any) -> tuple[bool, str]: + """开仓仅认真实卖一价+卖一深度;不接受标记价/内在价值顶包.""" + a = _safe_float(ask) + s = _safe_float(ask_sz) + if a is None or a <= 0: + return False, "暂无卖一价,无法买入" + if s is None or s <= 0: + return False, "暂无卖一深度,无法买入" + return True, "" + + +def cap_option_buy_sheets_to_ask_depth( + sheets: int, + ask_sz: Any, + *, + min_sz: int = 1, +) -> tuple[int | None, str]: + """将买入张数限制在卖一深度内(向下取整).""" + depth = _safe_float(ask_sz) + if depth is None or depth <= 0: + return None, "暂无卖一深度,无法买入" + max_sheets = int(math.floor(depth + 1e-12)) + need = max(1, int(min_sz or 1)) + if max_sheets < need: + return None, f"卖一深度不足 {need} 张(当前 {depth:g})" + want = max(0, int(sheets)) + capped = min(want, max_sheets) + if capped < need: + return None, f"卖一深度不足 {need} 张(当前 {depth:g})" + return capped, "" + + def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]: inst_id = (inst_id or "").strip() if not inst_id: @@ -805,6 +837,7 @@ def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]: return {"ok": False, "msg": "行情限频,请稍后重试"} return {"ok": False, "msg": "合约不存在"} t = t_rows[0] if t_rows else {} + # 开仓用真实盘口卖一;绝不把标记价写入 ask ask = _safe_float(t.get("askPx")) bid = _safe_float(t.get("bidPx")) ask_sz = _safe_float(t.get("askSz")) @@ -821,10 +854,15 @@ def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]: bid_sz = book_bid_sz mark = _safe_float(t.get("markPx")) tick_sz = meta.get("tickSz") - if ask is None and mark is not None: - ask = round_option_px(mark, tick_sz, "buy") + # 买一缺失时仍可用标记价补展示(平仓路径读 bid);开仓 ask 不顶包 if bid is None and mark is not None: bid = round_option_px(mark, tick_sz, "sell") + ref_ask = None + if ask is None and mark is not None and mark > 0: + ref_ask = round_option_px(mark, tick_sz, "buy") + can_open, open_block_msg = option_buy_liquidity_ok(ask, ask_sz) + book_ask = ask + book_ask_sz = ask_sz uly = str(meta.get("uly") or "") idx = fetch_index_price(ex, uly) opt_type = meta.get("optType") @@ -832,18 +870,24 @@ def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]: expiry_be = expiry_breakeven_from_ask( opt_type=str(opt_type or ""), strike=strike, - ask_px=ask, + ask_px=book_ask if can_open else None, mark_px=mark, ) return { "ok": True, "inst_id": inst_id, "meta": meta, - "ask": ask, + "ask": book_ask if can_open else None, "bid": bid, - "ask_sz": ask_sz, + "ask_sz": book_ask_sz if can_open else None, "bid_sz": bid_sz, "mark": mark, + "ref_ask": ref_ask, + "book_ask": book_ask, + "book_ask_sz": book_ask_sz, + "can_open": can_open, + "ask_source": "book" if can_open else "none", + "open_block_msg": "" if can_open else open_block_msg, "index_px": idx, "expiry_be_px": expiry_be, "dist_expiry_be": idx_distance_to_be(idx, expiry_be), diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py index e4635db..ba9f5c5 100644 --- a/lib/hedge_plan/hedge_plan_orders_lib.py +++ b/lib/hedge_plan/hedge_plan_orders_lib.py @@ -77,6 +77,11 @@ def _buy_option( sheets: float, dry_run: bool, ) -> dict[str, Any]: + from lib.exchange.okx_options_lib import ( + cap_option_buy_sheets_to_ask_depth, + option_buy_liquidity_ok, + ) + ex = cfg.get("exchange_options") quote_fn = cfg.get("quote_option_contract") place_fn = cfg.get("place_option_limit_order") @@ -89,9 +94,22 @@ def _buy_option( if not q.get("ok"): return {"ok": False, "msg": q.get("msg") or "期权报价失败", "quote": q} ask = q.get("ask") - if ask is None or float(ask) <= 0: - return {"ok": False, "msg": "暂无卖一价,无法买入"} + ask_sz = q.get("ask_sz") + can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) + if not can_open: + return { + "ok": False, + "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", + "quote": q, + "mark": q.get("mark"), + "ref_ask": q.get("ref_ask"), + "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: + return {"ok": False, "msg": cap_msg or "卖一深度不足,无法买入", "quote": q} + sheets_i = capped ct_mult = float(q.get("ct_mult") or 0.01) premium = float(ask) * sheets_i * ct_mult if dry_run: @@ -101,6 +119,7 @@ def _buy_option( "inst_id": inst_id, "sheets": sheets_i, "ask": float(ask), + "ask_sz": float(ask_sz), "premium": premium, "ct_mult": ct_mult, "tick_sz": q.get("tick_sz"), @@ -108,6 +127,7 @@ def _buy_option( "strike": q.get("strike"), "exp_time": q.get("exp_time"), "opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"), + "can_open": True, } if not callable(place_fn): return {"ok": False, "msg": "期权限价下单未注入"} @@ -130,6 +150,7 @@ def _buy_option( "inst_id": inst_id, "sheets": sheets_i, "ask": float(ask), + "ask_sz": float(ask_sz), "premium": premium, "ct_mult": ct_mult, "tick_sz": q.get("tick_sz"), @@ -139,6 +160,7 @@ def _buy_option( "opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"), "exchange_ord_id": (order.get("data") or {}).get("ordId"), "order": order, + "can_open": True, } diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index 5e53529..a97ff5b 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -82,7 +82,7 @@ -

单位说明:权利金结算币=USDC · 张数=期权张(整张) · 卖一/买一=价格/张

+

单位说明:权利金结算币=USDC · 张数=期权张(整张) · 卖一/买一=价格/张.期权买入仅认真实卖一价且卖一深度>0;无深度不可开仓(链上~为参考估算).

diff --git a/lib/options/options_register.py b/lib/options/options_register.py index fa5941e..00ea89c 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -17,7 +17,12 @@ from lib.options.options_pricing_lib import ( min_sz_from_meta, premium_per_sheet, ) -from lib.exchange.okx_options_lib import _safe_float, td_mode_for_option_buy +from lib.exchange.okx_options_lib import ( + _safe_float, + cap_option_buy_sheets_to_ask_depth, + option_buy_liquidity_ok, + td_mode_for_option_buy, +) def _env_bool(key: str, default: bool = False) -> bool: @@ -419,8 +424,30 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: eth_amount = float(request.args.get("eth_amount")) except (TypeError, ValueError): pass - if ask is None or ask <= 0: - return jsonify({**q, "ok": False, "msg": "暂无卖一价"}) + ask = q.get("ask") + ask_sz = q.get("ask_sz") + can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) + if not can_open: + # 合约可报价,但不可开仓:返回参考标记价供展示 + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", + "quote_per_unit": None, + "premium_per_sheet": None, + "sizing": { + "ok": False, + "msg": block_msg or "暂无卖一深度,无法买入", + "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), @@ -431,6 +458,32 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: sheets=sheet_count if mode == "sheets" else None, budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None, ) + if sizing.get("ok"): + capped, cap_msg = cap_option_buy_sheets_to_ask_depth( + int(sizing.get("sheets") or 0), + ask_sz, + min_sz=int(min_sz), + ) + if capped is None: + sizing = { + "ok": False, + "msg": cap_msg, + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + } + elif capped < int(sizing.get("sheets") or 0): + sizing = calc_order_size( + quote_per_unit=float(ask), + ct_mult=float(ct_mult), + min_sz=int(min_sz), + sheets=capped, + budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None, + ) + if sizing.get("ok"): + sizing["ask_depth_capped"] = True + sizing["ask_sz"] = ask_sz + sizing["msg"] = f"已按卖一深度限制为 {capped} 张" q = _attach_close_preview( cfg, ex, @@ -441,6 +494,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: return jsonify( { **q, + "can_open": True, "quote_per_unit": ask, "premium_per_sheet": premium_per_sheet(float(ask), float(ct_mult)), "sizing": sizing, @@ -474,8 +528,18 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: if not q.get("ok"): return jsonify(q) ask = q.get("ask") - if ask is None or ask <= 0: - return jsonify({"ok": False, "msg": "暂无卖一价,无法买入"}) + ask_sz = q.get("ask_sz") + can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) + if not can_open: + return jsonify( + { + "ok": False, + "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", + "can_open": False, + "mark": q.get("mark"), + "ref_ask": q.get("ref_ask"), + } + ) ct_mult = float(q.get("ct_mult") or 0.01) min_sz = int(q.get("min_sz") or 1) eth_amount = None @@ -510,6 +574,20 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: if not sizing.get("ok"): return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing}) sheets = int(sizing["sheets"]) + capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=min_sz) + if capped is None: + return jsonify({"ok": False, "msg": cap_msg or "卖一深度不足,无法买入"}) + if capped < sheets: + sizing = calc_order_size( + quote_per_unit=float(ask), + ct_mult=ct_mult, + min_sz=min_sz, + sheets=capped, + budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None, + ) + if not sizing.get("ok"): + return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing}) + sheets = int(sizing["sheets"]) tick_sz = q.get("tick_sz") order = cfg["place_option_limit_order"]( ex, diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 18de9d2..6d1a5a6 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -7,7 +7,7 @@

期权下单 开平仓与监控说明

-

报价单位为每 1 ETH/BTC;1 张 = 0.01.列表含卖一/买一;T 型仅卖一(买方开仓),中间为跨式双买测算.卖一无挂单时以标记价估算并标 ~.链展示近 14 日到期.T 型默认 ATM ±5 档,可展开全部.平仓仅买一限价,见说明.

+

报价单位为每 1 ETH/BTC;1 张 = 0.01.列表含卖一/买一;T 型仅卖一(买方开仓),中间为跨式双买测算.链上无卖一挂单时以标记价/内在价值估算并标 ~(仅参考).开仓只认真实卖一价且卖一深度>0;无深度时面板显示参考标记价并禁用买入.链展示近 14 日到期.T 型默认 ATM ±5 档,可展开全部.平仓仅买一限价,见说明.

@@ -73,6 +73,7 @@
卖一/张
买一/张
+
参考标记价
张数
ETH 数量
预估权利金
@@ -273,4 +274,4 @@
- + diff --git a/tests/test_hedge_plan_orders.py b/tests/test_hedge_plan_orders.py index d0f2afd..0605ef7 100644 --- a/tests/test_hedge_plan_orders.py +++ b/tests/test_hedge_plan_orders.py @@ -89,6 +89,8 @@ class TestHedgePlanOrderPath(unittest.TestCase): return_value={ "ok": True, "ask": 12.5, + "ask_sz": 10, + "can_open": True, "ct_mult": 0.01, "tick_sz": "0.1", "strike": 1800, @@ -134,6 +136,8 @@ class TestHedgePlanOrderPath(unittest.TestCase): return_value={ "ok": True, "ask": 10, + "ask_sz": 5, + "can_open": True, "ct_mult": 0.01, "tick_sz": "0.1", "strike": 1800, @@ -157,6 +161,55 @@ class TestHedgePlanOrderPath(unittest.TestCase): self.assertTrue(out["ok"]) self.assertEqual(len(out["results"]), 2) + def test_buy_rejects_without_ask_depth(self): + from lib.hedge_plan.hedge_plan_orders_lib import _buy_option + + quote = MagicMock( + return_value={ + "ok": True, + "ask": None, + "ask_sz": None, + "mark": 11.2, + "ref_ask": 11.2, + "can_open": False, + "open_block_msg": "暂无卖一深度,无法买入", + "ct_mult": 0.01, + } + ) + cfg = { + "exchange_options": object(), + "quote_option_contract": quote, + "place_option_limit_order": MagicMock(), + } + out = _buy_option(cfg, inst_id="ETH-USD_UM-260717-1900-C", sheets=1, dry_run=True) + self.assertFalse(out["ok"]) + self.assertIn("卖一", out["msg"]) + cfg["place_option_limit_order"].assert_not_called() + + def test_buy_caps_sheets_to_ask_depth(self): + from lib.hedge_plan.hedge_plan_orders_lib import _buy_option + + quote = MagicMock( + return_value={ + "ok": True, + "ask": 10, + "ask_sz": 2, + "can_open": True, + "ct_mult": 0.01, + "tick_sz": "0.1", + "meta": {"optType": "C"}, + } + ) + cfg = { + "exchange_options": object(), + "quote_option_contract": quote, + "place_option_limit_order": MagicMock(), + "td_mode_for_option_buy": lambda x: "isolated", + } + out = _buy_option(cfg, inst_id="X", sheets=9, dry_run=True) + self.assertTrue(out["ok"]) + self.assertEqual(out["sheets"], 2) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_option_buy_liquidity.py b/tests/test_option_buy_liquidity.py new file mode 100644 index 0000000..cc10c24 --- /dev/null +++ b/tests/test_option_buy_liquidity.py @@ -0,0 +1,22 @@ +"""期权买入流动性门禁:真实卖一价+深度.""" +from lib.exchange.okx_options_lib import ( + cap_option_buy_sheets_to_ask_depth, + option_buy_liquidity_ok, +) + + +def test_option_buy_liquidity_ok_requires_ask_and_depth(): + assert option_buy_liquidity_ok(10, 1)[0] is True + assert option_buy_liquidity_ok(10, 0)[0] is False + assert option_buy_liquidity_ok(10, None)[0] is False + assert option_buy_liquidity_ok(None, 5)[0] is False + assert option_buy_liquidity_ok(0, 5)[0] is False + + +def test_cap_option_buy_sheets_to_ask_depth(): + capped, msg = cap_option_buy_sheets_to_ask_depth(9, 2.8, min_sz=1) + assert capped == 2 + assert msg == "" + capped, msg = cap_option_buy_sheets_to_ask_depth(1, 0.4, min_sz=1) + assert capped is None + assert "深度不足" in msg