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