From f5c553844f3e27f844ff4d2a0eef03b30ea8304b Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 20 Aug 2026 17:45:32 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B8=81=E6=9C=AC=E4=BD=8D=E7=9B=88=E4=BA=8F?= =?UTF-8?q?=E5=8F=8C=E6=98=BEETH/U(=E6=8C=89=E6=8C=87=E6=95=B0=E6=8D=A2?= =?UTF-8?q?=E7=AE=97)=EF=BC=9B=E5=B9=B3=E4=BB=93=E5=8D=96=E5=B8=81?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=8D=96=E5=85=89=E4=BA=A4=E6=98=93=E6=88=B7?= =?UTF-8?q?=E5=8F=AF=E7=94=A8=E4=BD=99=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- crypto_monitor_okx/app.py | 17 +++++++- lib/common/static/options_panel.js | 38 ++++++++++++++++- lib/common/static/options_position_cards.js | 35 +++++++++++++++- .../templates/embed_boot_scripts.html | 36 ++++++++++++---- lib/instance/templates/index.html | 36 ++++++++++++---- lib/options/options_hub_lib.py | 21 +++++++++- lib/options/options_spot_bridge_lib.py | 41 ++++++++++--------- manual_trading_hub/static/app.js | 39 +++++++++++++++--- manual_trading_hub/static/dashboard.js | 20 +++++++-- 9 files changed, 235 insertions(+), 48 deletions(-) diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index e19e424..831b079 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -7076,9 +7076,10 @@ def api_account_snapshot(): options_trading_btc = None options_margin_mode = "coin" options_underly = "ETH" + options_index_px = None if OKX_OPTIONS_ENABLED and exchange_options.apiKey: try: - from lib.exchange.okx_options_lib import options_header_balance_pack + from lib.exchange.okx_options_lib import fetch_index_price, options_header_balance_pack _op = options_header_balance_pack(exchange_options, force=force_refresh) options_trading_usdc = _op.get("trading_usdc") @@ -7090,6 +7091,7 @@ def api_account_snapshot(): options_trading_btc = _op.get("trading_btc") options_margin_mode = _op.get("options_margin_mode") or "coin" options_underly = _op.get("options_underly") or "ETH" + options_index_px = fetch_index_price(exchange_options, options_underly) except Exception: options_trading_usdc = None options_funding_usdc = None @@ -7100,6 +7102,7 @@ def api_account_snapshot(): options_trading_btc = None options_margin_mode = "coin" options_underly = "ETH" + options_index_px = None recommended_capital = get_recommended_capital(current_capital) from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors @@ -7187,6 +7190,7 @@ def api_account_snapshot(): "options_trading_btc": options_trading_btc, "options_margin_mode": options_margin_mode, "options_underly": options_underly, + "options_index_px": options_index_px, "total_funds": total_funds_usdt( funding_usdt if _show_perp_funds else None, current_capital if _show_perp_funds else None, @@ -7586,9 +7590,14 @@ def api_price_snapshot(): ) options_unrealized_pnl = None + options_index_px = None + options_margin_mode = None + options_underly = None if OKX_OPTIONS_ENABLED and exchange_options.apiKey: try: from lib.options.options_positions_lib import sum_options_net_pnl_usdc + from lib.options.options_margin_mode_lib import normalize_options_margin_mode + from lib.exchange.okx_options_lib import fetch_index_price opt_cfg = app.extensions.get("options_cfg") if opt_cfg: @@ -7597,6 +7606,9 @@ def api_price_snapshot(): from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options) + options_margin_mode = normalize_options_margin_mode() + options_underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH" + options_index_px = fetch_index_price(exchange_options, options_underly) except Exception: options_unrealized_pnl = None @@ -7607,6 +7619,9 @@ def api_price_snapshot(): "position_marks": position_marks, "positions_raw_count": len(all_swap_positions), "options_unrealized_pnl": options_unrealized_pnl, + "options_index_px": options_index_px, + "options_margin_mode": options_margin_mode, + "options_underly": options_underly, **force_close_template_context( FORCE_CLOSE_ENABLED, FORCE_CLOSE_BJ_HOUR, diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 8802fbb..aebcc4f 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -897,6 +897,31 @@ return fmtUsdc(n); } + /** 币本位盈亏双显:0.0018 ETH / 4.09U(按指数/现货价换算). */ + function spotPxOf(p) { + const n = Number(p && (p.idx_px != null ? p.idx_px : p.idxPx != null ? p.idxPx : p.index_px)); + return Number.isFinite(n) && n > 0 ? n : null; + } + + function fmtCoinUsdtDual(coinAmt, spotPx, ccy, signed) { + if (coinAmt === null || coinAmt === undefined || Number.isNaN(Number(coinAmt))) return "—"; + const n = Number(coinAmt); + const unit = String(ccy || "ETH").toUpperCase(); + if (unit !== "ETH" && unit !== "BTC") { + const sign = signed && n > 0 ? "+" : ""; + return sign + fmtUsdc(n) + "U"; + } + const absCoin = Math.abs(n).toFixed(8).replace(/\.?0+$/, "") || "0"; + const coinSign = n < 0 ? "-" : signed && n > 0 ? "+" : ""; + const coinTxt = coinSign + absCoin + " " + unit; + const px = Number(spotPx); + if (!Number.isFinite(px) || !(px > 0)) return coinTxt; + const u = n * px; + const absU = Math.abs(u).toFixed(2); + const uSign = u < 0 ? "-" : signed && u > 0 ? "+" : ""; + return coinTxt + " / " + uSign + absU + "U"; + } + function fmtPremiumAmtSigned(v, ccy) { if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; const n = Number(v); @@ -904,6 +929,15 @@ return sign + fmtPremiumAmt(n, ccy) + " " + (String(ccy || "USDC").toUpperCase()); } + function fmtNetPnlDual(net, p) { + const ccy = posPremiumCcy(p); + if (ccy === "USDC") { + if (net == null || Number.isNaN(Number(net))) return "—"; + return fmtUsdc(Number(net)) + "U"; + } + return fmtCoinUsdtDual(net, spotPxOf(p), ccy, true); + } + function fmtClosePreview(preview, premiumPaid, p) { if (!preview || preview.total_received == null) return "—"; const ccy = posPremiumCcy(p); @@ -1614,7 +1648,7 @@ const markTxt = p.mark_px != null ? fmtOptionPx(p.mark_px, tickSz) : fmtDisplay(p.mark_px_fmt); const netTxt = closePreview.bid_invalid || net == null ? "—" - : (fmtPremiumAmt(net, premCcy) + (coinPos ? (" " + premCcy) : "")); + : fmtNetPnlDual(net, p); return ( '
' + '
' + (p.inst_id || "") + '' + @@ -1873,7 +1907,7 @@ ? '到期 ' : "") + '' + - (net == null ? "—" : (fmtPremiumAmt(net, posPremiumCcy(p)) + " " + posPremiumCcy(p))) + "" + + (net == null ? "—" : fmtNetPnlDual(net, p)) + "" + '' + (roi == null ? "—" : fmt(roi, 2) + "%") + "" + "" + diff --git a/lib/common/static/options_position_cards.js b/lib/common/static/options_position_cards.js index e1079f4..3452cfe 100644 --- a/lib/common/static/options_position_cards.js +++ b/lib/common/static/options_position_cards.js @@ -56,6 +56,39 @@ return fmtUsdc(n); } + function spotPxOf(p) { + const n = Number(p && (p.idx_px != null ? p.idx_px : p.idxPx != null ? p.idxPx : p.index_px)); + return Number.isFinite(n) && n > 0 ? n : null; + } + + function fmtCoinUsdtDual(coinAmt, spotPx, ccy, signed) { + if (coinAmt === null || coinAmt === undefined || Number.isNaN(Number(coinAmt))) return "—"; + const n = Number(coinAmt); + const unit = String(ccy || "ETH").toUpperCase(); + if (unit !== "ETH" && unit !== "BTC") { + const sign = signed && n > 0 ? "+" : ""; + return sign + fmtUsdc(n) + "U"; + } + const absCoin = Math.abs(n).toFixed(8).replace(/\.?0+$/, "") || "0"; + const coinSign = n < 0 ? "-" : signed && n > 0 ? "+" : ""; + const coinTxt = coinSign + absCoin + " " + unit; + const px = Number(spotPx); + if (!Number.isFinite(px) || !(px > 0)) return coinTxt; + const u = n * px; + const absU = Math.abs(u).toFixed(2); + const uSign = u < 0 ? "-" : signed && u > 0 ? "+" : ""; + return coinTxt + " / " + uSign + absU + "U"; + } + + function fmtNetPnlDual(net, p) { + const ccy = posPremiumCcy(p); + if (ccy === "USDC") { + if (net == null || Number.isNaN(Number(net))) return "—"; + return fmtUsdc(Number(net)) + "U"; + } + return fmtCoinUsdtDual(net, spotPxOf(p), ccy, true); + } + function optTypeLabel(t) { return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call"; } @@ -209,7 +242,7 @@ const pnlCells = hidePnl ? "" : '
净盈亏' + - (net == null ? "—" : (fmtPremiumAmt(net, premCcy) + (premCcy !== "USDC" ? (" " + premCcy) : ""))) + "
" + + (net == null ? "—" : fmtNetPnlDual(net, p)) + "
" + '
收益率' + (roi == null ? "—" : fmt(roi, 2) + "%") + "
"; return ( diff --git a/lib/instance/templates/embed_boot_scripts.html b/lib/instance/templates/embed_boot_scripts.html index c8de900..fe1b112 100644 --- a/lib/instance/templates/embed_boot_scripts.html +++ b/lib/instance/templates/embed_boot_scripts.html @@ -1075,7 +1075,7 @@ function refreshOrderDefaults(){ }).catch(()=>{}); } -function paintRealtimePnl(v, unit){ +function paintRealtimePnl(v, unit, spotPx){ const nodes = document.querySelectorAll('[data-funds-field="realtime-pnl"]'); if(!nodes.length) return; if(v === null || v === undefined || Number.isNaN(Number(v))){ @@ -1088,11 +1088,23 @@ function paintRealtimePnl(v, unit){ const n = Number(v); const u = String(unit || lastRealtimePnlUnit || "U").toUpperCase() || "U"; lastRealtimePnlUnit = u; + if (spotPx != null && Number.isFinite(Number(spotPx)) && Number(spotPx) > 0) { + lastRealtimePnlSpotPx = Number(spotPx); + } const sign = n > 0 ? "+" : ""; let text; if (u === "ETH" || u === "BTC") { const abs = Math.abs(n).toFixed(8).replace(/\.?0+$/, "") || "0"; - text = `${n < 0 ? "-" : sign}${abs}${u}`; + const coinTxt = `${n < 0 ? "-" : sign}${abs} ${u}`; + const px = Number(spotPx != null ? spotPx : lastRealtimePnlSpotPx); + if (Number.isFinite(px) && px > 0) { + const uu = n * px; + const uAbs = Math.abs(uu).toFixed(2); + const uSign = uu < 0 ? "-" : uu > 0 ? "+" : ""; + text = `${coinTxt} / ${uSign}${uAbs}U`; + } else { + text = coinTxt; + } } else { text = `${sign}${n.toFixed(2)}U`; } @@ -1104,15 +1116,16 @@ function paintRealtimePnl(v, unit){ } let lastRealtimePnl = null; let lastRealtimePnlUnit = "U"; -function updateRealtimePnl(v, unit){ +let lastRealtimePnlSpotPx = null; +function updateRealtimePnl(v, unit, spotPx){ if(v != null && !Number.isNaN(Number(v))){ lastRealtimePnl = Number(v); if (unit) lastRealtimePnlUnit = String(unit).toUpperCase(); - paintRealtimePnl(v, lastRealtimePnlUnit); + paintRealtimePnl(v, lastRealtimePnlUnit, spotPx); return; } if(lastRealtimePnl != null) return; - paintRealtimePnl(v, lastRealtimePnlUnit); + paintRealtimePnl(v, lastRealtimePnlUnit, spotPx); } function sumOrdersFloatPnl(orders){ if(!orders || !orders.length) return null; @@ -1143,14 +1156,23 @@ function paintRealtimePnlFromSnapshot(data){ const opt = data.options_unrealized_pnl; const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin"; const underly = String(data.options_underly || "ETH").toUpperCase() || "ETH"; + const spotPx = data.options_index_px != null ? Number(data.options_index_px) : null; if (coinMode && opt != null && !Number.isNaN(Number(opt))) { if (perp != null && Math.abs(Number(perp)) >= 0.005) { const optAbs = Math.abs(Number(opt)).toFixed(8).replace(/\.?0+$/, "") || "0"; const optSign = Number(opt) > 0 ? "+" : (Number(opt) < 0 ? "-" : ""); const perpSign = Number(perp) > 0 ? "+" : ""; - const text = `${perpSign}${Number(perp).toFixed(2)}U / ${optSign}${optAbs}${underly}`; + let optPart = `${optSign}${optAbs} ${underly}`; + if (Number.isFinite(spotPx) && spotPx > 0) { + const uu = Number(opt) * spotPx; + const uAbs = Math.abs(uu).toFixed(2); + const uSign = uu < 0 ? "-" : uu > 0 ? "+" : ""; + optPart += ` / ${uSign}${uAbs}U`; + } + const text = `${perpSign}${Number(perp).toFixed(2)}U / ${optPart}`; lastRealtimePnl = Number(opt); lastRealtimePnlUnit = underly; + lastRealtimePnlSpotPx = spotPx; document.querySelectorAll('[data-funds-field="realtime-pnl"]').forEach((pnlEl) => { pnlEl.innerText = text; const n = Number(opt) + Number(perp); @@ -1159,7 +1181,7 @@ function paintRealtimePnlFromSnapshot(data){ }); return; } - paintRealtimePnl(opt, underly); + paintRealtimePnl(opt, underly, spotPx); return; } const combined = combineRealtimeFloatPnl(perp, opt); diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index f572fb3..8467aee 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -1556,7 +1556,7 @@ function refreshOrderDefaults(){ }).catch(()=>{}); } -function paintRealtimePnl(v, unit){ +function paintRealtimePnl(v, unit, spotPx){ const nodes = document.querySelectorAll('[data-funds-field="realtime-pnl"]'); if(!nodes.length) return; if(v === null || v === undefined || Number.isNaN(Number(v))){ @@ -1569,11 +1569,23 @@ function paintRealtimePnl(v, unit){ const n = Number(v); const u = String(unit || lastRealtimePnlUnit || "U").toUpperCase() || "U"; lastRealtimePnlUnit = u; + if (spotPx != null && Number.isFinite(Number(spotPx)) && Number(spotPx) > 0) { + lastRealtimePnlSpotPx = Number(spotPx); + } const sign = n > 0 ? "+" : ""; let text; if (u === "ETH" || u === "BTC") { const abs = Math.abs(n).toFixed(8).replace(/\.?0+$/, "") || "0"; - text = `${n < 0 ? "-" : sign}${abs}${u}`; + const coinTxt = `${n < 0 ? "-" : sign}${abs} ${u}`; + const px = Number(spotPx != null ? spotPx : lastRealtimePnlSpotPx); + if (Number.isFinite(px) && px > 0) { + const uu = n * px; + const uAbs = Math.abs(uu).toFixed(2); + const uSign = uu < 0 ? "-" : uu > 0 ? "+" : ""; + text = `${coinTxt} / ${uSign}${uAbs}U`; + } else { + text = coinTxt; + } } else { text = `${sign}${n.toFixed(2)}U`; } @@ -1585,15 +1597,16 @@ function paintRealtimePnl(v, unit){ } let lastRealtimePnl = null; let lastRealtimePnlUnit = "U"; -function updateRealtimePnl(v, unit){ +let lastRealtimePnlSpotPx = null; +function updateRealtimePnl(v, unit, spotPx){ if(v != null && !Number.isNaN(Number(v))){ lastRealtimePnl = Number(v); if (unit) lastRealtimePnlUnit = String(unit).toUpperCase(); - paintRealtimePnl(v, lastRealtimePnlUnit); + paintRealtimePnl(v, lastRealtimePnlUnit, spotPx); return; } if(lastRealtimePnl != null) return; - paintRealtimePnl(v, lastRealtimePnlUnit); + paintRealtimePnl(v, lastRealtimePnlUnit, spotPx); } function sumOrdersFloatPnl(orders){ if(!orders || !orders.length) return null; @@ -1624,15 +1637,24 @@ function paintRealtimePnlFromSnapshot(data){ const opt = data.options_unrealized_pnl; const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin"; const underly = String(data.options_underly || "ETH").toUpperCase() || "ETH"; + const spotPx = data.options_index_px != null ? Number(data.options_index_px) : null; if (coinMode && opt != null && !Number.isNaN(Number(opt))) { // 币本位期权盈亏单位为币,勿与永续 U 混加成「xxU」 if (perp != null && Math.abs(Number(perp)) >= 0.005) { const optAbs = Math.abs(Number(opt)).toFixed(8).replace(/\.?0+$/, "") || "0"; const optSign = Number(opt) > 0 ? "+" : (Number(opt) < 0 ? "-" : ""); const perpSign = Number(perp) > 0 ? "+" : ""; - const text = `${perpSign}${Number(perp).toFixed(2)}U / ${optSign}${optAbs}${underly}`; + let optPart = `${optSign}${optAbs} ${underly}`; + if (Number.isFinite(spotPx) && spotPx > 0) { + const uu = Number(opt) * spotPx; + const uAbs = Math.abs(uu).toFixed(2); + const uSign = uu < 0 ? "-" : uu > 0 ? "+" : ""; + optPart += ` / ${uSign}${uAbs}U`; + } + const text = `${perpSign}${Number(perp).toFixed(2)}U / ${optPart}`; lastRealtimePnl = Number(opt); lastRealtimePnlUnit = underly; + lastRealtimePnlSpotPx = spotPx; document.querySelectorAll('[data-funds-field="realtime-pnl"]').forEach((pnlEl) => { pnlEl.innerText = text; const n = Number(opt) + Number(perp); @@ -1641,7 +1663,7 @@ function paintRealtimePnlFromSnapshot(data){ }); return; } - paintRealtimePnl(opt, underly); + paintRealtimePnl(opt, underly, spotPx); return; } const combined = combineRealtimeFloatPnl(perp, opt); diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py index 4e0cdd8..e3b151f 100644 --- a/lib/options/options_hub_lib.py +++ b/lib/options/options_hub_lib.py @@ -115,6 +115,24 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: ) p["margin_mode_label"] = "币本位" if row_mode == "coin" else "USDC" + underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH" + options_index_px = None + try: + from lib.exchange.okx_options_lib import fetch_index_price + + options_index_px = fetch_index_price(ex, underly) + except Exception: + options_index_px = None + if options_index_px is None: + for p in positions: + try: + px = float(p.get("idx_px") or p.get("idxPx") or 0) + except (TypeError, ValueError): + px = 0 + if px > 0: + options_index_px = px + break + coin_budget = None bridge_status = None open_bridges = [] @@ -157,7 +175,8 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: "max_active_positions": options_max_active_positions(), "options_margin_mode": margin_mode, "options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC", - "options_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH", + "options_underly": underly, + "options_index_px": options_index_px, "coin_budget": coin_budget, "bridge_status": bridge_status, "open_bridges": open_bridges, diff --git a/lib/options/options_spot_bridge_lib.py b/lib/options/options_spot_bridge_lib.py index 886947b..9f1e675 100644 --- a/lib/options/options_spot_bridge_lib.py +++ b/lib/options/options_spot_bridge_lib.py @@ -221,26 +221,32 @@ def spot_market_sell_coin_to_usdt( underlying: str, coin_amount: float | None = None, ) -> dict[str, Any]: - """交易账户:市价卖出标的币换 USDT.coin_amount 空则尽量卖光可用.""" + """交易账户:市价卖出标的币换 USDT. + + 默认/推荐:coin_amount 为空 → 卖光交易账户全部可用币(全部卖出). + 传入 coin_amount 时仍不超过可用余额,且尽量按可用全额卖出(不故意留粉尘). + """ ccy = (underlying or "ETH").upper() - amt = coin_amount - if amt is None or float(amt) <= 0: - avail = fetch_trading_coin_available(ex, ccy) - if avail is None or float(avail) <= 0: - return {"ok": False, "msg": f"交易账户无可用 {ccy}"} - amt = float(avail) - if float(amt) <= 0: + avail = fetch_trading_coin_available(ex, ccy) + if avail is None or float(avail) <= 0: + return {"ok": False, "msg": f"交易账户无可用 {ccy}"} + avail_f = float(avail) + # 全部卖出:以可用余额为准;若传入数量则不超过可用(开仓失败回滚用) + if coin_amount is None or float(coin_amount) <= 0: + sell_sz = avail_f + else: + sell_sz = min(float(coin_amount), avail_f) + if sell_sz <= 0: return {"ok": False, "msg": f"{ccy} 数量须大于 0"} - # 留一点粉尘避免精度拒单 - sell_sz = float(amt) - if sell_sz > 1e-8: - sell_sz = max(0.0, sell_sz * 0.999) inst_id = spot_quote_inst_id(ccy) try: - # 现货卖出数量精度:截到 8 位 + # 现货卖出:向下截到 8 位,避免超过可用被拒;不再 *0.999 故意留残 sz = f"{sell_sz:.8f}".rstrip("0").rstrip(".") if not sz or float(sz) <= 0: return {"ok": False, "msg": f"{ccy} 可卖数量过小"} + # 二次钳制:格式化后仍不得超过可用 + if float(sz) > avail_f: + sz = f"{avail_f:.8f}".rstrip("0").rstrip(".") body = { "instId": inst_id, "tdMode": "cash", @@ -328,13 +334,8 @@ def sell_residual_after_option_flat( if not underlying or str(b.get("underlying") or "").upper() == underlying.upper(): target = b break - coin_amt = None - if target is not None: - try: - coin_amt = float(target.get("coin_bought") or 0) or None - except (TypeError, ValueError): - coin_amt = None - sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying, coin_amount=coin_amt) + # 平仓后全部卖出交易账户可用标的币(含权利金盈亏留下的币),不按 bridge 记账量限卖 + sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying, coin_amount=None) if target is None: if not sell.get("ok"): msg = str(sell.get("msg") or "") diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 3ba78cb..af90416 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -782,13 +782,37 @@ return fmt(n, 2); } - function fmtOptPnlText(v, ccy) { + function spotPxFromOptMeta(optMeta, p) { + if (p) { + const n = Number(p.idx_px != null ? p.idx_px : p.idxPx != null ? p.idxPx : p.index_px); + if (Number.isFinite(n) && n > 0) return n; + } + if (optMeta) { + const n = Number(optMeta.options_index_px != null ? optMeta.options_index_px : optMeta.index_px); + if (Number.isFinite(n) && n > 0) return n; + const pos = Array.isArray(optMeta.positions) ? optMeta.positions : []; + for (let i = 0; i < pos.length; i++) { + const px = Number(pos[i] && (pos[i].idx_px != null ? pos[i].idx_px : pos[i].idxPx)); + if (Number.isFinite(px) && px > 0) return px; + } + } + return null; + } + + function fmtOptPnlText(v, ccy, spotPx) { if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; const n = Number(v); const unit = String(ccy || "USDC").toUpperCase(); const sign = n > 0 ? "+" : ""; if (unit === "ETH" || unit === "BTC") { - return `${sign}${fmtOptPnlAmt(n, unit)} ${unit}`; + const coin = `${sign}${fmtOptPnlAmt(Math.abs(n), unit)}`; + const signedCoin = n < 0 ? `-${fmtOptPnlAmt(Math.abs(n), unit)}` : coin; + const px = Number(spotPx); + if (!Number.isFinite(px) || !(px > 0)) return `${signedCoin} ${unit}`; + const u = n * px; + const uAbs = Math.abs(u).toFixed(2); + const uTxt = u < 0 ? `-${uAbs}` : u > 0 ? `+${uAbs}` : uAbs; + return `${signedCoin} ${unit} / ${uTxt}U`; } return `${sign}${fmt(n, 2)}U`; } @@ -3948,9 +3972,10 @@ : "—"; const tradeTxt = formatCoinTradingLabel(usdt, eth, btc); const pnlCcy = optionsPanelCcy(optMeta); + const spotPx = spotPxFromOptMeta(optMeta); const pnlTxt = upnl == null || Number.isNaN(Number(upnl)) ? "—" - : `${fmtOptPnlText(upnl, pnlCcy)}`; + : `${fmtOptPnlText(upnl, pnlCcy, spotPx)}`; return `
资金账户
${fundTxt}
交易账户
${tradeTxt}
@@ -4116,7 +4141,7 @@ ${renderOptionsTargetCell(target, p)}`; if (showPnl) { const premCcy = optPremiumCcyOf(p); - html += `${net == null ? "—" : fmtOptPnlText(net, premCcy)} + html += `${net == null ? "—" : fmtOptPnlText(net, premCcy, spotPxFromOptMeta(null, p))} ${roi == null ? "—" : esc(Number(roi).toFixed(2)) + "%"}`; } html += ""; @@ -4602,6 +4627,7 @@ let pnlShow = upnl; let pnlSuffix = ""; let pnlUnit = "U"; + let pnlSpotPx = null; if (hasOptCap) { if (opt.enabled === false) { optLine = "期权未启用"; @@ -4629,13 +4655,14 @@ parts.push(`交易 ${fmt(bal.trading, 2)}U`); } if (optUpl != null && Number.isFinite(Number(optUpl))) { - parts.push(`浮盈 ${fmtOptPnlText(optUpl, optCcy)}`); + parts.push(`浮盈 ${fmtOptPnlText(optUpl, optCcy, spotPxFromOptMeta(opt))}`); } if (optUpl != null && Number.isFinite(Number(optUpl)) && openCount === 0) { // 永续空仓时主数字优先展示期权浮盈,避免一直显示 0U pnlShow = optUpl; pnlSuffix = "期权"; pnlUnit = optCcy; + pnlSpotPx = spotPxFromOptMeta(opt); } } optLine = parts.join(" · "); @@ -4646,7 +4673,7 @@ const strategyStats = renderCardStrategyStats(row, hm, flaskOk); const tilePnlHtml = pnlUnit === "ETH" || pnlUnit === "BTC" - ? `${fmtOptPnlAmt(pnlShow, pnlUnit)} ${esc(pnlUnit)}${pnlSuffix ? " · " + esc(pnlSuffix) : ""}` + ? `${fmtOptPnlText(pnlShow, pnlUnit, pnlSpotPx)} ${pnlSuffix ? esc(pnlSuffix) : ""}` : `${fmt(pnlShow, 2)} U${pnlSuffix ? " · " + esc(pnlSuffix) : ""}`; return `
diff --git a/manual_trading_hub/static/dashboard.js b/manual_trading_hub/static/dashboard.js index ce3757e..344cd16 100644 --- a/manual_trading_hub/static/dashboard.js +++ b/manual_trading_hub/static/dashboard.js @@ -51,14 +51,20 @@ return "USDC"; } - function pnlSignedOpt(v, ccy) { + function pnlSignedOpt(v, ccy, spotPx) { const n = Number(v); if (!Number.isFinite(n)) return "—"; const unit = String(ccy || "USDC").toUpperCase(); if (unit === "ETH" || unit === "BTC") { const abs = Math.abs(n).toFixed(8).replace(/\.?0+$/, "") || "0"; const sign = n > 0 ? "+" : n < 0 ? "-" : ""; - return `${sign}${abs} ${unit}`; + const coinTxt = `${sign}${abs} ${unit}`; + const px = Number(spotPx); + if (!Number.isFinite(px) || !(px > 0)) return coinTxt; + const u = n * px; + const uAbs = Math.abs(u).toFixed(2); + const uSign = u < 0 ? "-" : u > 0 ? "+" : ""; + return `${coinTxt} / ${uSign}${uAbs}U`; } return pnlSigned(n, 2); } @@ -361,7 +367,15 @@ ${p.idx_px != null ? fmt(p.idx_px, 0) : "—"} ${esc(target)}`; if (showPnl) { - html += `${net != null ? pnlSignedOpt(net, optPremiumCcyOf(p)) : "—"} + html += `${ + net != null + ? pnlSignedOpt( + net, + optPremiumCcyOf(p), + Number(p.idx_px != null ? p.idx_px : p.idxPx) || null + ) + : "—" + } ${roi != null ? esc(Number(roi).toFixed(2)) + "%" : "—"}`; } html += "";