From 99f13817f877ce52fdd25c3570151548fbe2b23d Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 7 Jul 2026 16:21:22 +0800 Subject: [PATCH] Add pre-buy expiry breakeven columns to options chain table. Show estimated expiry balance and distance from index in the chain list and order panel using ask price before opening a position. Co-authored-by: Cursor --- lib/common/static/instance_theme.css | 12 +++++++++ lib/common/static/options_panel.js | 30 +++++++++++++++++++-- lib/exchange/okx_options_lib.py | 34 +++++++++++++++++++++--- lib/options/options_pricing_lib.py | 12 +++++++++ lib/options/templates/options_panel.html | 8 ++++-- tests/test_options_pricing.py | 8 ++++++ 6 files changed, 96 insertions(+), 8 deletions(-) diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 2174310..ad239bb 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -2588,6 +2588,18 @@ html[data-theme="light"] .settings-side-export-label { .options-page-wrap .options-strike-table code { font-size: 0.66rem; } +.opt-be-dist-up { + color: #5ee89a; +} +.opt-be-dist-down { + color: #ff8a8a; +} +html[data-theme="light"] .opt-be-dist-up { + color: #0d7a45; +} +html[data-theme="light"] .opt-be-dist-down { + color: #c62828; +} .options-chain-toolbar .btn-secondary.active, .opt-uly-btn.active, .opt-type-btn.active { diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index c051f2b..16cf88b 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -149,6 +149,21 @@ "指数 " + state.underlying + " ≈ " + fmt(idx, 2) + " · 实值=价内 · 虚值=价外"; } + function fmtDist(v) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + const n = Number(v); + const sign = n > 0 ? "+" : ""; + return sign + n.toFixed(1); + } + + function distBeClass(v) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return ""; + const n = Number(v); + if (n > 0) return "opt-be-dist-up"; + if (n < 0) return "opt-be-dist-down"; + return ""; + } + function renderStrikes() { const tbody = document.getElementById("opt-strike-tbody"); const expMs = document.getElementById("opt-exp-select").value; @@ -156,7 +171,7 @@ parkOrderPanel(); tbody.innerHTML = ""; if (!expMs || !state.chain) { - tbody.innerHTML = '请选择到期日'; + tbody.innerHTML = '请选择到期日'; state.selectedInst = null; return; } @@ -168,7 +183,7 @@ return c.opt_type === state.optType; }); if (!list.length) { - tbody.innerHTML = '该到期日暂无报价'; + tbody.innerHTML = '该到期日暂无报价'; state.selectedInst = null; return; } @@ -184,6 +199,8 @@ "" + c.inst_id + "" + "" + fmt(c.ask, 4) + "" + "" + fmt(c.bid, 4) + "" + + "" + (c.expiry_be_px != null ? fmt(c.expiry_be_px, 0) : "—") + "" + + '' + fmtDist(c.dist_expiry_be) + "" + '' + ' ' + '' + @@ -216,6 +233,15 @@ document.getElementById("opt-order-sheets").textContent = sz.sheets != null ? sz.sheets : "—"; document.getElementById("opt-order-eth").textContent = sz.eth_amount != null ? sz.eth_amount : "—"; document.getElementById("opt-order-premium").textContent = sz.total_premium != null ? fmt(sz.total_premium, 4) + " USDC" : "—"; + const beEl = document.getElementById("opt-order-expiry-be"); + const distEl = document.getElementById("opt-order-dist-be"); + if (beEl) { + beEl.textContent = d.expiry_be_px != null ? fmt(d.expiry_be_px, 0) : "—"; + } + if (distEl) { + distEl.textContent = fmtDist(d.dist_expiry_be); + distEl.className = "v " + distBeClass(d.dist_expiry_be); + } const msgEl = document.getElementById("opt-order-msg"); if (!d.ok) { msgEl.textContent = d.msg || "报价失败"; diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 21f79d3..9ecd860 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -9,7 +9,13 @@ from typing import Any, Callable import ccxt -from lib.options.options_pricing_lib import is_shallow_itm, option_moneyness, option_moneyness_label +from lib.options.options_pricing_lib import ( + expiry_breakeven_from_ask, + idx_distance_to_be, + is_shallow_itm, + option_moneyness, + option_moneyness_label, +) _OKX_OPTION_ERR_ZH: dict[str, str] = { "51018": "期权账户不能持有净空头头寸", @@ -331,8 +337,15 @@ def build_option_chain( t = tickers.get(inst_id) or {} ask = _safe_float(t.get("askPx")) bid = _safe_float(t.get("bidPx")) - if ask is None and bid is None: + mark = _safe_float(t.get("markPx")) + if ask is None and bid is None and mark is None: continue + expiry_be = expiry_breakeven_from_ask( + opt_type=opt_type, + strike=strike, + ask_px=ask, + mark_px=mark, + ) mny = option_moneyness(opt_type=opt_type, strike=strike, index_px=idx) exp_key = str(exp_ms) expiries.setdefault(exp_key, []).append( @@ -343,6 +356,9 @@ def build_option_chain( "exp_time": exp_ms, "ask": ask, "bid": bid, + "mark_px": mark, + "expiry_be_px": expiry_be, + "dist_expiry_be": idx_distance_to_be(idx, expiry_be), "moneyness": mny, "moneyness_label": option_moneyness_label(mny), "ct_mult": _safe_float(meta.get("ctMult")) or 0.01, @@ -383,6 +399,14 @@ def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]: bid = round_option_px(mark, tick_sz, "sell") uly = str(meta.get("uly") or "") idx = fetch_index_price(ex, uly) + opt_type = meta.get("optType") + strike = _safe_float(meta.get("stk")) + expiry_be = expiry_breakeven_from_ask( + opt_type=str(opt_type or ""), + strike=strike, + ask_px=ask, + mark_px=mark, + ) return { "ok": True, "inst_id": inst_id, @@ -391,11 +415,13 @@ def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]: "bid": bid, "mark": mark, "index_px": idx, + "expiry_be_px": expiry_be, + "dist_expiry_be": idx_distance_to_be(idx, expiry_be), "ct_mult": _safe_float(meta.get("ctMult")) or 0.01, "min_sz": int(_safe_float(meta.get("minSz")) or 1), "tick_sz": tick_sz, - "strike": _safe_float(meta.get("stk")), - "opt_type": meta.get("optType"), + "strike": strike, + "opt_type": opt_type, "exp_time": meta.get("expTime"), } except Exception as e: diff --git a/lib/options/options_pricing_lib.py b/lib/options/options_pricing_lib.py index ea5945e..2a528e3 100644 --- a/lib/options/options_pricing_lib.py +++ b/lib/options/options_pricing_lib.py @@ -134,6 +134,18 @@ def option_moneyness_label(moneyness: str) -> str: return {"itm": "实值", "otm": "虚值", "atm": "平值"}.get((moneyness or "").lower(), "") +def expiry_breakeven_from_ask( + *, + opt_type: str, + strike: float | None, + ask_px: float | None, + mark_px: float | None = None, +) -> float | None: + """买入前预估到期平衡:权利金按卖一;无卖一时回退标记价。""" + prem = ask_px if ask_px is not None and ask_px > 0 else mark_px + return expiry_breakeven_px(opt_type=opt_type, strike=strike, avg_px=prem) + + def expiry_breakeven_px( *, opt_type: str, diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 649ba4a..1d9ab1e 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 兑换见「系统设置 → 期权设置」。

@@ -26,11 +26,13 @@ 合约 卖一 买一 + 到期平衡 + 距平衡 操作 - 请选择到期日 + 请选择到期日
@@ -43,6 +45,8 @@
张数
ETH/BTC 数量
预估权利金
+
到期平衡
+
距平衡
diff --git a/tests/test_options_pricing.py b/tests/test_options_pricing.py index 5add479..b830eb7 100644 --- a/tests/test_options_pricing.py +++ b/tests/test_options_pricing.py @@ -67,6 +67,14 @@ def test_option_moneyness(): assert option_moneyness_label("otm") == "虚值" +def test_expiry_breakeven_from_ask(): + from lib.options.options_pricing_lib import expiry_breakeven_from_ask + + assert expiry_breakeven_from_ask(opt_type="C", strike=1760, ask_px=15.6) == 1775.6 + assert expiry_breakeven_from_ask(opt_type="P", strike=1760, ask_px=15.6) == 1744.4 + assert expiry_breakeven_from_ask(opt_type="C", strike=1760, ask_px=None, mark_px=14.2) == 1774.2 + + def test_calc_order_size_too_small(): r = calc_order_size( quote_per_unit=2000.0,