diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 98b9527..e9a3bc2 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -374,6 +374,9 @@ /** 买盘深度:价格/流动性;仅展示平仓所需档位(买一不够才出买二…). */ function fmtCloseLevels(preview, tickSz) { + if (preview && (preview.auto_close_blocked || preview.bid_invalid)) { + return "暂无有效买盘"; + } const levels = ((preview && preview.levels) || []).slice(0, 5); if (!levels.length) return "—"; return levels.map(function (x, idx) { @@ -885,12 +888,18 @@ '
到期平衡' + fmt(p.expiry_be_px, 0) + "
" + '
平掉回本' + fmt(p.close_be_px, 0) + "
" + '
净盈亏' + - (net == null ? "—" : fmt(net, 2)) + "
" + + (closePreview.auto_close_blocked || closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "" + '
收益率' + - (roi == null ? "—" : fmt(roi, 2) + "%") + "
" + + (closePreview.auto_close_blocked || closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "" + '
买盘深度' + fmtCloseLevels(closePreview, tickSz) + "
" + - '
按买盘回收' + fmtClosePreview(closePreview, p.premium_paid) + "
" + + '
按买盘回收' + + (closePreview.auto_close_blocked || closePreview.bid_invalid + ? '禁用以残档自动平' + : fmtClosePreview(closePreview, p.premium_paid)) + "
" + "" + + (closePreview.auto_close_blocked || closePreview.bid_invalid + ? '
' + (closePreview.bid_invalid_reason || "当前买一无效,禁止按买盘自动平仓") + "
" + : "") + renderTargetDelegateRow(p) ); } @@ -1184,6 +1193,10 @@ return; } const preview = q.close_preview || {}; + if (preview.auto_close_blocked || preview.bid_invalid) { + alert(preview.bid_invalid_reason || "当前买一为无效残档,禁止按买盘自动平仓。请到 OKX App 自行挂限价/市价。"); + return; + } if (!preview.covered_sheets || preview.covered_sheets <= 0) { alert("暂无可用买盘深度,请稍后在 OKX App 平仓或等盘口恢复"); return; diff --git a/lib/common/static/options_position_cards.js b/lib/common/static/options_position_cards.js index 529117c..e8408f9 100644 --- a/lib/common/static/options_position_cards.js +++ b/lib/common/static/options_position_cards.js @@ -52,6 +52,9 @@ /** 买盘深度:价格/流动性;仅展示平仓所需档位(买一不够才出买二…). */ function fmtCloseLevels(preview, tickSz) { + if (preview && (preview.auto_close_blocked || preview.bid_invalid)) { + return "暂无有效买盘"; + } const levels = ((preview && preview.levels) || []).slice(0, 5); if (!levels.length) return "—"; return levels.map(function (x, idx) { @@ -147,12 +150,18 @@ '
到期平衡' + fmt(p.expiry_be_px, 0) + "
" + '
平掉回本' + fmt(p.close_be_px, 0) + "
" + '
净盈亏' + - (net == null ? "—" : fmt(net, 2)) + "
" + + (closePreview.auto_close_blocked || closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "" + '
收益率' + - (roi == null ? "—" : fmt(roi, 2) + "%") + "
" + + (closePreview.auto_close_blocked || closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "" + '
买盘深度' + fmtCloseLevels(closePreview, tickSz) + "
" + - '
按买盘回收' + fmtClosePreview(closePreview, p.premium_paid, hub) + "
" + + '
按买盘回收' + + (closePreview.auto_close_blocked || closePreview.bid_invalid + ? '禁用以残档自动平' + : fmtClosePreview(closePreview, p.premium_paid, hub)) + "
" + "" + + (closePreview.auto_close_blocked || closePreview.bid_invalid + ? '
' + (closePreview.bid_invalid_reason || "当前买一无效,禁止按买盘自动平仓") + "
" + : "") + (p.target_index != null ? (function () { const eth = p.eth_amount != null ? Number(p.eth_amount) diff --git a/lib/options/options_positions_lib.py b/lib/options/options_positions_lib.py index 6942df6..5c05d78 100644 --- a/lib/options/options_positions_lib.py +++ b/lib/options/options_positions_lib.py @@ -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_pricing_lib import estimate_close_by_bids +from lib.options.options_pricing_lib import estimate_close_by_bids, intrinsic_px_per_unit def _safe_float(v: Any) -> float | None: @@ -34,11 +34,19 @@ def attach_close_preview( book = cfg["fetch_option_book_depth"](ex, inst_id, 5) row["bid_depth"] = book.get("bids") or [] row["ask_depth"] = book.get("asks") or [] + mark_px = _safe_float(row.get("mark_px") or row.get("markPx")) + intrinsic = intrinsic_px_per_unit( + row.get("opt_type") or row.get("optType"), + _safe_float(row.get("strike") or row.get("stk")), + _safe_float(row.get("idx_px") or row.get("idxPx")), + ) row["close_preview"] = estimate_close_by_bids( row["bid_depth"], target_sheets, ct_mult=ct_mult, premium_paid=paid, + mark_px=mark_px, + intrinsic_px=intrinsic, ) return row diff --git a/lib/options/options_pricing_lib.py b/lib/options/options_pricing_lib.py index ca381b3..8022781 100644 --- a/lib/options/options_pricing_lib.py +++ b/lib/options/options_pricing_lib.py @@ -50,29 +50,153 @@ def total_premium(quote_per_unit: float, eth_amount: float, ct_mult: float = 0.0 return float(quote_per_unit) * float(eth_amount) +# 买一相对标记价/内在价值低于该比例 → 视为残档,禁止按买盘自动/多档平仓 +BID_CLOSE_MIN_RATIO = 0.3 + + +def _safe_px(v: Any) -> float | None: + if v is None or v == "": + return None + try: + x = float(v) + except (TypeError, ValueError): + return None + return x if x > 0 else None + + +def intrinsic_px_per_unit(opt_type: str | None, strike: float | None, index_px: float | None) -> float | None: + o = (opt_type or "").strip().upper() + if strike is None or index_px is None: + return None + try: + k = float(strike) + idx = float(index_px) + except (TypeError, ValueError): + return None + if o == "C" and idx > k: + return idx - k + if o == "P" and idx < k: + return k - idx + return None + + +def is_stub_bid_px( + bid_px: float | None, + *, + mark_px: float | None = None, + intrinsic_px: float | None = None, + min_ratio: float = BID_CLOSE_MIN_RATIO, +) -> tuple[bool, str]: + """ + 判断买一是否为无效残档(如标记 42、买一 0.2). + 返回 (is_stub, reason). + """ + bid = _safe_px(bid_px) + if bid is None: + return True, "无买一" + ref = _safe_px(mark_px) + ref_name = "标记价" + intrinsic = _safe_px(intrinsic_px) + if intrinsic is not None and (ref is None or intrinsic > ref): + ref = intrinsic + ref_name = "内在价值" + if ref is None: + return False, "" + ratio = float(min_ratio) if min_ratio and min_ratio > 0 else BID_CLOSE_MIN_RATIO + if bid < ref * ratio: + return True, f"买一{bid:g}远低于{ref_name}{ref:g},属无效残档,禁止按买盘自动平仓" + return False, "" + + +def fetch_option_mark_px(ex: Any, inst_id: str) -> float | None: + """优先 mark-price 接口,失败则 None.""" + inst_id = (inst_id or "").strip() + if not inst_id or ex is None: + return None + try: + rows = ex.public_get_public_mark_price({"instType": "OPTION", "instId": inst_id}).get("data") or [] + if rows: + return _safe_px(rows[0].get("markPx")) + except Exception: + pass + return None + + +def close_ref_prices( + *, + mark_px: float | None = None, + opt_type: str | None = None, + strike: float | None = None, + index_px: float | None = None, +) -> tuple[float | None, float | None]: + """返回 (mark_px, intrinsic_px) 供残档判断.""" + return _safe_px(mark_px), intrinsic_px_per_unit(opt_type, strike, index_px) + + +def filter_bids_for_close( + bids: list[dict[str, Any]] | None, + *, + mark_px: float | None = None, + intrinsic_px: float | None = None, + min_ratio: float = BID_CLOSE_MIN_RATIO, +) -> tuple[list[dict[str, Any]], bool, str]: + """过滤不可用于平仓的残档买盘.返回 (usable_bids, had_stub_only, reason).""" + raw = list(bids or []) + usable: list[dict[str, Any]] = [] + stub_reason = "" + for level in raw: + px = _safe_px(level.get("px") if isinstance(level, dict) else None) + stub, reason = is_stub_bid_px(px, mark_px=mark_px, intrinsic_px=intrinsic_px, min_ratio=min_ratio) + if stub: + if not stub_reason: + stub_reason = reason or "买一无效" + continue + usable.append(level) + if raw and not usable: + return [], True, stub_reason or "暂无有效买盘" + return usable, False, "" + + def estimate_close_by_bids( bids: list[dict[str, Any]] | None, sheets: int | float, *, ct_mult: float = 0.01, premium_paid: float | None = None, + mark_px: float | None = None, + intrinsic_px: float | None = None, + min_bid_ratio: float = BID_CLOSE_MIN_RATIO, ) -> dict[str, Any]: - """按买一到买N逐档估算限价卖出可收回金额.""" + """按买一到买N逐档估算限价卖出可收回金额;残档买盘不参与估算与自动平仓.""" target = max(0, int(float(sheets or 0))) remaining = target total_received = 0.0 levels: list[dict[str, Any]] = [] + empty = { + "levels": [], + "covered_sheets": 0, + "uncovered_sheets": target, + "total_received": 0.0, + "avg_px": None, + "estimated_pnl": None, + "estimated_pnl_ratio_pct": None, + "bid_invalid": False, + "bid_invalid_reason": None, + "auto_close_blocked": False, + } 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, - "estimated_pnl_ratio_pct": None, - } - for i, level in enumerate(bids or [], start=1): + return empty + usable, stub_only, stub_reason = filter_bids_for_close( + bids, mark_px=mark_px, intrinsic_px=intrinsic_px, min_ratio=min_bid_ratio + ) + if stub_only: + out = dict(empty) + out["bid_invalid"] = True + out["bid_invalid_reason"] = stub_reason + 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): if remaining <= 0: break try: @@ -115,6 +239,9 @@ def estimate_close_by_bids( "avg_px": round(avg_px, 4) if avg_px is not None else None, "estimated_pnl": estimated_pnl, "estimated_pnl_ratio_pct": estimated_pnl_ratio_pct, + "bid_invalid": False, + "bid_invalid_reason": None, + "auto_close_blocked": False, } diff --git a/lib/options/options_register.py b/lib/options/options_register.py index a4e8daf..390e704 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -13,8 +13,12 @@ from lib.options.options_db import init_options_tables from lib.options.options_monitor_lib import options_monitor_loop from lib.options.options_pricing_lib import ( calc_order_size, + close_ref_prices, ct_mult_from_meta, estimate_close_by_bids, + fetch_option_mark_px, + filter_bids_for_close, + is_stub_bid_px, min_sz_from_meta, premium_per_sheet, total_premium, @@ -707,6 +711,41 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: return jsonify(order) elif depth_split: ct_mult = float(q.get("ct_mult") or 0.01) + from lib.exchange.okx_options_lib import option_fields_from_inst_id + + mark_px = _safe_float(pos.get("markPx")) or _safe_float(q.get("mark_px") or q.get("mark")) + if mark_px is None: + mark_px = fetch_option_mark_px(ex, inst_id) + opt_type = pos.get("optType") or q.get("opt_type") + strike = _safe_float(pos.get("stk")) or _safe_float(q.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_px = _safe_float(pos.get("idxPx")) or _safe_float(q.get("index_px")) + mark_px, intrinsic_px = close_ref_prices( + mark_px=mark_px, opt_type=str(opt_type or ""), strike=strike, index_px=idx_px + ) + book0 = cfg["fetch_option_book_depth"](ex, inst_id, 5) + usable0, stub_only0, stub_reason0 = filter_bids_for_close( + book0.get("bids") or [], mark_px=mark_px, intrinsic_px=intrinsic_px + ) + if stub_only0 or not usable0: + bid_chk = None + if book0.get("bids"): + bid_chk = _safe_float((book0.get("bids") or [{}])[0].get("px")) + bid_chk = bid_chk or _safe_float(bid) + stub, stub_reason = is_stub_bid_px(bid_chk, mark_px=mark_px, intrinsic_px=intrinsic_px) + if stub or stub_only0: + return jsonify( + { + "ok": False, + "msg": stub_reason0 or stub_reason or "暂无有效买盘,禁止按买盘自动平仓", + "stopped_reason": "stub_bid", + "auto_close_blocked": True, + } + ) remaining = close_sheets submitted_sheets = 0 filled_or_reduced_sheets = 0 @@ -726,7 +765,22 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: 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) + preview = estimate_close_by_bids( + book.get("bids") or [], + remaining, + ct_mult=ct_mult, + mark_px=mark_px, + intrinsic_px=intrinsic_px, + ) + if preview.get("auto_close_blocked") or preview.get("bid_invalid"): + return jsonify( + { + "ok": False, + "msg": preview.get("bid_invalid_reason") or "暂无有效买盘,禁止按买盘自动平仓", + "stopped_reason": "stub_bid", + "auto_close_blocked": True, + } + ) levels = preview.get("levels") or [] if not levels: stopped_reason = "no_bid_depth" @@ -829,7 +883,33 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: } ) else: + from lib.exchange.okx_options_lib import option_fields_from_inst_id + + mark_px = _safe_float(pos.get("markPx")) or _safe_float(q.get("mark_px") or q.get("mark")) + if mark_px is None: + mark_px = fetch_option_mark_px(ex, inst_id) + opt_type = pos.get("optType") or q.get("opt_type") + strike = _safe_float(pos.get("stk")) or _safe_float(q.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_px = _safe_float(pos.get("idxPx")) or _safe_float(q.get("index_px")) + mark_px, intrinsic_px = close_ref_prices( + mark_px=mark_px, opt_type=str(opt_type or ""), strike=strike, index_px=idx_px + ) close_px = float(bid) + stub, stub_reason = is_stub_bid_px(close_px, mark_px=mark_px, intrinsic_px=intrinsic_px) + if stub: + return jsonify( + { + "ok": False, + "msg": stub_reason or "暂无有效买盘,禁止按买盘自动平仓", + "stopped_reason": "stub_bid", + "auto_close_blocked": True, + } + ) order = cfg["place_option_limit_order"]( ex, inst_id=inst_id, diff --git a/lib/options/options_target_lib.py b/lib/options/options_target_lib.py index 1347e64..b9cced2 100644 --- a/lib/options/options_target_lib.py +++ b/lib/options/options_target_lib.py @@ -6,7 +6,14 @@ import time from typing import Any, Callable from lib.options.options_db import init_options_tables -from lib.options.options_pricing_lib import estimate_close_by_bids, total_premium +from lib.options.options_pricing_lib import ( + close_ref_prices, + estimate_close_by_bids, + fetch_option_mark_px, + filter_bids_for_close, + is_stub_bid_px, + total_premium, +) def _safe_float(v: Any) -> float | None: @@ -18,6 +25,24 @@ def _safe_float(v: Any) -> float | None: 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 + + 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 ensure_target_tables(conn: sqlite3.Connection) -> None: init_options_tables(conn) conn.execute( @@ -290,6 +315,46 @@ def close_option_by_bid_depth( 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) + + def _cancel_sell_pending() -> 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 + + # 残档买盘:禁止自动按买盘平仓(并撤掉可能已挂的异常低价卖单) + try: + book0 = cfg["fetch_option_book_depth"](ex, inst_id, 5) + usable0, stub_only0, stub_reason0 = filter_bids_for_close( + book0.get("bids") or [], mark_px=mark_px, intrinsic_px=intrinsic_px + ) + raw_bid0 = None + if book0.get("bids"): + raw_bid0 = _safe_float((book0.get("bids") or [{}])[0].get("px")) + if not usable0: + bid_chk = raw_bid0 or _safe_float(q.get("bid")) + stub, stub_reason = is_stub_bid_px(bid_chk, mark_px=mark_px, intrinsic_px=intrinsic_px) + if stub or stub_only0: + _cancel_sell_pending() + return { + "ok": False, + "msg": stub_reason0 or stub_reason or "暂无有效买盘,禁止自动平仓", + "stopped_reason": "stub_bid", + "auto_close_blocked": True, + } + except Exception: + pass # 已有未成交卖平单时先等成交,避免每轮撤单重挂反复推送/吃档 try: @@ -378,12 +443,36 @@ def close_option_by_bid_depth( 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) + preview = estimate_close_by_bids( + book.get("bids") or [], + remaining, + ct_mult=ct_mult, + mark_px=mark_px, + intrinsic_px=intrinsic_px, + ) + if preview.get("auto_close_blocked") or preview.get("bid_invalid"): + stopped_reason = "stub_bid" + _cancel_sell_pending() + return { + "ok": False, + "msg": preview.get("bid_invalid_reason") or "暂无有效买盘,禁止自动平仓", + "stopped_reason": "stub_bid", + "auto_close_blocked": True, + } levels = preview.get("levels") or [] if not levels: # 无买盘深度时仅允许真实买一价,不用标记价挂单 q2 = cfg["quote_option_contract"](ex, inst_id) bid_px = _safe_float(q2.get("bid")) or _safe_float(q.get("bid")) + stub, stub_reason = is_stub_bid_px(bid_px, mark_px=mark_px, intrinsic_px=intrinsic_px) + if stub: + stopped_reason = "stub_bid" + return { + "ok": False, + "msg": stub_reason or "暂无有效买盘,禁止自动平仓", + "stopped_reason": "stub_bid", + "auto_close_blocked": True, + } if bid_px is None or bid_px <= 0: stopped_reason = "no_bid" break @@ -394,6 +483,14 @@ def close_option_by_bid_depth( if level_sheets <= 0 or level_px <= 0: stopped_reason = "invalid_bid_depth" break + stub_lv, stub_lv_reason = is_stub_bid_px(level_px, mark_px=mark_px, intrinsic_px=intrinsic_px) + if stub_lv: + return { + "ok": False, + "msg": stub_lv_reason or "暂无有效买盘,禁止自动平仓", + "stopped_reason": "stub_bid", + "auto_close_blocked": True, + } before_avail = current_avail order = cfg["place_option_limit_order"]( ex, diff --git a/tests/test_options_pricing.py b/tests/test_options_pricing.py index 3e4b265..397eb54 100644 --- a/tests/test_options_pricing.py +++ b/tests/test_options_pricing.py @@ -221,6 +221,38 @@ def test_estimate_close_by_bids_empty(): assert out["avg_px"] is None +def test_stub_bid_blocks_auto_close_estimate(): + from lib.options.options_pricing_lib import estimate_close_by_bids, is_stub_bid_px + + stub, reason = is_stub_bid_px(0.2, mark_px=42.0) + assert stub is True + assert "残档" in reason or "无效" in reason or "远低于" in reason + + out = estimate_close_by_bids( + [{"px": 0.2, "sz": 3500}], + 66, + ct_mult=0.01, + premium_paid=9.37, + mark_px=42.0, + ) + assert out["auto_close_blocked"] is True + assert out["bid_invalid"] is True + assert out["estimated_pnl"] is None + assert out["levels"] == [] + + ok, _ = is_stub_bid_px(30.0, mark_px=42.0) + assert ok is False + good = estimate_close_by_bids( + [{"px": 30.0, "sz": 100}], + 10, + ct_mult=0.01, + premium_paid=1.0, + mark_px=42.0, + ) + assert good["auto_close_blocked"] is False + assert good["covered_sheets"] == 10 + + def test_expiry_breakeven_from_ask(): from lib.options.options_pricing_lib import expiry_breakeven_from_ask