From 6e45604d93354c97f66368c9fb452b2d0d6898f5 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 11 Jul 2026 08:46:07 +0800 Subject: [PATCH] Add depth-based option close flow. Co-authored-by: Cursor --- lib/common/static/options_panel.js | 65 ++++++- lib/exchange/okx_options_lib.py | 32 ++++ lib/options/options_pricing_lib.py | 62 +++++++ lib/options/options_register.py | 219 +++++++++++++++++++++-- lib/options/templates/options_panel.html | 2 +- tests/test_options_pricing.py | 36 ++++ 6 files changed, 396 insertions(+), 20 deletions(-) diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index a4f264e..56aa1a3 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -185,6 +185,34 @@ return price + "/" + size; } + function fmtBidDepth(levels) { + const bids = (levels || []).slice(0, 5); + if (!bids.length) return "—"; + return bids.map(function (x, idx) { + return "买" + (idx + 1) + " " + fmtPxSz(x.px, x.sz); + }).join(" · "); + } + + function fmtClosePreview(preview) { + if (!preview || preview.total_received == null) return "—"; + let text = fmt(preview.total_received, 4) + " USDC"; + if (preview.covered_sheets != null) { + text += " · 覆盖 " + preview.covered_sheets + "张"; + } + if (preview.uncovered_sheets > 0) { + text += " · 缺 " + preview.uncovered_sheets + "张"; + } + return text; + } + + function fmtPreviewLevels(preview) { + const levels = (preview && preview.levels) || []; + if (!levels.length) return "暂无可用买盘深度"; + return levels.map(function (x) { + return "买" + x.level + " " + fmt(x.px, 4) + " × " + x.sheets + "张 ≈ " + fmt(x.received, 4) + " USDC"; + }).join("\n"); + } + function pnlCls(v) { if (v === null || v === undefined || Number.isNaN(Number(v))) return ""; const n = Number(v); @@ -456,12 +484,15 @@ const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long"; const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time; const expAttr = expMs != null && expMs !== "" ? String(expMs) : ""; + const closePreview = p.close_preview || {}; + const closePreviewCls = pnlCls(closePreview.estimated_pnl); + const closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos; return ( '
' + '
' + (p.inst_id || "") + '' + '' + optTypeLabel(p.opt_type) + "
" + '
' + - '' + + '' + "
" + '
' + '行权价: ' + fmt(p.strike, 0) + "" + @@ -475,6 +506,8 @@ '
开仓均价' + fmt(p.avg_px, 4) + "
" + '
标记价' + fmt(p.mark_px, 4) + "
" + '
指数价' + fmt(p.idx_px, 0) + "
" + + '
买盘深度' + fmtBidDepth(p.bid_depth) + "
" + + '
按买盘收回' + fmtClosePreview(closePreview) + "
" + '
到期平衡' + fmt(p.expiry_be_px, 0) + "
" + '
平掉回本' + fmt(p.close_be_px, 0) + "
" + '
浮盈亏' + fmt(p.upl, 2) + "
" + @@ -562,26 +595,42 @@ } async function closePosition(inst, btn) { - const q = await apiJson("/api/options/quote?inst_id=" + encodeURIComponent(inst) + "&mode=sheets&sheets=1"); + const sheets = btn && btn.getAttribute("data-sheets") ? parseInt(btn.getAttribute("data-sheets"), 10) : null; + let url = "/api/options/quote?inst_id=" + encodeURIComponent(inst) + "&mode=close_preview"; + if (sheets && sheets > 0) url += "&sheets=" + encodeURIComponent(sheets); + const q = await apiJson(url); if (!q.ok) { alert(q.msg || "获取买一价失败"); return; } - const bid = q.bid; - if (bid == null || bid <= 0) { - alert("暂无买一价,请稍后在 OKX App 平仓或等盘口恢复"); + const preview = q.close_preview || {}; + if (!preview.covered_sheets || preview.covered_sheets <= 0) { + alert("暂无可用买盘深度,请稍后在 OKX App 平仓或等盘口恢复"); return; } - if (!confirm("限价卖出 @ 买一 " + fmt(bid, 4) + "(每 1 ETH/BTC)?\n合约:" + inst)) return; + const msg = [ + "按最多5档买盘拆分限价卖出?", + "合约: " + inst, + "预计收回: " + fmtClosePreview(preview), + preview.estimated_pnl != null ? "预估盈亏: " + fmt(preview.estimated_pnl, 4) + " USDC" : "", + "", + fmtPreviewLevels(preview), + preview.uncovered_sheets > 0 ? "\n注意: 当前买盘不足,预计仍剩 " + preview.uncovered_sheets + " 张未覆盖。" : "" + ].filter(function (x) { return x !== ""; }).join("\n"); + if (!confirm(msg)) return; if (btn) btn.disabled = true; try { const r = await apiJson("/api/options/close", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ inst_id: inst }), + body: JSON.stringify({ inst_id: inst, mode: "depth_split", sheets: sheets }), }); if (r.ok) { - alert("平仓单已提交" + (r.bid != null ? " @ " + fmt(r.bid, 4) : "")); + let okMsg = "平仓单已提交 " + (r.submitted_sheets || 0) + " 张"; + if (r.premium_received != null) okMsg += "\n预估收回: " + fmt(r.premium_received, 4) + " USDC"; + if (r.remaining_sheets > 0) okMsg += "\n剩余: " + r.remaining_sheets + " 张"; + if (r.stopped_reason) okMsg += "\n停止原因: " + r.stopped_reason; + alert(okMsg); } else { alert(r.msg || "平仓失败"); } diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index e9284e1..6cc01b0 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -176,6 +176,38 @@ def _fetch_book_bid_ask(ex: ccxt.okx, inst_id: str) -> tuple[float | None, float return bid, ask +def _normalize_book_levels(rows: list[Any], depth: int) -> list[dict[str, float]]: + levels: list[dict[str, float]] = [] + for row in rows[: max(0, int(depth))]: + if not isinstance(row, (list, tuple)) or len(row) < 2: + continue + px = _safe_float(row[0]) + sz = _safe_float(row[1]) + if px is None or sz is None or px <= 0 or sz <= 0: + continue + levels.append({"px": px, "sz": sz}) + return levels + + +def fetch_option_book_depth(ex: ccxt.okx, inst_id: str, depth: int = 5) -> dict[str, list[dict[str, float]]]: + """获取期权盘口深度,sz 为 OKX 返回的张数口径.""" + inst_id = (inst_id or "").strip() + if not inst_id: + return {"bids": [], "asks": []} + try: + sz = str(max(1, min(int(depth), 10))) + rows = ex.public_get_market_books({"instId": inst_id, "sz": sz}).get("data") or [] + if not rows: + return {"bids": [], "asks": []} + row = rows[0] + return { + "bids": _normalize_book_levels(row.get("bids") or [], int(depth)), + "asks": _normalize_book_levels(row.get("asks") or [], int(depth)), + } + except Exception: + return {"bids": [], "asks": []} + + def _fetch_book_top( ex: ccxt.okx, inst_id: str ) -> tuple[float | None, float | None, float | None, float | None]: diff --git a/lib/options/options_pricing_lib.py b/lib/options/options_pricing_lib.py index 2130efd..b05a8ac 100644 --- a/lib/options/options_pricing_lib.py +++ b/lib/options/options_pricing_lib.py @@ -50,6 +50,68 @@ def total_premium(quote_per_unit: float, eth_amount: float, ct_mult: float = 0.0 return float(quote_per_unit) * float(eth_amount) +def estimate_close_by_bids( + bids: list[dict[str, Any]] | None, + sheets: int | float, + *, + ct_mult: float = 0.01, + premium_paid: float | None = None, +) -> dict[str, Any]: + """按买一到买N逐档估算限价卖出可收回金额.""" + target = max(0, int(float(sheets or 0))) + remaining = target + total_received = 0.0 + levels: list[dict[str, Any]] = [] + if target <= 0 or ct_mult <= 0: + return { + "levels": [], + "covered_sheets": 0, + "uncovered_sheets": target, + "total_received": 0.0, + "avg_px": None, + "estimated_pnl": None, + } + for i, level in enumerate(bids or [], start=1): + if remaining <= 0: + break + try: + px = float(level.get("px")) + sz = int(float(level.get("sz"))) + except (AttributeError, TypeError, ValueError): + continue + if px <= 0 or sz <= 0: + continue + take = min(remaining, sz) + eth_amount = eth_amount_from_sheets(take, ct_mult) + received = total_premium(px, eth_amount) + levels.append( + { + "level": i, + "px": px, + "available_sheets": sz, + "sheets": take, + "eth_amount": eth_amount, + "received": round(received, 4), + } + ) + total_received += received + remaining -= take + covered = target - remaining + avg_px = (total_received / eth_amount_from_sheets(covered, ct_mult)) if covered > 0 else None + estimated_pnl = None + if premium_paid is not None and covered > 0: + paid_basis = float(premium_paid) * (covered / target) + estimated_pnl = round(total_received - paid_basis, 4) + return { + "levels": levels, + "covered_sheets": covered, + "uncovered_sheets": remaining, + "total_received": round(total_received, 4), + "avg_px": round(avg_px, 4) if avg_px is not None else None, + "estimated_pnl": estimated_pnl, + } + + def sheets_from_eth_amount(eth_amount: float, ct_mult: float = 0.01) -> int: if eth_amount <= 0 or ct_mult <= 0: return 0 diff --git a/lib/options/options_register.py b/lib/options/options_register.py index c2cea17..8b19b29 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -14,6 +14,7 @@ from lib.options.options_monitor_lib import options_monitor_loop from lib.options.options_pricing_lib import ( calc_order_size, ct_mult_from_meta, + estimate_close_by_bids, min_sz_from_meta, premium_per_sheet, total_premium, @@ -76,6 +77,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]: build_option_chain, estimate_usdt_to_usdc, execute_convert, + fetch_option_book_depth, fetch_option_positions, fetch_options_balances, format_position_row, @@ -109,6 +111,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]: "account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "OKX期权").strip(), "build_option_chain": build_option_chain, "quote_option_contract": quote_option_contract, + "fetch_option_book_depth": fetch_option_book_depth, "place_option_limit_order": place_option_limit_order, "place_option_market_order": place_option_market_order, "fetch_option_positions": fetch_option_positions, @@ -143,6 +146,75 @@ def _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]: return float(raw), "" +def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None: + conn = cfg["get_db"]() + try: + init_options_tables(conn) + rec = conn.execute( + """ + SELECT premium_paid FROM options_trades + WHERE inst_id = ? AND status = 'open' + ORDER BY id DESC LIMIT 1 + """, + (inst_id,), + ).fetchone() + if rec and rec["premium_paid"] is not None: + return round(float(rec["premium_paid"]), 4) + finally: + conn.close() + return None + + +def _position_avail_sheets(pos: dict[str, Any]) -> int: + avail = _safe_float(pos.get("availPos")) + if avail is None or avail <= 0: + avail = abs(_safe_float(pos.get("pos")) or 0) + return max(0, int(avail or 0)) + + +def _find_position(rows: list[dict[str, Any]] | None, inst_id: str) -> dict[str, Any] | None: + return next((p for p in rows or [] if str(p.get("instId")) == inst_id), None) + + +def _refresh_position_avail(cfg: dict[str, Any], ex: Any, inst_id: str) -> int | None: + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return None + pos = _find_position(raw, inst_id) + if not pos: + return 0 + return _position_avail_sheets(pos) + + +def _attach_close_preview( + cfg: dict[str, Any], + ex: Any, + row: dict[str, Any], + *, + sheets: int | None = None, + premium_paid: float | None = None, +) -> dict[str, Any]: + inst_id = str(row.get("inst_id") or row.get("instId") or "").strip() + if not inst_id: + return row + ct_mult = float(row.get("ct_mult") or 0.01) + target_sheets = int(sheets) if sheets is not None else int(abs(_safe_float(row.get("pos")) or 0)) + paid = premium_paid if premium_paid is not None else _safe_float(row.get("premium_paid")) + book = cfg["fetch_option_book_depth"](ex, inst_id, 5) + row["bid_depth"] = book.get("bids") or [] + row["ask_depth"] = book.get("asks") or [] + row["close_preview"] = estimate_close_by_bids( + row["bid_depth"], + target_sheets, + ct_mult=ct_mult, + premium_paid=paid, + ) + return row + + _OPTIONS_SYNC_LOCK = threading.Lock() _OPTIONS_SYNC_LAST_AT = 0.0 _OPTIONS_SYNC_INTERVAL_SEC = 15.0 @@ -231,6 +303,16 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: ct_mult = q.get("ct_mult") or 0.01 min_sz = q.get("min_sz") or 1 mode = (request.args.get("mode") or "budget_full").strip() + sheet_count = None + try: + if request.args.get("sheets"): + sheet_count = int(request.args.get("sheets")) + except (TypeError, ValueError): + pass + if mode == "close_preview": + paid = _open_premium_paid(cfg, inst_id) + target = sheet_count if sheet_count is not None else 0 + return jsonify(_attach_close_preview(cfg, ex, {**q, "pos": target, "premium_paid": paid}, sheets=target, premium_paid=paid)) budget = cfg["trade_budget"] budget_cap = cfg["trade_budget"] available_usdc = None @@ -243,17 +325,11 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: available_usdc = fetch_options_trading_usdc(ex) eth_amount = None - sheet_count = None try: if request.args.get("eth_amount"): eth_amount = float(request.args.get("eth_amount")) except (TypeError, ValueError): pass - try: - if request.args.get("sheets"): - sheet_count = int(request.args.get("sheets")) - except (TypeError, ValueError): - pass if ask is None or ask <= 0: return jsonify({**q, "ok": False, "msg": "暂无卖一价"}) sizing = calc_order_size( @@ -266,6 +342,13 @@ 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, ) + q = _attach_close_preview( + cfg, + ex, + q, + sheets=int(sizing.get("sheets") or sheet_count or 0), + premium_paid=_open_premium_paid(cfg, inst_id), + ) return jsonify( { **q, @@ -403,6 +486,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: ).fetchone() if rec and rec["premium_paid"] is not None: row["premium_paid"] = round(float(rec["premium_paid"]), 4) + _attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid"))) finally: conn.close() return jsonify({"ok": True, "positions": rows}) @@ -416,23 +500,24 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: data = request.get_json(silent=True) or {} inst_id = (data.get("inst_id") or "").strip() use_market = bool(data.get("market")) and cfg["allow_market_close"] + close_mode = (data.get("mode") or "").strip() + depth_split = close_mode == "depth_split" and not use_market if not inst_id: return jsonify({"ok": False, "msg": "缺少 inst_id"}) sheets = data.get("sheets") q = cfg["quote_option_contract"](ex, inst_id) bid = q.get("bid") - if not use_market and (bid is None or bid <= 0): + if not use_market and not depth_split and (bid is None or bid <= 0): return jsonify({"ok": False, "msg": "暂无买一价,无法限价平仓"}) raw_positions = cfg["fetch_option_positions"](ex) if raw_positions is None: return jsonify({"ok": False, "msg": "获取期权持仓失败"}) - pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None) + pos = _find_position(raw_positions, inst_id) if not pos: return jsonify({"ok": False, "msg": "未找到持仓"}) - avail = _safe_float(pos.get("availPos")) - if avail is None or avail <= 0: - avail = abs(_safe_float(pos.get("pos")) or 0) + avail = _position_avail_sheets(pos) close_sheets = int(sheets) if sheets else int(avail) + close_sheets = min(close_sheets, int(avail)) if close_sheets < 1: return jsonify({"ok": False, "msg": "可平张数不足"}) td_mode = str(pos.get("mgnMode") or cfg["td_mode"]) @@ -450,6 +535,118 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: ) if not order.get("ok"): return jsonify(order) + elif depth_split: + ct_mult = float(q.get("ct_mult") or 0.01) + remaining = close_sheets + submitted_sheets = 0 + filled_or_reduced_sheets = 0 + total_received = 0.0 + orders: list[dict[str, Any]] = [] + stopped_reason = None + for _ in range(5): + if remaining <= 0: + break + current_avail = _refresh_position_avail(cfg, ex, inst_id) + if current_avail is None: + stopped_reason = "refresh_position_failed" + break + if current_avail <= 0: + filled_or_reduced_sheets = close_sheets + remaining = 0 + break + remaining = min(remaining, current_avail) + book = cfg["fetch_option_book_depth"](ex, inst_id, 5) + preview = estimate_close_by_bids(book.get("bids") or [], remaining, ct_mult=ct_mult) + levels = preview.get("levels") or [] + if not levels: + stopped_reason = "no_bid_depth" + break + level = levels[0] + level_sheets = int(level.get("sheets") or 0) + level_px = float(level.get("px") or 0) + if level_sheets <= 0 or level_px <= 0: + stopped_reason = "invalid_bid_depth" + break + before_avail = current_avail + order = cfg["place_option_limit_order"]( + ex, + inst_id=inst_id, + side="sell", + sheets=level_sheets, + price=level_px, + td_mode=td_mode, + tick_sz=tick_sz, + reduce_only=True, + pos_side=pos_side, + ) + if not order.get("ok"): + stopped_reason = order.get("msg") or "order_failed" + break + px = float(order.get("px", level_px)) + orders.append({"order": order, "px": px, "sheets": level_sheets}) + submitted_sheets += level_sheets + total_received += total_premium(px, level_sheets * ct_mult) + time.sleep(0.6) + after_avail = _refresh_position_avail(cfg, ex, inst_id) + if after_avail is None: + stopped_reason = "refresh_position_failed" + break + reduced = max(0, before_avail - after_avail) + if reduced <= 0: + stopped_reason = "order_not_filled" + break + filled_or_reduced_sheets += min(reduced, level_sheets) + remaining = max(0, close_sheets - filled_or_reduced_sheets) + if not orders: + return jsonify({"ok": False, "msg": "暂无可用买盘深度,无法拆分平仓", "stopped_reason": stopped_reason}) + bid = (total_received / (submitted_sheets * ct_mult)) if submitted_sheets > 0 and ct_mult > 0 else 0 + prem_recv = round(total_received, 4) + fully_submitted = submitted_sheets >= close_sheets and stopped_reason is None + conn = cfg["get_db"]() + try: + init_options_tables(conn) + row = conn.execute( + "SELECT id, premium_paid FROM options_trades WHERE inst_id = ? AND status = 'open' ORDER BY id DESC LIMIT 1", + (inst_id,), + ).fetchone() + if row and fully_submitted: + paid = float(row["premium_paid"] or 0) + pnl = prem_recv - paid + conn.execute( + """ + UPDATE options_trades + SET status = 'closed', close_quote = ?, premium_received = ?, + realized_pnl = ?, close_ord_id = ?, closed_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + ( + bid, + prem_recv, + pnl, + ",".join(str((o.get("order", {}).get("data") or {}).get("ordId") or "") for o in orders), + int(row["id"]), + ), + ) + conn.commit() + finally: + conn.close() + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + _sync_options_trades(cfg, force=True) + return jsonify( + { + "ok": True, + "mode": "depth_split", + "orders": orders, + "bid": bid, + "submitted_sheets": submitted_sheets, + "filled_or_reduced_sheets": filled_or_reduced_sheets, + "remaining_sheets": max(0, close_sheets - filled_or_reduced_sheets), + "premium_received": prem_recv, + "stopped_reason": stopped_reason, + } + ) else: close_px = float(bid) order = cfg["place_option_limit_order"]( diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 2fcbcc1..d9c311d 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -200,4 +200,4 @@
- + diff --git a/tests/test_options_pricing.py b/tests/test_options_pricing.py index 40b4868..34e4031 100644 --- a/tests/test_options_pricing.py +++ b/tests/test_options_pricing.py @@ -129,6 +129,42 @@ def test_format_quote_liquidity(): assert format_quote_liquidity(None, 10) is None +def test_estimate_close_by_bids_full_depth(): + from lib.options.options_pricing_lib import estimate_close_by_bids + + out = estimate_close_by_bids( + [{"px": 12.3, "sz": 2}, {"px": 12.1, "sz": 3}], + 4, + ct_mult=0.01, + premium_paid=0.4, + ) + assert out["covered_sheets"] == 4 + assert out["uncovered_sheets"] == 0 + assert out["total_received"] == 0.488 + assert out["avg_px"] == 12.2 + assert out["estimated_pnl"] == 0.088 + assert [x["sheets"] for x in out["levels"]] == [2, 2] + + +def test_estimate_close_by_bids_partial_depth(): + from lib.options.options_pricing_lib import estimate_close_by_bids + + out = estimate_close_by_bids([{"px": 10, "sz": 1}], 3, ct_mult=0.01, premium_paid=0.6) + assert out["covered_sheets"] == 1 + assert out["uncovered_sheets"] == 2 + assert out["total_received"] == 0.1 + assert out["estimated_pnl"] == -0.1 + + +def test_estimate_close_by_bids_empty(): + from lib.options.options_pricing_lib import estimate_close_by_bids + + out = estimate_close_by_bids([], 2) + assert out["covered_sheets"] == 0 + assert out["uncovered_sheets"] == 2 + assert out["avg_px"] is None + + def test_expiry_breakeven_from_ask(): from lib.options.options_pricing_lib import expiry_breakeven_from_ask