diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 2a2723d..35716c4 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -150,9 +150,10 @@ "指数 " + state.underlying + " ≈ " + fmt(idx, 2) + " · 实值=价内 · 虚值=价外"; } - function fmtPxSz(px, sz) { + function fmtPxSz(px, sz, estimated) { if (px === null || px === undefined || Number.isNaN(Number(px))) return "—"; - const price = Number(px).toFixed(4).replace(/\.?0+$/, ""); + let price = Number(px).toFixed(4).replace(/\.?0+$/, ""); + if (estimated) price += "~"; if (sz === null || sz === undefined || sz === "" || Number.isNaN(Number(sz))) return price; const s = Number(sz); const size = Math.abs(s - Math.round(s)) < 1e-9 ? String(Math.round(s)) : String(s); @@ -295,7 +296,7 @@ "" + c.strike + "" + "" + moneynessBadge(c) + "" + "" + c.inst_id + "" + - "" + fmtPxSz(c.ask, c.ask_sz) + "" + + "" + fmtPxSz(c.ask, c.ask_sz, c.ask_estimated) + "" + "" + fmtPxSz(c.bid, c.bid_sz) + "" + "" + (c.expiry_be_px != null ? fmt(c.expiry_be_px, 0) : "—") + "" + '' + fmtDist(c.dist_expiry_be) + "" + diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 31ce7f2..7b0dbac 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -115,6 +115,61 @@ def format_option_px(px: float, tick_sz: Any) -> str: return f"{px:.{decimals}f}".rstrip("0").rstrip(".") or "0" +def _intrinsic_px_per_unit(opt_type: str, strike: float, index_px: float) -> float | None: + o = (opt_type or "").upper() + if o == "C" and index_px > strike: + return float(index_px) - float(strike) + if o == "P" and index_px < strike: + return float(strike) - float(index_px) + return None + + +def _resolve_chain_quote( + *, + ticker: dict[str, Any], + meta: dict[str, Any], + opt_type: str, + strike: float, + index_px: float, +) -> dict[str, Any]: + """链列表报价:卖一缺失时用标记价/内在价值估算(深度实值常见无卖一)。""" + tick_sz = meta.get("tickSz") + ask = _safe_float(ticker.get("askPx")) + bid = _safe_float(ticker.get("bidPx")) + mark = _safe_float(ticker.get("markPx")) + ask_sz = _safe_float(ticker.get("askSz")) + bid_sz = _safe_float(ticker.get("bidSz")) + ask_estimated = False + + if ask is None and mark is not None and mark > 0: + ask = round_option_px(mark, tick_sz, "buy") + ask_estimated = True + if ask is None: + intrinsic = _intrinsic_px_per_unit(opt_type, strike, index_px) + if intrinsic is not None and intrinsic > 0: + ask = round_option_px(intrinsic, tick_sz, "buy") + ask_estimated = True + + if bid is None and mark is not None and mark > 0: + bid = round_option_px(mark, tick_sz, "sell") + if bid is None: + intrinsic = _intrinsic_px_per_unit(opt_type, strike, index_px) + if intrinsic is not None and intrinsic > 0: + bid = round_option_px(intrinsic, tick_sz, "sell") + + if ask_estimated: + ask_sz = None + + return { + "ask": ask, + "bid": bid, + "ask_sz": ask_sz, + "bid_sz": bid_sz, + "mark_px": mark, + "ask_estimated": ask_estimated, + } + + def _fetch_book_bid_ask(ex: ccxt.okx, inst_id: str) -> tuple[float | None, float | None]: bid, ask, _, _ = _fetch_book_top(ex, inst_id) return bid, ask @@ -344,11 +399,18 @@ def build_option_chain( continue inst_id = str(meta.get("instId") or "") t = tickers.get(inst_id) or {} - ask = _safe_float(t.get("askPx")) - bid = _safe_float(t.get("bidPx")) - mark = _safe_float(t.get("markPx")) - ask_sz = _safe_float(t.get("askSz")) - bid_sz = _safe_float(t.get("bidSz")) + q = _resolve_chain_quote( + ticker=t, + meta=meta, + opt_type=opt_type, + strike=strike, + index_px=idx, + ) + ask = q["ask"] + bid = q["bid"] + mark = q["mark_px"] + ask_sz = q["ask_sz"] + bid_sz = q["bid_sz"] if ask is None and bid is None and mark is None: continue expiry_be = expiry_breakeven_from_ask( @@ -370,6 +432,7 @@ def build_option_chain( "ask_sz": ask_sz, "bid_sz": bid_sz, "mark_px": mark, + "ask_estimated": q["ask_estimated"], "expiry_be_px": expiry_be, "dist_expiry_be": idx_distance_to_be(idx, expiry_be), "moneyness": mny, diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 747e5b3..06a5386 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -7,7 +7,7 @@

期权下单

-

报价单位为每 1 ETH/BTC;1 张 = 0.01。卖一/买一列为 价格/张数。链展示近 14 日到期,标注实值/虚值;到期平衡按卖一预估(无卖一按标记价)。资金划转与 USDT/USDC 兑换见「系统设置 → 期权设置」。

+

报价单位为每 1 ETH/BTC;1 张 = 0.01。卖一/买一列为 价格/张数;卖一无挂单时以标记价估算并标 ~。链展示近 14 日到期,标注实值/虚值;到期平衡按卖一预估(无卖一按标记价)。资金划转与 USDT/USDC 兑换见「系统设置 → 期权设置」。

@@ -136,4 +136,4 @@
- + diff --git a/tests/test_options_pricing.py b/tests/test_options_pricing.py index d6b3dfa..d9b803f 100644 --- a/tests/test_options_pricing.py +++ b/tests/test_options_pricing.py @@ -90,6 +90,21 @@ def test_estimate_expiry_profit_at_index(): assert p2 == round(-12.2 * 0.01, 4) +def test_resolve_chain_quote_estimated_ask(): + from lib.exchange.okx_options_lib import _resolve_chain_quote + + q = _resolve_chain_quote( + ticker={"bidPx": "0.2", "bidSz": "3500"}, + meta={"tickSz": "0.2"}, + opt_type="C", + strike=1650, + index_px=1776, + ) + assert q["ask_estimated"] is True + assert q["ask"] is not None + assert q["ask"] >= 120 + + def test_format_quote_liquidity(): from lib.options.options_pricing_lib import format_quote_liquidity