Add depth-based option close flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-11 08:46:07 +08:00
parent 02472f19cb
commit 6e45604d93
6 changed files with 396 additions and 20 deletions
+57 -8
View File
@@ -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 (
'<div class="pos-card-head">' +
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + '</strong>' +
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span></div>" +
'<div class="pos-head-actions">' +
'<button type="button" class="btn-primary opt-close-btn" data-inst="' + p.inst_id + '">限价平仓</button>' +
'<button type="button" class="btn-primary opt-close-btn" data-inst="' + p.inst_id + '" data-sheets="' + closeSheets + '">多档平仓</button>' +
"</div></div>" +
'<div class="pos-meta">' +
'<span class="pos-meta-item">行权价: ' + fmt(p.strike, 0) + "</span>" +
@@ -475,6 +506,8 @@
'<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + fmt(p.avg_px, 4) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + fmt(p.mark_px, 4) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">买盘深度</span><span class="pos-value">' + fmtBidDepth(p.bid_depth) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">按买盘收回</span><span class="pos-value ' + closePreviewCls + '">' + fmtClosePreview(closePreview) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">到期平衡</span><span class="pos-value">' + fmt(p.expiry_be_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">平掉回本</span><span class="pos-value">' + fmt(p.close_be_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">浮盈亏</span><span class="pos-value ' + uplCls + '">' + fmt(p.upl, 2) + "</span></div>" +
@@ -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 || "平仓失败");
}
+32
View File
@@ -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]:
+62
View File
@@ -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
+208 -11
View File
@@ -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"](
+1 -1
View File
@@ -200,4 +200,4 @@
</div>
</div>
<script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/options_panel.js?v=20"></script>
<script src="/static/options_panel.js?v=21"></script>
+36
View File
@@ -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