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 ( + '
' + + renderCardInner(p, opts) + + "
" + ); + } + + 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 @@ + - +