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 <cursoragent@cursor.com>
This commit is contained in:
@@ -324,6 +324,9 @@
|
||||
'<div class="pos-grid">' +
|
||||
'<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + fmt(p.avg_px, 4) + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + fmt(p.mark_px, 4) + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">到期平衡</span><span class="pos-value">' + fmt(p.expiry_be_px, 0) + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">平掉回本</span><span class="pos-value">' + fmt(p.close_be_px, 0) + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">浮盈亏</span><span class="pos-value ' + uplCls + '">' + fmt(p.upl, 4) + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
|
||||
(p.upl_ratio_pct != null ? p.upl_ratio_pct + "%" : "—") + "</span></div>" +
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -3475,7 +3475,7 @@
|
||||
function renderOptionsPositionsTable(pos) {
|
||||
if (!pos.length) return '<div class="empty-hint">暂无期权持仓</div>';
|
||||
let html = '<div class="table-wrap hub-options-table-wrap"><table class="hub-options-table"><thead><tr>';
|
||||
html += "<th>合约</th><th>类型</th><th>张数</th><th>标记</th><th>浮盈</th><th>浮盈%</th>";
|
||||
html += "<th>合约</th><th>类型</th><th>张数</th><th>指数</th><th>到期平衡</th><th>平掉回本</th><th>浮盈</th><th>浮盈%</th>";
|
||||
html += "</tr></thead><tbody>";
|
||||
pos.forEach((p) => {
|
||||
const optType = (p.opt_type || "").toUpperCase() === "C" ? "Call" : (p.opt_type || "").toUpperCase() === "P" ? "Put" : (p.opt_type || "—");
|
||||
@@ -3483,7 +3483,9 @@
|
||||
<td><code class="hub-options-inst" title="${esc(p.inst_id || "")}">${esc(shortOptionsInst(p.inst_id))}</code></td>
|
||||
<td>${esc(optType)}</td>
|
||||
<td>${esc(p.pos)}</td>
|
||||
<td>${fmt(p.mark_px, 4)}</td>
|
||||
<td>${p.idx_px != null ? fmt(p.idx_px, 0) : "—"}</td>
|
||||
<td>${p.expiry_be_px != null ? fmt(p.expiry_be_px, 0) : "—"}</td>
|
||||
<td>${p.close_be_px != null ? fmt(p.close_be_px, 0) : "—"}</td>
|
||||
<td class="${pnlCls(p.upl)}">${fmt(p.upl, 4)}</td>
|
||||
<td class="${pnlCls(p.upl)}">${p.upl_ratio_pct != null ? esc(p.upl_ratio_pct) + "%" : "—"}</td>
|
||||
</tr>`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 `<tr>
|
||||
<td title="${esc(p.inst_id || "")}">${esc(shortDashInst(p.inst_id))}</td>
|
||||
<td>${esc(optType)}</td>
|
||||
<td>${p.idx_px != null ? fmt(p.idx_px, 0) : "—"}</td>
|
||||
<td>${p.expiry_be_px != null ? fmt(p.expiry_be_px, 0) : "—"}</td>
|
||||
<td>${p.close_be_px != null ? fmt(p.close_be_px, 0) : "—"}</td>
|
||||
<td class="${pnlClass(p.upl)}">${p.upl != null ? pnlSigned(p.upl, 2) : "—"}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="dash-options-block">
|
||||
<div class="dash-ac-section-label">期权持仓</div>
|
||||
<div class="dash-table-wrap dash-options-table-wrap">
|
||||
<table class="dash-table dash-options-table">
|
||||
<thead><tr>
|
||||
<th>合约</th><th>类型</th><th>指数</th><th>到期平衡</th><th>平掉回本</th><th>浮盈</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 @@
|
||||
? `<div class="dash-ac-monitor-row">${chips.join("")}${expandBtn}</div>`
|
||||
: "";
|
||||
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 `<div class="dash-ac-remark-line dash-ac-remark-pos">${text}</div>`;
|
||||
})
|
||||
.join("");
|
||||
} else if (!chips.length && !issues.length) {
|
||||
} else if (!chips.length && !issues.length && !(ac && ac.options_layout && optionsPositions.length)) {
|
||||
posHtml = `<div class="dash-ac-remark-line dash-ac-remark-empty">无持仓</div>`;
|
||||
}
|
||||
const issueHtml = issues
|
||||
.map((text) => `<div class="dash-ac-remark-line dash-ac-remark-issue">${esc(text)}</div>`)
|
||||
.join("");
|
||||
return `<div class="dash-ac-remark">${monitorRow}<div class="dash-ac-positions">${posHtml}</div>${issueHtml}</div>`;
|
||||
const optionsHtml = ac && ac.options_layout ? renderDashboardOptionsTable(optionsPositions) : "";
|
||||
return `<div class="dash-ac-remark">${monitorRow}<div class="dash-ac-positions">${posHtml}</div>${optionsHtml}${issueHtml}</div>`;
|
||||
}
|
||||
|
||||
function bindDashboardExpand() {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user