From 3570a6900e934befcae89cae8c04dcac97de294d Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 7 Jul 2026 16:10:03 +0800 Subject: [PATCH] Add OKX options expiry and close breakeven to hub monitor and dashboard. Expose bePx-based expiry balance and mark-to-close breakeven on positions so monitor and dashboard can show both labels per contract. Co-authored-by: Cursor --- lib/common/static/options_panel.js | 3 + lib/exchange/okx_options_lib.py | 34 ++++++++++- lib/options/options_pricing_lib.py | 72 +++++++++++++++++++++++ manual_trading_hub/hub_ai/context.py | 15 ++++- manual_trading_hub/static/app.js | 6 +- manual_trading_hub/static/dashboard.css | 18 ++++++ manual_trading_hub/static/dashboard.js | 53 +++++++++++++++-- tests/test_options_pricing.py | 77 +++++++++++++++++++++++++ 8 files changed, 269 insertions(+), 9 deletions(-) diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 5dabdc0..c051f2b 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -324,6 +324,9 @@ '
' + '
开仓均价' + fmt(p.avg_px, 4) + "
" + '
标记价' + fmt(p.mark_px, 4) + "
" + + '
指数价' + fmt(p.idx_px, 0) + "
" + + '
到期平衡' + fmt(p.expiry_be_px, 0) + "
" + + '
平掉回本' + fmt(p.close_be_px, 0) + "
" + '
浮盈亏' + fmt(p.upl, 4) + "
" + '
收益率' + (p.upl_ratio_pct != null ? p.upl_ratio_pct + "%" : "—") + "
" + diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 60cdb95..21f79d3 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -666,23 +666,53 @@ def transfer_main_sub_account( def format_position_row(pos: dict[str, Any], ct_mult: float = 0.01) -> dict[str, Any]: + from lib.options.options_pricing_lib import ( + close_breakeven_idx, + expiry_breakeven_px, + idx_distance_to_be, + ) + sheets = _safe_float(pos.get("pos")) or 0.0 avg = _safe_float(pos.get("avgPx")) mark = _safe_float(pos.get("markPx")) upl = _safe_float(pos.get("upl")) upl_ratio = _safe_float(pos.get("uplRatio")) + idx_px = _safe_float(pos.get("idxPx")) + opt_type = pos.get("optType") + strike = _safe_float(pos.get("stk")) + delta_pa = _safe_float(pos.get("deltaPA")) + expiry_be = expiry_breakeven_px( + opt_type=str(opt_type or ""), + strike=strike, + avg_px=avg, + be_px_api=_safe_float(pos.get("bePx")), + ) + close_be = close_breakeven_idx( + opt_type=str(opt_type or ""), + idx_px=idx_px, + mark_px=mark, + avg_px=avg, + delta_pa=delta_pa, + pos=sheets, + ct_mult=ct_mult, + ) return { "inst_id": pos.get("instId"), "pos": sheets, "eth_amount": round(abs(sheets) * ct_mult, 8), "avg_px": avg, "mark_px": mark, + "idx_px": idx_px, "upl": upl, "upl_ratio_pct": round(upl_ratio * 100, 2) if upl_ratio is not None else None, "exp_time": pos.get("expTime"), - "opt_type": pos.get("optType"), - "strike": _safe_float(pos.get("stk")), + "opt_type": opt_type, + "strike": strike, "avail_pos": _safe_float(pos.get("availPos")), + "expiry_be_px": expiry_be, + "close_be_px": close_be, + "dist_expiry_be": idx_distance_to_be(idx_px, expiry_be), + "dist_close_be": idx_distance_to_be(idx_px, close_be), "raw": pos, } diff --git a/lib/options/options_pricing_lib.py b/lib/options/options_pricing_lib.py index 6d1421b..ea5945e 100644 --- a/lib/options/options_pricing_lib.py +++ b/lib/options/options_pricing_lib.py @@ -132,3 +132,75 @@ def option_moneyness(*, opt_type: str, strike: float, index_px: float) -> str: def option_moneyness_label(moneyness: str) -> str: return {"itm": "实值", "otm": "虚值", "atm": "平值"}.get((moneyness or "").lower(), "") + + +def expiry_breakeven_px( + *, + opt_type: str, + strike: float | None, + avg_px: float | None, + be_px_api: float | None = None, +) -> float | None: + """到期平衡点:持有至到期时标的指数盈亏为 0 的价格。优先 OKX bePx。""" + if be_px_api is not None and be_px_api > 0: + return round(float(be_px_api), 2) + if strike is None or avg_px is None: + return None + o = (opt_type or "").upper() + if o == "C": + return round(strike + avg_px, 2) + if o == "P": + return round(strike - avg_px, 2) + return None + + +def close_breakeven_idx( + *, + opt_type: str, + idx_px: float | None, + mark_px: float | None, + avg_px: float | None, + delta_pa: float | None = None, + pos: float = 0, + ct_mult: float = 0.01, +) -> float | None: + """ + 平掉回本:标的指数达到该价位时,按标记价平仓近似盈亏为 0。 + 优先用 deltaPA 线性外推,否则用时间价值近似(适合短期轻度实值)。 + """ + if idx_px is None or mark_px is None or avg_px is None: + return None + eth_amt = abs(float(pos)) * float(ct_mult) + if eth_amt > 1e-12 and delta_pa is not None and abs(float(delta_pa)) > 1e-12: + slope = float(delta_pa) / eth_amt + return round(float(idx_px) + (float(avg_px) - float(mark_px)) / slope, 2) + o = (opt_type or "").upper() + if o == "C": + return round(float(idx_px) + float(avg_px) - float(mark_px), 2) + if o == "P": + return round(float(idx_px) + float(mark_px) - float(avg_px), 2) + return None + + +def idx_distance_to_be(idx_px: float | None, be_px: float | None) -> float | None: + """指数距平衡点(正=指数需上涨才到平衡点)。""" + if idx_px is None or be_px is None: + return None + return round(float(be_px) - float(idx_px), 2) + + +def format_options_breakeven_line( + *, + expiry_be_px: float | None, + close_be_px: float | None, + idx_px: float | None = None, +) -> str: + """持仓摘要行:到期平衡 / 平掉回本。""" + parts: list[str] = [] + if expiry_be_px is not None: + parts.append(f"到期平衡{expiry_be_px:.0f}") + if close_be_px is not None: + parts.append(f"平掉回本{close_be_px:.0f}") + if idx_px is not None and parts: + return " ".join(parts) + f"(指数{idx_px:.0f})" + return " ".join(parts) diff --git a/manual_trading_hub/hub_ai/context.py b/manual_trading_hub/hub_ai/context.py index 2dbe32f..0ac6b9d 100644 --- a/manual_trading_hub/hub_ai/context.py +++ b/manual_trading_hub/hub_ai/context.py @@ -989,17 +989,29 @@ def format_dashboard_account_detail(ac: dict) -> dict[str, Any]: } ) opt_snap = ac.get("options_snapshot") if isinstance(ac.get("options_snapshot"), dict) else {} + options_positions: list[dict[str, Any]] = [] if opt_snap.get("ok") is not False and opt_snap.get("enabled") is not False: + from lib.options.options_pricing_lib import format_options_breakeven_line + for p in opt_snap.get("positions") or []: if not isinstance(p, dict): continue + options_positions.append(p) inst = p.get("inst_id") or "?" opt_type = (p.get("opt_type") or "").upper() label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else opt_type or "OPT" upl = p.get("upl") + be_line = format_options_breakeven_line( + expiry_be_px=p.get("expiry_be_px"), + close_be_px=p.get("close_be_px"), + idx_px=p.get("idx_px"), + ) + text = f"期权 {inst} {label}" + if be_line: + text = f"{text} {be_line}" line: dict[str, Any] = { "kind": "options", - "text": f"期权 {inst} {label}", + "text": text, } if upl is not None: try: @@ -1016,6 +1028,7 @@ def format_dashboard_account_detail(ac: dict) -> dict[str, Any]: "rolls": len(mon.get("rolls") or []), }, "position_lines": position_lines, + "options_positions": options_positions, "issues": issues, } diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index e54d496..5e06497 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -3475,7 +3475,7 @@ function renderOptionsPositionsTable(pos) { if (!pos.length) return '
暂无期权持仓
'; let html = '
'; - html += ""; + html += ""; html += ""; pos.forEach((p) => { const optType = (p.opt_type || "").toUpperCase() === "C" ? "Call" : (p.opt_type || "").toUpperCase() === "P" ? "Put" : (p.opt_type || "—"); @@ -3483,7 +3483,9 @@ - + + + `; diff --git a/manual_trading_hub/static/dashboard.css b/manual_trading_hub/static/dashboard.css index be719bd..89b4e2a 100644 --- a/manual_trading_hub/static/dashboard.css +++ b/manual_trading_hub/static/dashboard.css @@ -332,6 +332,24 @@ body.hub-page-dashboard .page#page-dashboard { border-top: 1px dashed color-mix(in srgb, var(--dash-card-border) 80%, transparent); } +.dash-options-block { + margin-top: 8px; +} + +.dash-options-block .dash-ac-section-label { + margin-bottom: 4px; +} + +.dash-options-table-wrap { + overflow-x: auto; +} + +.dash-options-table th, +.dash-options-table td { + font-size: 0.68rem; + white-space: nowrap; +} + .dash-ac-metrics-3col .dash-ac-metric { text-align: center; } diff --git a/manual_trading_hub/static/dashboard.js b/manual_trading_hub/static/dashboard.js index 1218a26..c9df236 100644 --- a/manual_trading_hub/static/dashboard.js +++ b/manual_trading_hub/static/dashboard.js @@ -118,9 +118,50 @@ return chips; } + function renderDashboardOptionsTable(positions) { + const pos = Array.isArray(positions) ? positions : []; + if (!pos.length) return ""; + const rows = pos + .map((p) => { + const optType = + (p.opt_type || "").toUpperCase() === "C" + ? "Call" + : (p.opt_type || "").toUpperCase() === "P" + ? "Put" + : p.opt_type || "—"; + return ` + + + + + + + `; + }) + .join(""); + return `
+ +
+
合约类型张数标记浮盈浮盈%合约类型张数指数到期平衡平掉回本浮盈浮盈%
${esc(shortOptionsInst(p.inst_id))} ${esc(optType)} ${esc(p.pos)}${fmt(p.mark_px, 4)}${p.idx_px != null ? fmt(p.idx_px, 0) : "—"}${p.expiry_be_px != null ? fmt(p.expiry_be_px, 0) : "—"}${p.close_be_px != null ? fmt(p.close_be_px, 0) : "—"} ${fmt(p.upl, 4)} ${p.upl_ratio_pct != null ? esc(p.upl_ratio_pct) + "%" : "—"}
${esc(shortDashInst(p.inst_id))}${esc(optType)}${p.idx_px != null ? fmt(p.idx_px, 0) : "—"}${p.expiry_be_px != null ? fmt(p.expiry_be_px, 0) : "—"}${p.close_be_px != null ? fmt(p.close_be_px, 0) : "—"}${p.upl != null ? pnlSigned(p.upl, 2) : "—"}
+ + + + ${rows} +
合约类型指数到期平衡平掉回本浮盈
+
+
`; + } + + function shortDashInst(instId) { + const s = String(instId || ""); + if (s.length <= 18) return s; + return s.slice(0, 8) + "…" + s.slice(-6); + } + function renderAccountDetail(ac) { const counts = (ac && ac.monitor_counts) || {}; const positions = Array.isArray(ac && ac.position_lines) ? ac.position_lines : []; + const optionsPositions = Array.isArray(ac && ac.options_positions) ? ac.options_positions : []; const issues = Array.isArray(ac && ac.issues) ? ac.issues : []; const exId = ac && ac.id != null ? String(ac.id) : ""; const chips = renderMonitorCountChips(counts); @@ -134,8 +175,11 @@ ? `
${chips.join("")}${expandBtn}
` : ""; let posHtml = ""; - if (positions.length) { - posHtml = positions + const perpLines = ac && ac.options_layout + ? positions.filter((ln) => (ln && ln.kind) !== "options") + : positions; + if (perpLines.length) { + posHtml = perpLines .map((ln) => { const text = esc((ln && ln.text) || ""); if (ln.pnl != null && Number.isFinite(Number(ln.pnl))) { @@ -149,13 +193,14 @@ return `
${text}
`; }) .join(""); - } else if (!chips.length && !issues.length) { + } else if (!chips.length && !issues.length && !(ac && ac.options_layout && optionsPositions.length)) { posHtml = `
无持仓
`; } const issueHtml = issues .map((text) => `
${esc(text)}
`) .join(""); - return `
${monitorRow}
${posHtml}
${issueHtml}
`; + const optionsHtml = ac && ac.options_layout ? renderDashboardOptionsTable(optionsPositions) : ""; + return `
${monitorRow}
${posHtml}
${optionsHtml}${issueHtml}
`; } function bindDashboardExpand() { diff --git a/tests/test_options_pricing.py b/tests/test_options_pricing.py index 4cf1f0b..5add479 100644 --- a/tests/test_options_pricing.py +++ b/tests/test_options_pricing.py @@ -77,3 +77,80 @@ def test_calc_order_size_too_small(): budget_cap=10, ) assert r["ok"] is False + + +def test_expiry_breakeven_from_api(): + from lib.options.options_pricing_lib import expiry_breakeven_px + + assert expiry_breakeven_px( + opt_type="C", strike=3500, avg_px=15.6, be_px_api=3516.2 + ) == 3516.2 + + +def test_expiry_breakeven_call_put(): + from lib.options.options_pricing_lib import expiry_breakeven_px + + assert expiry_breakeven_px(opt_type="C", strike=3500, avg_px=15.6) == 3515.6 + assert expiry_breakeven_px(opt_type="P", strike=3500, avg_px=15.6) == 3484.4 + + +def test_close_breakeven_at_mark_equals_avg(): + from lib.options.options_pricing_lib import close_breakeven_idx + + assert close_breakeven_idx( + opt_type="C", idx_px=3480, mark_px=15.6, avg_px=15.6 + ) == 3480.0 + assert close_breakeven_idx( + opt_type="P", idx_px=3480, mark_px=15.6, avg_px=15.6 + ) == 3480.0 + + +def test_close_breakeven_with_delta(): + from lib.options.options_pricing_lib import close_breakeven_idx + + # mark below avg, delta 0.5 ETH on 0.5 ETH position -> slope 1 + be = close_breakeven_idx( + opt_type="C", + idx_px=3480, + mark_px=14.6, + avg_px=15.6, + delta_pa=0.5, + pos=50, + ct_mult=0.01, + ) + assert be == 3481.0 + + +def test_format_options_breakeven_line(): + from lib.options.options_pricing_lib import format_options_breakeven_line + + s = format_options_breakeven_line( + expiry_be_px=3515.6, close_be_px=3498.0, idx_px=3480.0 + ) + assert "到期平衡3516" in s + assert "平掉回本3498" in s + assert "指数3480" in s + + +def test_format_position_row_breakeven(): + from lib.exchange.okx_options_lib import format_position_row + + row = format_position_row( + { + "instId": "ETH-USD_UM-260703-1800-C", + "pos": "50", + "avgPx": "15.6", + "markPx": "16.2", + "idxPx": "3480", + "bePx": "3515.6", + "optType": "C", + "stk": "3500", + "deltaPA": "0.45", + "upl": "0.3", + "uplRatio": "0.02", + } + ) + assert row["expiry_be_px"] == 3515.6 + assert row["idx_px"] == 3480.0 + assert row["close_be_px"] is not None + assert row["dist_expiry_be"] == 35.6