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 @@
${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) + "%" : "—"} |
`;
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 `
+ | ${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) : "—"} |
+
`;
+ })
+ .join("");
+ return `
+
期权持仓
+
+
+
+ | 合约 | 类型 | 指数 | 到期平衡 | 平掉回本 | 浮盈 |
+
+ ${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 ``;
})
.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) => ``)
.join("");
- return ``;
+ const optionsHtml = ac && ac.options_layout ? renderDashboardOptionsTable(optionsPositions) : "";
+ return ``;
}
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