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 (
'
' +
'
行权价: ' + 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