diff --git a/lib/common/static/options_position_cards.js b/lib/common/static/options_position_cards.js
new file mode 100644
index 0000000..3ff8350
--- /dev/null
+++ b/lib/common/static/options_position_cards.js
@@ -0,0 +1,135 @@
+(function (global) {
+ "use strict";
+
+ function fmt(v, d) {
+ if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
+ return Number(v).toFixed(d == null ? 2 : d);
+ }
+
+ function fmtDisplay(v, fallback) {
+ if (v !== null && v !== undefined && String(v).trim() !== "") return String(v);
+ if (fallback !== undefined) return fmtDisplay(fallback);
+ return "—";
+ }
+
+ function fmtOptionPx(v, tickSz) {
+ if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
+ const n = Number(v);
+ const tick = Number(tickSz);
+ if (!tickSz || Number.isNaN(tick) || tick <= 0) return String(n);
+ let decimals = 0;
+ if (tick < 1) decimals = Math.max(0, -Math.round(Math.log10(tick)));
+ else if (String(tick).indexOf(".") >= 0) decimals = String(tick).split(".")[1].length;
+ return n.toFixed(decimals).replace(/\.?0+$/, "") || "0";
+ }
+
+ function fmtUsdc(v) {
+ if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
+ return Number(v).toFixed(2);
+ }
+
+ function optTypeLabel(t) {
+ return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call";
+ }
+
+ function pnlCls(upl, hub) {
+ if (upl > 0) return hub ? "pnl-pos" : "pos-pnl-profit";
+ if (upl < 0) return hub ? "pnl-neg" : "pos-pnl-loss";
+ return "";
+ }
+
+ function fmtCloseLevels(preview, tickSz) {
+ const levels = ((preview && preview.levels) || []).slice(0, 5);
+ if (!levels.length) return "—";
+ return levels.map(function (x, idx) {
+ const levelNo = x.level != null ? x.level : idx + 1;
+ return "买" + levelNo + " " + fmtOptionPx(x.px, tickSz);
+ }).join(" · ");
+ }
+
+ function fmtClosePreview(preview, premiumPaid, hub) {
+ if (!preview || preview.total_received == null) return "—";
+ const recvTxt = fmtUsdc(preview.total_received);
+ let cls = "";
+ const prem = Number(premiumPaid);
+ const recv = Number(preview.total_received);
+ if (!Number.isNaN(prem) && !Number.isNaN(recv)) {
+ if (recv > prem) cls = " " + pnlCls(1, hub);
+ else if (recv < prem) cls = " " + pnlCls(-1, hub);
+ }
+ return '' + recvTxt + " USDC";
+ }
+
+ function expiryCdHtml(expMs) {
+ const ms = expMs != null && expMs !== "" ? String(expMs) : "";
+ if (!ms) return "—";
+ return '—';
+ }
+
+ function renderCardInner(p, opts) {
+ opts = opts || {};
+ const hub = !!opts.hub;
+ const readOnly = !!opts.readOnly;
+ const upl = p.upl;
+ const uplCls = pnlCls(upl, hub);
+ const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long";
+ const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time;
+ const expAttr = expMs != null && expMs !== "" ? String(expMs) : "";
+ const closePreview = p.close_preview || {};
+ const tickSz = p.tick_sz;
+ const premTxt = fmtDisplay(p.premium_paid_fmt, p.premium_paid != null ? fmtUsdc(p.premium_paid) : null);
+ const avgTxt = fmtDisplay(p.avg_px_fmt, fmtOptionPx(p.avg_px, tickSz));
+ const markTxt = fmtDisplay(p.mark_px_fmt, fmtOptionPx(p.mark_px, tickSz));
+ let headActions = "";
+ if (!readOnly) {
+ const closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos;
+ headActions =
+ '
' +
+ '' +
+ "
";
+ }
+ return (
+ '' +
+ '
' + (p.inst_id || "") + "" +
+ '' + optTypeLabel(p.opt_type) + "
" +
+ headActions +
+ "
" +
+ '' +
+ '行权价: ' + fmt(p.strike, 0) + "" +
+ '张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "" +
+ (expAttr
+ ? '到期倒计时: ' + expiryCdHtml(expAttr) + ""
+ : "") +
+ "
" +
+ '' +
+ '
权利金' + premTxt + " USDC
" +
+ '
开仓均价' + avgTxt + "
" +
+ '
标记价' + markTxt + "
" +
+ '
指数价' + fmt(p.idx_px, 0) + "
" +
+ '
到期平衡' + fmt(p.expiry_be_px, 0) + "
" +
+ '
平掉回本' + fmt(p.close_be_px, 0) + "
" +
+ '
浮盈亏' + fmt(p.upl, 2) + "
" +
+ '
收益率' +
+ (p.upl_ratio_pct != null ? fmt(p.upl_ratio_pct, 2) + "%" : "—") + "
" +
+ '
买盘深度' + fmtCloseLevels(closePreview, tickSz) + "
" +
+ '
按买盘回收' + fmtClosePreview(closePreview, p.premium_paid, hub) + "
" +
+ "
"
+ );
+ }
+
+ function renderCard(p, opts) {
+ opts = opts || {};
+ const hub = !!opts.hub;
+ const extraCls = hub ? " hub-pos-card hub-opt-pos-card" : " opt-pos-card";
+ return (
+ '"
+ );
+ }
+
+ global.OptionsPositionCards = {
+ renderCardInner: renderCardInner,
+ renderCard: renderCard,
+ };
+})(typeof window !== "undefined" ? window : globalThis);
diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py
index 8f49b77..1d12d79 100644
--- a/lib/options/options_hub_lib.py
+++ b/lib/options/options_hub_lib.py
@@ -24,10 +24,12 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
if not ok:
return {"ok": False, "enabled": True, "msg": reason or "期权 API 未配置"}
try:
+ from lib.options.options_positions_lib import build_display_option_positions
+
raw = cfg["fetch_option_positions"](ex)
if raw is None:
return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"}
- positions = [cfg["format_position_row"](p) for p in raw]
+ positions = build_display_option_positions(cfg, ex, raw)
upl_total = 0.0
has_upl = False
for p in positions:
diff --git a/lib/options/options_positions_lib.py b/lib/options/options_positions_lib.py
new file mode 100644
index 0000000..6942df6
--- /dev/null
+++ b/lib/options/options_positions_lib.py
@@ -0,0 +1,82 @@
+"""期权持仓展示(实例页 / 中控快照共用)."""
+from __future__ import annotations
+
+from typing import Any
+
+from lib.options.options_db import init_options_tables
+from lib.options.options_history_lib import enrich_position_row_display
+from lib.options.options_pricing_lib import estimate_close_by_bids
+
+
+def _safe_float(v: Any) -> float | None:
+ if v is None or v == "":
+ return None
+ try:
+ return float(v)
+ except (TypeError, ValueError):
+ return None
+
+
+def attach_close_preview(
+ cfg: dict[str, Any],
+ ex: Any,
+ row: dict[str, Any],
+ *,
+ sheets: int | None = None,
+ premium_paid: float | None = None,
+) -> dict[str, Any]:
+ inst_id = str(row.get("inst_id") or row.get("instId") or "").strip()
+ if not inst_id:
+ return row
+ ct_mult = float(row.get("ct_mult") or 0.01)
+ target_sheets = int(sheets) if sheets is not None else int(abs(_safe_float(row.get("pos")) or 0))
+ paid = premium_paid if premium_paid is not None else _safe_float(row.get("premium_paid"))
+ book = cfg["fetch_option_book_depth"](ex, inst_id, 5)
+ row["bid_depth"] = book.get("bids") or []
+ row["ask_depth"] = book.get("asks") or []
+ row["close_preview"] = estimate_close_by_bids(
+ row["bid_depth"],
+ target_sheets,
+ ct_mult=ct_mult,
+ premium_paid=paid,
+ )
+ return row
+
+
+def build_display_option_positions(
+ cfg: dict[str, Any],
+ ex: Any,
+ raw_positions: list[dict[str, Any]],
+) -> list[dict[str, Any]]:
+ """与实例 /api/options/positions 相同 enrichment + close_preview."""
+ meta_cache: dict[str, dict[str, Any] | None] = {}
+ rows: list[dict[str, Any]] = []
+ conn = cfg["get_db"]()
+ try:
+ init_options_tables(conn)
+ for p in raw_positions:
+ inst = str(p.get("instId") or "").strip()
+ premium_override = None
+ if inst:
+ rec = conn.execute(
+ """
+ SELECT premium_paid FROM options_trades
+ WHERE inst_id = ? AND status = 'open'
+ ORDER BY id DESC LIMIT 1
+ """,
+ (inst,),
+ ).fetchone()
+ if rec and rec["premium_paid"] is not None:
+ premium_override = float(rec["premium_paid"])
+ row = enrich_position_row_display(
+ cfg,
+ ex,
+ p,
+ meta_cache=meta_cache,
+ premium_override=premium_override,
+ )
+ attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid")))
+ rows.append(row)
+ finally:
+ conn.close()
+ return rows
diff --git a/lib/options/options_register.py b/lib/options/options_register.py
index 0689e2f..89621eb 100644
--- a/lib/options/options_register.py
+++ b/lib/options/options_register.py
@@ -216,22 +216,15 @@ def _attach_close_preview(
sheets: int | None = None,
premium_paid: float | None = None,
) -> dict[str, Any]:
- inst_id = str(row.get("inst_id") or row.get("instId") or "").strip()
- if not inst_id:
- return row
- ct_mult = float(row.get("ct_mult") or 0.01)
- target_sheets = int(sheets) if sheets is not None else int(abs(_safe_float(row.get("pos")) or 0))
- paid = premium_paid if premium_paid is not None else _safe_float(row.get("premium_paid"))
- book = cfg["fetch_option_book_depth"](ex, inst_id, 5)
- row["bid_depth"] = book.get("bids") or []
- row["ask_depth"] = book.get("asks") or []
- row["close_preview"] = estimate_close_by_bids(
- row["bid_depth"],
- target_sheets,
- ct_mult=ct_mult,
- premium_paid=paid,
+ from lib.options.options_positions_lib import attach_close_preview
+
+ return attach_close_preview(
+ cfg,
+ ex,
+ row,
+ sheets=sheets,
+ premium_paid=premium_paid,
)
- return row
_OPTIONS_SYNC_LOCK = threading.Lock()
diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py
index b7fa5be..fd8068c 100644
--- a/manual_trading_hub/hub.py
+++ b/manual_trading_hub/hub.py
@@ -773,6 +773,7 @@ _TRADE_STATS_CALENDAR_JS = _REPO_STATIC / "trade_stats_calendar.js"
_ACCOUNT_RISK_BADGE_CSS = _REPO_STATIC / "account_risk_badge.css"
_ACCOUNT_RISK_BADGE_JS = _REPO_STATIC / "account_risk_badge.js"
_OPTIONS_EXPIRY_COUNTDOWN_JS = _REPO_STATIC / "options_expiry_countdown.js"
+_OPTIONS_POSITION_CARDS_JS = _REPO_STATIC / "options_position_cards.js"
@app.get("/assets/account_risk_badge.css")
@@ -807,6 +808,16 @@ def hub_options_expiry_countdown_js():
)
+@app.get("/assets/options_position_cards.js")
+def hub_options_position_cards_js():
+ if not _OPTIONS_POSITION_CARDS_JS.is_file():
+ raise HTTPException(status_code=404, detail="options_position_cards.js not found")
+ return FileResponse(
+ str(_OPTIONS_POSITION_CARDS_JS),
+ media_type="application/javascript; charset=utf-8",
+ )
+
+
@app.get("/assets/ai_review_render.js")
def hub_ai_review_render_js():
"""与三所实例共用仓库根 static/ai_review_render.js(须在 /assets mount 之前注册)."""
diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css
index 5ee4390..6bf2244 100644
--- a/manual_trading_hub/static/app.css
+++ b/manual_trading_hub/static/app.css
@@ -1970,6 +1970,35 @@ body.market-chart-fs-open {
margin-bottom: 12px;
}
+.exchange-fullscreen .hub-opt-pos-list {
+ margin-bottom: 14px;
+}
+
+.hub-opt-pos-card .pos-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.hub-opt-pos-card .opt-pos-cell--depth {
+ grid-column: span 2;
+}
+
+.hub-opt-pos-card .opt-bid-plain {
+ color: #dbe6ff;
+ font-variant-numeric: tabular-nums;
+ line-height: 1.35;
+ white-space: normal;
+}
+
+.hub-opt-pos-card .opt-close-value {
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+}
+
+.hub-opt-pos-card .pos-card-symbol strong {
+ font-size: 0.72rem;
+ word-break: break-all;
+}
+
.hub-pos-card .pos-cell {
display: flex;
flex-direction: column;
diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js
index d213149..ed6232f 100644
--- a/manual_trading_hub/static/app.js
+++ b/manual_trading_hub/static/app.js
@@ -3569,7 +3569,23 @@
return html;
}
- function renderOptionsSectionBody(row) {
+ function renderOptionsPositionsCards(pos) {
+ if (!pos.length) return '暂无期权持仓
';
+ if (!globalThis.OptionsPositionCards || !OptionsPositionCards.renderCard) {
+ return renderOptionsPositionsTable(pos);
+ }
+ const cls = hubPosListCountClass(pos.length);
+ let html = ``;
+ pos.forEach((p) => {
+ html += OptionsPositionCards.renderCard(p, { readOnly: true, hub: true });
+ });
+ html += "
";
+ return html;
+ }
+
+ function renderOptionsSectionBody(row, opts) {
+ const options = opts || {};
+ const layout = options.layout || "table";
const opt = row.options || {};
let html = "";
if (opt.enabled === false) {
@@ -3585,7 +3601,7 @@
const bal = optionsBalanceFields(opt);
html += renderStatRow(bal.funding, bal.trading, bal.upl);
html += `期权持仓 · ${pos.length} 仓
`;
- html += renderOptionsPositionsTable(pos);
+ html += layout === "cards" ? renderOptionsPositionsCards(pos) : renderOptionsPositionsTable(pos);
if (row.flask_url_browser || row.flask_url) {
html += ``;
}
@@ -3602,11 +3618,11 @@
return html;
}
- function renderOptionsMonitorSection(row) {
+ function renderOptionsMonitorSection(row, opts) {
if (!rowHasOptionsLayout(row)) return "";
let html = '';
html += '
期权账户
';
- html += renderOptionsSectionBody(row);
+ html += renderOptionsSectionBody(row, opts);
html += "
";
return html;
}
@@ -3723,7 +3739,7 @@
if (rowHasOptionsLayout(row)) {
html += "";
}
- html += renderOptionsMonitorSection(row);
+ html += renderOptionsMonitorSection(row, { layout: "cards" });
html += '';
if ((row.capabilities || []).includes("key")) {
if (!flaskOk) {
diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html
index dc1fefe..baa4228 100644
--- a/manual_trading_hub/static/index.html
+++ b/manual_trading_hub/static/index.html
@@ -15,7 +15,7 @@
-
+
@@ -1237,7 +1237,8 @@
+
-
+