Use bid1-only option closes with hard-disabled market exits.

Manual close checks liquidity only; target auto still requires 2x recycle hold once, then reuses the shared bid1 executor. Add /options/guide doc and update hedge-plan refs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-15 21:49:06 +08:00
parent ab8b2a74e2
commit 1909eea654
17 changed files with 2136 additions and 2210 deletions
+18 -24
View File
@@ -490,12 +490,13 @@
function closeGateHint(preview) {
if (!preview) return "";
if (preview.bid_invalid) {
return preview.bid_invalid_reason || "当前买一无效,禁止按买盘自动平仓";
if (preview.bid_invalid || preview.manual_close_blocked) {
return preview.bid_invalid_reason || "当前买一无效,禁止买一平仓";
}
const gate = preview.close_gate || {};
if (preview.close_gate_blocked || gate.ready === false) {
return preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟后才可平仓";
// 2×门控仅约束目标位自动平;手动买一平仓不拦截,作状态提示
if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) {
return "自动平仓: " + (preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟");
}
return "";
}
@@ -981,7 +982,7 @@
'<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 + '" data-sheets="' + closeSheets + '">多档平仓</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>" +
@@ -1304,30 +1305,22 @@
return;
}
const preview = q.close_preview || {};
if (preview.bid_invalid) {
alert(preview.bid_invalid_reason || "当前买一为无效残档,禁止按买盘自动平仓。请到 OKX App 自行挂限价/市价。");
return;
}
if (preview.close_gate_blocked || (preview.close_gate && preview.close_gate.ready === false)) {
alert(
preview.close_gate_msg ||
(preview.close_gate && preview.close_gate.msg) ||
"可回收需≥2×权利金,并持续满2分钟后才可平仓"
);
if (preview.bid_invalid || preview.manual_close_blocked) {
alert(preview.bid_invalid_reason || "当前买一为无效残档,禁止买一平仓。");
return;
}
if (!preview.covered_sheets || preview.covered_sheets <= 0) {
alert("暂无可用买盘深度,请稍后 OKX App 平仓或等盘口恢复");
alert("暂无有效买一深度,请稍后重试或到 OKX App 挂限价");
return;
}
const lv = (preview.levels && preview.levels[0]) || {};
const msg = [
"按最多5档买盘拆分限价卖出?",
"按买一限价卖出本轮可平张数?",
"合约: " + inst,
"锁定买一: " + (lv.px != null ? lv.px : "—") + " × " + (lv.sheets != null ? lv.sheets : preview.covered_sheets) + " 张",
"预计收回: " + fmtClosePreviewText(preview),
preview.estimated_pnl != null ? "预估盈亏: " + fmt(preview.estimated_pnl, 4) + " USDC" : "",
"",
fmtPreviewLevels(preview),
preview.uncovered_sheets > 0 ? "\n注意: 当前买盘不足,预计仍剩 " + preview.uncovered_sheets + " 张未覆盖。" : ""
preview.uncovered_sheets > 0 ? "\n注意: 买一深度不足,预计仍剩 " + preview.uncovered_sheets + " 张,需下次再平。" : ""
].filter(function (x) { return x !== ""; }).join("\n");
if (!confirm(msg)) return;
if (btn) btn.disabled = true;
@@ -1335,13 +1328,14 @@
const r = await apiJson("/api/options/close", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ inst_id: inst, mode: "depth_split", sheets: sheets }),
body: JSON.stringify({ inst_id: inst, mode: "bid1", sheets: sheets }),
});
if (r.ok) {
let okMsg = "平仓已提交 " + (r.submitted_sheets || 0) + " 张";
let okMsg = "买一平仓已提交 " + (r.submitted_sheets || 0) + " 张";
if (r.locked_bid_px != null) okMsg += "\n锁定买一: " + r.locked_bid_px;
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;
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 || "平仓失败");
+5 -5
View File
@@ -66,12 +66,12 @@
function closeGateHint(preview) {
if (!preview) return "";
if (preview.bid_invalid) {
return preview.bid_invalid_reason || "当前买一无效,禁止按买盘自动平仓";
if (preview.bid_invalid || preview.manual_close_blocked) {
return preview.bid_invalid_reason || "当前买一无效,禁止买一平仓";
}
const gate = preview.close_gate || {};
if (preview.close_gate_blocked || gate.ready === false) {
return preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟后才可平仓";
if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) {
return "自动平仓: " + (preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟");
}
return "";
}
@@ -138,7 +138,7 @@
const closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos;
headActions =
'<div class="pos-head-actions">' +
'<button type="button" class="btn-primary opt-close-btn" data-inst="' + (p.inst_id || "") + '" data-sheets="' + closeSheets + '">多档平仓</button>' +
'<button type="button" class="btn-primary opt-close-btn" data-inst="' + (p.inst_id || "") + '" data-sheets="' + closeSheets + '">买一平仓</button>' +
"</div>";
}
return (
@@ -13,7 +13,9 @@
<div class="card hp-head-card">
<div class="hp-head-row">
<h2 class="hp-title">对冲计划 <span class="muted hp-title-sub">测算 · 下单</span></h2>
<h2 class="hp-title">对冲计划 <span class="muted hp-title-sub">测算 · 下单</span>
<a class="muted" href="/options/guide" target="_blank" rel="noopener" style="font-size:13px;font-weight:500;margin-left:8px">期权开平仓与监控说明</a>
</h2>
<button type="button" class="btn-secondary" id="hp-refresh" title="刷新永续行情与期权链">刷新行情</button>
</div>
<div class="hp-tabs" role="tablist" aria-label="对冲计划分类">
+371
View File
@@ -0,0 +1,371 @@
"""期权平仓执行:只锁买一限价卖出;永不市价."""
from __future__ import annotations
import time
from typing import Any
from lib.options.options_close_gate_lib import (
clear_close_gate,
is_close_gate_passed,
mark_close_gate_passed,
update_close_gate,
)
from lib.options.options_pricing_lib import (
estimate_close_by_bids,
fetch_option_mark_px,
is_stub_bid_px,
total_premium,
)
def _safe_float(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None:
try:
conn = cfg["get_db"]()
try:
from lib.options.options_db import init_options_tables
init_options_tables(conn)
row = conn.execute(
"SELECT premium_paid FROM options_trades WHERE inst_id = ? AND status = 'open' ORDER BY id DESC LIMIT 1",
(inst_id,),
).fetchone()
if row and row["premium_paid"] is not None:
return float(row["premium_paid"])
finally:
conn.close()
except Exception:
pass
return None
def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]:
from lib.exchange.okx_options_lib import option_fields_from_inst_id
from lib.options.options_pricing_lib import close_ref_prices
inst_id = str(pos.get("instId") or pos.get("inst_id") or "")
mark = _safe_float(pos.get("markPx")) or _safe_float((quote or {}).get("mark_px") or (quote or {}).get("mark"))
if mark is None:
mark = fetch_option_mark_px(ex, inst_id)
opt_type = pos.get("optType") or (quote or {}).get("opt_type")
strike = _safe_float(pos.get("stk")) or _safe_float((quote or {}).get("strike"))
if not opt_type or strike is None:
pt, ps = option_fields_from_inst_id(inst_id)
opt_type = opt_type or pt
if strike is None:
strike = ps
idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px"))
return close_ref_prices(mark_px=mark, opt_type=str(opt_type or ""), strike=strike, index_px=idx)
def _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 _cancel_sell_pending(ex: Any, inst_id: str) -> None:
try:
pending = ex.private_get_trade_orders_pending({"instType": "OPTION", "instId": inst_id}) or {}
for o in pending.get("data") or []:
if str(o.get("side") or "").lower() != "sell":
continue
oid = o.get("ordId")
if not oid:
continue
try:
ex.private_post_trade_cancel_order({"instId": inst_id, "ordId": oid})
except Exception:
pass
except Exception:
pass
def close_option_by_bid1(
cfg: dict[str, Any],
ex: Any,
inst_id: str,
*,
sheets: int | None = None,
require_recycle_gate: bool = False,
signal_note: str | None = None,
) -> dict[str, Any]:
"""
本轮只吃买一深度:
- 本批张数 = min(请求张数, 持仓, 买一深度)
- 限价 = 校验通过时锁定的买一价
- 永不市价
- 始终校验有效流动性(残档买一禁止)
- require_recycle_gate=True 时:首次还需可回收≥2×权利金并持续 hold 秒;
一旦通过后对同仓续批只验流动性
"""
from lib.exchange.okx_options_lib import (
_pos_side_from_position,
invalidate_option_positions_cache,
)
inst_id = (inst_id or "").strip()
if not inst_id:
return {"ok": False, "msg": "缺少 inst_id"}
q = cfg["quote_option_contract"](ex, inst_id)
if not q.get("ok"):
return {"ok": False, "msg": q.get("msg") or "报价失败"}
tick_sz = q.get("tick_sz")
ct_mult = float(q.get("ct_mult") or 0.01)
raw_positions = cfg["fetch_option_positions"](ex)
if raw_positions is None:
return {"ok": False, "msg": "获取期权持仓失败"}
pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None)
if not pos:
clear_close_gate(inst_id)
return {"ok": False, "msg": "未找到持仓", "already_flat": True}
avail = _avail_sheets(pos)
want = int(sheets) if sheets else avail
want = min(want, avail)
if want < 1:
clear_close_gate(inst_id)
return {"ok": False, "msg": "可平张数不足", "already_flat": True}
td_mode = str(pos.get("mgnMode") or cfg.get("td_mode") or "isolated")
pos_side = _pos_side_from_position(pos) or "net"
mark_px, intrinsic_px = _pos_close_refs(ex, pos, q)
premium_paid = _open_premium_paid(cfg, inst_id)
if premium_paid is None:
premium_paid = _safe_float(pos.get("premium_paid"))
# 已有未成交卖平单:等成交,不撤不重挂
try:
pending = ex.private_get_trade_orders_pending({"instType": "OPTION", "instId": inst_id}) or {}
sell_pending = [
o
for o in (pending.get("data") or [])
if str(o.get("side") or "").lower() == "sell" and o.get("ordId")
]
if sell_pending:
time.sleep(0.5)
invalidate_option_positions_cache()
raw_positions = cfg["fetch_option_positions"](ex)
if raw_positions is None:
return {"ok": False, "msg": "获取期权持仓失败"}
pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None)
if not pos or _avail_sheets(pos) < 1:
clear_close_gate(inst_id)
return {
"ok": True,
"already_flat": True,
"msg": "已有限价卖单成交",
"close_ord_id": ",".join(str(o.get("ordId")) for o in sell_pending),
"fully_closed": True,
"submitted_sheets": want,
"remaining_sheets": 0,
"mode": "bid1",
}
return {
"ok": False,
"msg": "等待已有买一限价卖单成交",
"stopped_reason": "pending_close_order",
"close_ord_id": ",".join(str(o.get("ordId")) for o in sell_pending),
}
except Exception:
pass
book = cfg["fetch_option_book_depth"](ex, inst_id, 1)
preview = estimate_close_by_bids(
book.get("bids") or [],
want,
ct_mult=ct_mult,
premium_paid=premium_paid,
mark_px=mark_px,
intrinsic_px=intrinsic_px,
max_levels=1,
)
if preview.get("bid_invalid") or preview.get("auto_close_blocked"):
_cancel_sell_pending(ex, inst_id)
update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid)
return {
"ok": False,
"msg": preview.get("bid_invalid_reason") or "暂无有效买盘,禁止平仓",
"stopped_reason": "stub_bid",
"auto_close_blocked": True,
"liquidity_blocked": True,
}
levels = preview.get("levels") or []
if not levels:
bid_px = _safe_float(q.get("bid"))
stub, stub_reason = is_stub_bid_px(bid_px, mark_px=mark_px, intrinsic_px=intrinsic_px)
if stub or bid_px is None or bid_px <= 0:
update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid)
return {
"ok": False,
"msg": stub_reason or "暂无买一,无法限价平仓",
"stopped_reason": "stub_bid" if stub else "no_bid",
"auto_close_blocked": True,
"liquidity_blocked": True,
}
return {
"ok": False,
"msg": "暂无买一深度,无法平仓",
"stopped_reason": "no_bid_depth",
"liquidity_blocked": True,
}
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:
return {"ok": False, "msg": "买一深度无效", "stopped_reason": "invalid_bid_depth"}
stub_lv, stub_lv_reason = is_stub_bid_px(level_px, mark_px=mark_px, intrinsic_px=intrinsic_px)
if stub_lv:
update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid)
return {
"ok": False,
"msg": stub_lv_reason or "暂无有效买盘,禁止平仓",
"stopped_reason": "stub_bid",
"auto_close_blocked": True,
"liquidity_blocked": True,
}
# 自动平仓:2×权利金门控(首次);通过后同仓续批只验流动性
gate = update_close_gate(
inst_id,
recycle_usdc=_safe_float(preview.get("total_received")),
premium_paid=premium_paid,
)
if require_recycle_gate and not is_close_gate_passed(inst_id) and not gate.get("ready"):
return {
"ok": False,
"msg": gate.get("msg") or "平仓门控未就绪(需可回收≥2×权利金并持续一段时间)",
"stopped_reason": "close_gate",
"auto_close_blocked": True,
"close_gate": gate,
}
if gate.get("ready"):
mark_close_gate_passed(inst_id)
locked_bid_px = level_px
before_avail = avail
order = cfg["place_option_limit_order"](
ex,
inst_id=inst_id,
side="sell",
sheets=level_sheets,
price=locked_bid_px,
td_mode=td_mode,
tick_sz=tick_sz,
reduce_only=True,
pos_side=pos_side,
)
if not order.get("ok"):
return {
"ok": False,
"msg": order.get("msg") or "买一限价平仓失败",
"stopped_reason": "order_failed",
"locked_bid_px": locked_bid_px,
"batch_sheets": level_sheets,
}
px = float(order.get("px", locked_bid_px))
oid = str((order.get("data") or {}).get("ordId") or "")
prem_recv = round(total_premium(px, level_sheets * ct_mult), 4)
time.sleep(0.6)
invalidate_option_positions_cache()
raw2 = cfg["fetch_option_positions"](ex)
after_avail = 0
if raw2 is not None:
after_pos = next((p for p in raw2 if str(p.get("instId")) == inst_id), None)
after_avail = _avail_sheets(after_pos) if after_pos else 0
reduced = max(0, before_avail - after_avail) if raw2 is not None else 0
remaining_pos = after_avail if raw2 is not None else max(0, before_avail - level_sheets)
fully_closed = remaining_pos < 1
if fully_closed:
clear_close_gate(inst_id)
conn = cfg["get_db"]()
try:
from lib.options.options_db import init_options_tables
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:
paid = float(row["premium_paid"] or 0)
pnl = prem_recv - paid
note_sql = ""
params: list[Any] = [px, prem_recv, pnl, oid or None]
if signal_note:
note_sql = """,
signal_note = CASE
WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN ?
ELSE signal_note
END"""
params.append(signal_note)
params.append(int(row["id"]))
conn.execute(
f"""
UPDATE options_trades
SET status = 'closed', close_quote = ?, premium_received = ?,
realized_pnl = ?, close_ord_id = ?, closed_at = CURRENT_TIMESTAMP
{note_sql}
WHERE id = ?
""",
tuple(params),
)
conn.commit()
finally:
conn.close()
elif require_recycle_gate:
# 自动平已挂过单:同仓续批只验流动性
mark_close_gate_passed(inst_id)
return {
"ok": True,
"mode": "bid1",
"orders": [{"order": order, "px": px, "sheets": level_sheets}],
"bid": px,
"locked_bid_px": locked_bid_px,
"submitted_sheets": level_sheets,
"filled_or_reduced_sheets": min(reduced, level_sheets) if reduced else 0,
"remaining_sheets": remaining_pos,
"premium_received": prem_recv,
"stopped_reason": None if fully_closed else ("partial_bid1" if reduced > 0 else "order_not_filled"),
"close_ord_id": oid or None,
"fully_closed": fully_closed,
"msg": (
f"已按买一 {locked_bid_px:g} 提交 {level_sheets}"
+ ("" if fully_closed else f",剩余 {remaining_pos} 张待下次平仓")
),
}
# 兼容旧名
def close_option_by_bid_depth(
cfg: dict[str, Any],
ex: Any,
inst_id: str,
*,
sheets: int | None = None,
) -> dict[str, Any]:
return close_option_by_bid1(
cfg,
ex,
inst_id,
sheets=sheets,
require_recycle_gate=True,
signal_note="目标位平仓",
)
+30 -5
View File
@@ -40,6 +40,26 @@ def clear_close_gate(inst_id: str | None = None) -> None:
_gates.clear()
def mark_close_gate_passed(inst_id: str) -> None:
"""标记同仓已通过 2× 门控,续批平仓只验流动性."""
inst = (inst_id or "").strip()
if not inst:
return
with _lock:
st = _gates.get(inst) or {}
st["passed"] = True
st["updated"] = time.time()
_gates[inst] = st
def is_close_gate_passed(inst_id: str) -> bool:
inst = (inst_id or "").strip()
if not inst:
return False
with _lock:
return bool((_gates.get(inst) or {}).get("passed"))
def update_close_gate(
inst_id: str,
*,
@@ -86,6 +106,8 @@ def update_close_gate(
ok_since = None
held = (ts - float(ok_since)) if ok_since is not None else 0.0
ready = bool(recycle_ok and held + 1e-9 >= hold)
prev_passed = bool(prev.get("passed"))
passed = prev_passed or ready
state = {
"ok_since": ok_since,
"recycle": recv,
@@ -94,6 +116,7 @@ def update_close_gate(
"updated": ts,
"min_mult": mult,
"hold_seconds": hold,
"passed": passed,
}
_gates[inst] = state
@@ -103,18 +126,20 @@ def update_close_gate(
elif recv is None:
msg = "暂无有效买盘可回收金额"
elif not recycle_ok:
msg = f"可回收 {recv:.4f} USDC < 权利金×{mult:g}({need:.4f}),暂不可平仓"
msg = f"可回收 {recv:.4f} USDC < 权利金×{mult:g}({need:.4f}),暂不可自动平仓"
elif not ready:
msg = (
f"可回收已达×{mult:g}({recv:.4f}/{need:.4f}),"
f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)"
f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)后才可自动平仓"
)
else:
msg = f"可回收已达×{mult:g}且持续≥{hold:.0f}s,允许按买平仓"
msg = f"可回收已达×{mult:g}且持续≥{hold:.0f}s,允许自动按买平仓"
auto_blocked = not (ready or passed)
return {
"ok": True,
"ready": ready,
"passed": passed,
"recycle_ok": recycle_ok,
"recycle_usdc": recv,
"premium_paid": prem,
@@ -125,8 +150,8 @@ def update_close_gate(
"remain_seconds": round(remain, 1) if remain is not None else None,
"ok_since": ok_since,
"msg": msg,
"auto_close_blocked": not ready,
"close_gate_blocked": not ready,
"auto_close_blocked": auto_blocked,
"close_gate_blocked": auto_blocked,
}
+11 -4
View File
@@ -5,7 +5,7 @@ from typing import Any
from lib.options.options_db import init_options_tables
from lib.options.options_history_lib import enrich_position_row_display
from lib.options.options_close_gate_lib import clear_close_gate, update_close_gate
from lib.options.options_close_gate_lib import clear_close_gate, is_close_gate_passed, update_close_gate
from lib.options.options_pricing_lib import estimate_close_by_bids, intrinsic_px_per_unit
@@ -41,6 +41,7 @@ def attach_close_preview(
_safe_float(row.get("strike") or row.get("stk")),
_safe_float(row.get("idx_px") or row.get("idxPx")),
)
# 与实盘一致:只按买一估算本轮可平
preview = estimate_close_by_bids(
row["bid_depth"],
target_sheets,
@@ -48,23 +49,29 @@ def attach_close_preview(
premium_paid=paid,
mark_px=mark_px,
intrinsic_px=intrinsic,
max_levels=1,
)
# 残档时不累计 2×权利金门控;有效回收时刷新持续计时
# 残档时不累计 2×门控;有效买一时刷新计时(仅自动平仓需要)
if preview.get("bid_invalid") or preview.get("auto_close_blocked"):
gate = update_close_gate(inst_id, recycle_usdc=None, premium_paid=paid)
preview["close_gate"] = gate
preview["close_gate_blocked"] = True
preview["close_gate_msg"] = preview.get("bid_invalid_reason") or gate.get("msg")
preview["manual_close_blocked"] = True
preview["liquidity_ok"] = False
else:
gate = update_close_gate(
inst_id,
recycle_usdc=_safe_float(preview.get("total_received")),
premium_paid=paid,
)
passed = bool(gate.get("passed") or is_close_gate_passed(inst_id) or gate.get("ready"))
preview["close_gate"] = gate
preview["close_gate_blocked"] = not gate.get("ready")
preview["close_gate_blocked"] = not passed
preview["close_gate_msg"] = gate.get("msg")
if not gate.get("ready"):
preview["manual_close_blocked"] = False
preview["liquidity_ok"] = True
if not passed:
preview["auto_close_blocked"] = True
row["close_preview"] = preview
return row
+7 -3
View File
@@ -166,12 +166,14 @@ def estimate_close_by_bids(
mark_px: float | None = None,
intrinsic_px: float | None = None,
min_bid_ratio: float = BID_CLOSE_MIN_RATIO,
max_levels: int = 1,
) -> dict[str, Any]:
"""按买一到买N逐档估算限价卖出可收回金额;残档买盘不参与估算与自动平仓."""
"""按买估算限价卖出可收回金额;默认只估算买一(与实盘平仓一致);残档不参与."""
target = max(0, int(float(sheets or 0)))
remaining = target
total_received = 0.0
levels: list[dict[str, Any]] = []
max_lv = max(1, int(max_levels or 1))
empty = {
"levels": [],
"covered_sheets": 0,
@@ -183,6 +185,7 @@ def estimate_close_by_bids(
"bid_invalid": False,
"bid_invalid_reason": None,
"auto_close_blocked": False,
"max_levels": max_lv,
}
if target <= 0 or ct_mult <= 0:
return empty
@@ -196,7 +199,7 @@ def estimate_close_by_bids(
out["auto_close_blocked"] = True
out["raw_bid_px"] = _safe_px((bids or [{}])[0].get("px")) if bids else None
return out
for i, level in enumerate(usable, start=1):
for i, level in enumerate(usable[:max_lv], start=1):
if remaining <= 0:
break
try:
@@ -223,7 +226,7 @@ def estimate_close_by_bids(
remaining -= take
covered = target - remaining
avg_px = (total_received / eth_amount_from_sheets(covered, ct_mult)) if covered > 0 else None
# 净盈亏 = 买盘可回收 全部权利金(与「可落袋」口径一致;买一不够会展开更多档)
# 净盈亏 = 本轮买盘可回收 全部权利金(买一不够时剩余张数计入 uncovered)
estimated_pnl = None
estimated_pnl_ratio_pct = None
if premium_paid is not None and covered > 0:
@@ -242,6 +245,7 @@ def estimate_close_by_bids(
"bid_invalid": False,
"bid_invalid_reason": None,
"auto_close_blocked": False,
"max_levels": max_lv,
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -6,8 +6,8 @@
<div class="options-dual-grid">
<div class="card options-order-card">
<h2>期权下单</h2>
<p class="muted options-hint">报价单位为每 1 ETH/BTC;1 张 = 0.01.<strong>列表</strong>含卖一/买一;<strong>T 型</strong>仅卖一(买方开仓),中间为跨式双买测算.卖一无挂单时以标记价估算并标 <strong>~</strong>.链展示近 <span id="opt-chain-dte">14</span> 日到期.<strong>T 型</strong>默认 ATM ±5 档,可展开全部.</p>
<h2>期权下单 <a class="muted" href="/options/guide" target="_blank" rel="noopener" style="font-size:13px;font-weight:500;margin-left:8px">开平仓与监控说明</a></h2>
<p class="muted options-hint">报价单位为每 1 ETH/BTC;1 张 = 0.01.<strong>列表</strong>含卖一/买一;<strong>T 型</strong>仅卖一(买方开仓),中间为跨式双买测算.卖一无挂单时以标记价估算并标 <strong>~</strong>.链展示近 <span id="opt-chain-dte">14</span> 日到期.<strong>T 型</strong>默认 ATM ±5 档,可展开全部.平仓仅买一限价,见说明.</p>
<div class="form-row options-chain-toolbar">
<button type="button" class="btn-secondary opt-uly-btn active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary opt-uly-btn" data-uly="BTC">BTC</button>
@@ -137,15 +137,16 @@
<div id="opt-pos-cards"></div>
</div>
<details class="opt-close-rule">
<summary>多档平仓规则说明</summary>
<summary>买一平仓规则说明</summary>
<div class="opt-close-rule-body">
<p>系统平仓前重新读取最新买盘,不使用页面缓存</p>
<p>平仓前重新读盘口并校验有效流动性;市价平仓已禁用</p>
<ul>
<li>买一数量足够覆盖持仓时,只按买一价提交一笔限价卖单</li>
<li>买一不够时,先卖买一可覆盖数量;成交后刷新持仓和盘口,再继续用新的最优买盘拆分</li>
<li>最多尝试 5 次,全程使用限价卖出,并带 <code>reduceOnly</code>,不会主动市价平仓</li>
<li>盘口不足或订单未成交时会停止后续拆单,并提示剩余张数</li>
<li>本轮只锁<strong>买一</strong>:张数 = min(持仓, 买一深度),限价 = 当场买一</li>
<li>买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」</li>
<li>手动平仓只验有效买一(非残档);目标位自动平额外需可回收≥2×权利金并持续约 2 分钟</li>
<li>全程 <code>reduceOnly</code> 限价卖,不吃买二及以下、不走市价</li>
</ul>
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
</div>
</details>
</div>
@@ -257,4 +258,4 @@
</div>
</div>
<script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/options_panel.js?v=28"></script>
<script src="/static/options_panel.js?v=29"></script>