From fc2411414220c995b62696801a49ff341176a70a Mon Sep 17 00:00:00 2001 From: dekun Date: Fri, 21 Aug 2026 06:46:11 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B8=81=E6=9C=AC=E4=BD=8D=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E6=94=B9=E4=B8=BAU=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=EF=BC=8C=E5=B7=B2=E5=B9=B3=E7=9B=88=E4=BA=8F=E6=8C=89=E6=8C=87?= =?UTF-8?q?=E6=95=B0=E6=8A=98U=E4=B8=8E=E6=B5=AE=E7=9B=88=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E4=B8=80=E8=87=B4=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- lib/common/static/options_panel.js | 30 ++++++---- lib/options/options_register.py | 12 +++- lib/options/options_stats_lib.py | 88 ++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 13 deletions(-) diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index e6fa1bc..e0047ad 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -2340,7 +2340,12 @@ } } - function paintPnlStat(el, value) { + function statsPnlUnit(d) { + if (d && d.pnl_unit) return String(d.pnl_unit); + return isCoinMarginMode() ? "U" : "USDC"; + } + + function paintPnlStat(el, value, unit) { if (!el) return; if (value == null || value === "" || Number.isNaN(Number(value))) { el.textContent = "—"; @@ -2348,7 +2353,8 @@ return; } const n = Number(value); - el.textContent = (n > 0 ? "+" : "") + fmt(n, 2) + " USDC"; + const u = unit || "USDC"; + el.textContent = (n > 0 ? "+" : "") + fmt(n, 2) + " " + u; el.classList.toggle("pos-pnl-profit", n > 0); el.classList.toggle("pos-pnl-loss", n < 0); } @@ -2378,9 +2384,10 @@ paintStatsCharts(null); return; } - paintPnlStat(totalPnlEl, d.total_pnl); - paintPnlStat(netRealizedEl, d.net_realized_pnl); - paintPnlStat(openFloatEl, d.open_float_pnl); + const unit = statsPnlUnit(d); + paintPnlStat(totalPnlEl, d.total_pnl, unit); + paintPnlStat(netRealizedEl, d.net_realized_pnl, unit); + paintPnlStat(openFloatEl, d.open_float_pnl, unit); if (winEl) winEl.textContent = d.total_closed ? d.win_rate + "%" : "0%"; if (plrEl) { plrEl.textContent = d.profit_loss_ratio != null ? String(d.profit_loss_ratio) : "—"; @@ -2388,11 +2395,11 @@ if (closedEl) closedEl.textContent = String(d.total_closed || 0); if (profitEl) { profitEl.textContent = d.avg_win != null && d.avg_win > 0 - ? fmt(d.avg_win, 2) + " USDC" : (d.win_count ? "0 USDC" : "—"); + ? fmt(d.avg_win, 2) + " " + unit : (d.win_count ? ("0 " + unit) : "—"); } if (lossEl) { lossEl.textContent = d.avg_loss != null && d.avg_loss > 0 - ? fmt(d.avg_loss, 2) + " USDC" : (d.loss_count ? "0 USDC" : "—"); + ? fmt(d.avg_loss, 2) + " " + unit : (d.loss_count ? ("0 " + unit) : "—"); } if (avgHoldEl) avgHoldEl.textContent = fmtDuration(d.avg_hold_sec); if (winHoldEl) winHoldEl.textContent = fmtDuration(d.avg_win_hold_sec); @@ -2455,19 +2462,20 @@ if (ring) ring.style.setProperty("--win-pct", String(winRate)); if (ringLabel) ringLabel.textContent = d.total_closed ? winRate.toFixed(0) + "%" : "0%"; + const unit = statsPnlUnit(d); const profit = Math.max(0, Number(d.avg_win) || 0); const loss = Math.max(0, Number(d.avg_loss) || 0); const pnlTotal = profit + loss; if (pnlTotal > 0) { setBarFill(profitBar, (profit / pnlTotal) * 100); setBarFill(lossBar, (loss / pnlTotal) * 100); - if (profitBarLabel) profitBarLabel.textContent = fmt(profit, 2) + " USDC"; - if (lossBarLabel) lossBarLabel.textContent = fmt(loss, 2) + " USDC"; + if (profitBarLabel) profitBarLabel.textContent = fmt(profit, 2) + " " + unit; + if (lossBarLabel) lossBarLabel.textContent = fmt(loss, 2) + " " + unit; } else { setBarFill(profitBar, 0); setBarFill(lossBar, 0); - if (profitBarLabel) profitBarLabel.textContent = d.win_count ? "0 USDC" : "—"; - if (lossBarLabel) lossBarLabel.textContent = d.loss_count ? "0 USDC" : "—"; + if (profitBarLabel) profitBarLabel.textContent = d.win_count ? ("0 " + unit) : "—"; + if (lossBarLabel) lossBarLabel.textContent = d.loss_count ? ("0 " + unit) : "—"; } const winHold = Number(d.avg_win_hold_sec) || 0; diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 316f50a..cd31203 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -1828,14 +1828,19 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: if ex is None: return jsonify({"ok": False, "msg": err}) from lib.options.options_history_lib import load_options_history + from lib.options.options_margin_mode_lib import is_coin_margin_mode from lib.options.options_positions_lib import sum_options_net_pnl_usdc - from lib.options.options_stats_lib import compute_options_stats_from_history + from lib.options.options_stats_lib import ( + compute_options_stats_from_history, + history_pnl_to_usdt, + ) raw_live = cfg["fetch_option_positions"](ex) if raw_live is None: return jsonify({"ok": False, "msg": "获取期权持仓失败"}) history = load_options_history(ex, cfg) - stats = compute_options_stats_from_history(history) + # 币本位已平盈亏按指数折 U,与持仓浮盈/合计口径一致 + stats = compute_options_stats_from_history(history_pnl_to_usdt(history, ex)) open_float = sum_options_net_pnl_usdc(cfg, ex, raw_live) net_realized = _safe_float(stats.get("net_realized_pnl")) or 0.0 total_pnl = None @@ -1843,12 +1848,15 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: total_pnl = round(net_realized + float(open_float), 4) elif stats.get("total_closed"): total_pnl = round(net_realized, 4) + # 币本位统计统一标 U;USDC 模式仍标 USDC + pnl_unit = "U" if is_coin_margin_mode() else "USDC" return jsonify( { "ok": True, **stats, "open_float_pnl": open_float, "total_pnl": total_pnl, + "pnl_unit": pnl_unit, } ) diff --git a/lib/options/options_stats_lib.py b/lib/options/options_stats_lib.py index 33ccc54..8936b0b 100644 --- a/lib/options/options_stats_lib.py +++ b/lib/options/options_stats_lib.py @@ -8,6 +8,94 @@ from lib.instance.instance_embed_context_lib import profit_loss_ratio_from_avera from lib.options.options_db import init_options_tables +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 _underlying_index_usdt(ex: Any, underly: str) -> float | None: + """取标的 USDT 近似指数(币本位已平盈亏折 U).优先公开 ticker,避免私钥失败.""" + u = (underly or "ETH").strip().upper() or "ETH" + pubs: list[Any] = [] + try: + from lib.sim.hooks import _APP_MODULE, _sim_public_exchange + + pub = _sim_public_exchange(ex) if _APP_MODULE is not None else None + if pub is not None: + pubs.append(pub) + except Exception: + pass + if ex is not None and ex not in pubs: + pubs.append(ex) + from lib.exchange.okx_options_lib import fetch_index_price + + for pub in pubs: + try: + if hasattr(pub, "public_get_market_ticker"): + rows = (pub.public_get_market_ticker({"instId": f"{u}-USDT"}) or {}).get("data") or [] + if rows: + last = _safe_float(rows[0].get("last") or rows[0].get("lastPx")) + if last is not None and last > 0: + return float(last) + except Exception: + pass + try: + px = fetch_index_price(pub, f"{u}-USD") + if px is not None and float(px) > 0: + return float(px) + except Exception: + pass + try: + t = pub.fetch_ticker(f"{u}/USDT") or {} + last = _safe_float(t.get("last") or t.get("close")) + if last is not None and last > 0: + return float(last) + except Exception: + continue + return None + + +def history_pnl_to_usdt(history: list[dict[str, Any]], ex: Any = None) -> list[dict[str, Any]]: + """ + 统计用:币本位 realized_pnl(ETH/BTC) 按指数折成 U;USDC 原样. + 折算失败的币仓剔除盈亏字段,避免把「币数量」当成 U. + """ + from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode + + idx_cache: dict[str, float | None] = {} + out: list[dict[str, Any]] = [] + for row in history: + r = dict(row) + if r.get("status") == "open": + out.append(r) + continue + pnl = _safe_float(r.get("realized_pnl")) + if pnl is None: + out.append(r) + continue + inst = str(r.get("inst_id") or "") + underly = str(r.get("underlying") or (inst.split("-")[0] if inst else "ETH") or "ETH") + ccy = str(r.get("premium_ccy") or "").strip().upper() + if not ccy: + ccy = premium_ccy_for_mode(margin_mode_from_inst_id(inst), underly) + if ccy in ("ETH", "BTC"): + if underly not in idx_cache: + idx_cache[underly] = _underlying_index_usdt(ex, underly) + idx = idx_cache.get(underly) + if idx is None or idx <= 0: + r["realized_pnl"] = None + else: + r["realized_pnl"] = round(float(pnl) * float(idx), 4) + else: + r["realized_pnl"] = round(float(pnl), 4) + out.append(r) + return out + + def _parse_ts(raw: Any) -> datetime | None: if raw is None or raw == "": return None