diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 8afae33..94be914 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -7390,6 +7390,9 @@ def api_account_snapshot(): position_limit_count = count_position_limit_active_monitors(conn) opens_today = count_opens_for_trading_day(conn, trading_day) risk_status = hub_account_risk_status(conn) + active_pnl_rows = conn.execute( + "SELECT exchange_symbol, symbol, direction FROM order_monitors WHERE status='active'" + ).fetchall() conn.close() can_trade = can_trade_new_open( time_allows=trading_day_reset_allows_new_open(now), @@ -7402,13 +7405,17 @@ def api_account_snapshot(): available_trading_usdt = get_available_trading_usdt() unrealized_pnl = None if exchange_private_api_configured(): - from lib.instance.instance_live_pnl_lib import fetch_unrealized_pnl + from lib.instance.instance_live_pnl_lib import resolve_instance_unrealized_pnl def _binance_positions(): ensure_markets_loaded() return exchange.fetch_positions() or [] - unrealized_pnl = fetch_unrealized_pnl(_binance_positions) + unrealized_pnl = resolve_instance_unrealized_pnl( + _binance_positions, + active_pnl_rows, + get_live_position_exchange_metrics, + ) return jsonify({ "funding_usdt": funding_usdt, "current_capital": current_capital, diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index ee79f3f..3002c91 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -7205,6 +7205,9 @@ def api_account_snapshot(): position_limit_count = count_position_limit_active_monitors(conn) opens_today = count_opens_for_trading_day(conn, trading_day) risk_status = hub_account_risk_status(conn) + active_pnl_rows = conn.execute( + "SELECT exchange_symbol, symbol, direction FROM order_monitors WHERE status='active'" + ).fetchall() conn.close() can_trade = can_trade_new_open( time_allows=trading_day_reset_allows_new_open(now), @@ -7217,7 +7220,7 @@ def api_account_snapshot(): available_trading_usdt = get_available_trading_usdt() unrealized_pnl = None if exchange_private_api_configured(): - from lib.instance.instance_live_pnl_lib import fetch_unrealized_pnl + from lib.instance.instance_live_pnl_lib import resolve_instance_unrealized_pnl def _gate_positions(): ensure_markets_loaded() @@ -7226,7 +7229,11 @@ def api_account_snapshot(): except Exception: return exchange.fetch_positions() or [] - unrealized_pnl = fetch_unrealized_pnl(_gate_positions) + unrealized_pnl = resolve_instance_unrealized_pnl( + _gate_positions, + active_pnl_rows, + get_live_position_exchange_metrics, + ) return jsonify({ "funding_usdt": funding_usdt, "current_capital": current_capital, diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 6e2c8b8..36c3f3f 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -6750,6 +6750,9 @@ def api_account_snapshot(): open_guard_enabled = get_trading_day_reset_open_guard_enabled(conn) opens_today = count_opens_for_trading_day(conn, trading_day) risk_status = hub_account_risk_status(conn) + active_pnl_rows = conn.execute( + "SELECT exchange_symbol, symbol, direction FROM order_monitors WHERE status='active'" + ).fetchall() conn.close() open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR can_trade = can_trade_new_open( @@ -6763,7 +6766,7 @@ def api_account_snapshot(): available_trading_usdt = get_available_trading_usdt() unrealized_pnl = None if exchange_private_api_configured(): - from lib.instance.instance_live_pnl_lib import fetch_unrealized_pnl + from lib.instance.instance_live_pnl_lib import resolve_instance_unrealized_pnl def _okx_positions(): ensure_markets_loaded() @@ -6772,7 +6775,11 @@ def api_account_snapshot(): except Exception: return exchange.fetch_positions() or [] - unrealized_pnl = fetch_unrealized_pnl(_okx_positions) + unrealized_pnl = resolve_instance_unrealized_pnl( + _okx_positions, + active_pnl_rows, + get_live_position_exchange_metrics, + ) return jsonify({ "funding_usdt": funding_usdt, "current_capital": current_capital, diff --git a/lib/instance/instance_live_pnl_lib.py b/lib/instance/instance_live_pnl_lib.py index 5694482..cb84b11 100644 --- a/lib/instance/instance_live_pnl_lib.py +++ b/lib/instance/instance_live_pnl_lib.py @@ -4,25 +4,91 @@ from __future__ import annotations from collections.abc import Callable from typing import Any -from lib.hub.hub_monitor_totals_lib import position_unrealized_pnl +from lib.hub.hub_position_metrics import parse_position_unrealized_pnl -def _position_contracts(pos: dict[str, Any]) -> float: - try: - return abs(float(pos.get("contracts") or 0)) - except (TypeError, ValueError): +def position_row_contracts(pos: dict[str, Any]) -> float: + """持仓张数:与三所 app 内 _position_row_effective_contracts 规则一致。""" + if not isinstance(pos, dict): return 0.0 + info = pos.get("info") or {} + if not isinstance(info, dict): + info = {} + for val in ( + pos.get("contracts"), + info.get("positionAmt"), + info.get("size"), + info.get("pos"), + info.get("availPos"), + ): + if val is None or val == "": + continue + try: + x = abs(float(val)) + if x > 0: + return x + except (TypeError, ValueError): + continue + return 0.0 -def sum_unrealized_pnl_from_positions(positions: list[dict[str, Any]] | None) -> float: +def sum_unrealized_pnl_from_positions(positions: list[dict[str, Any]] | None) -> float | None: total = 0.0 + found = False for p in positions or []: if not isinstance(p, dict): continue - if _position_contracts(p) <= 1e-12: + if position_row_contracts(p) <= 1e-12: continue - total += position_unrealized_pnl(p) - return round(total, 2) + upnl = parse_position_unrealized_pnl(p) + if upnl is None: + continue + found = True + total += float(upnl) + return round(total, 2) if found else None + + +def _row_field(row: Any, key: str, default: str = "") -> str: + if row is None: + return default + try: + if hasattr(row, "keys") and key in row.keys(): + val = row[key] + elif isinstance(row, dict): + val = row.get(key) + else: + val = None + except Exception: + val = None + return str(val or default).strip() + + +def sum_unrealized_pnl_from_metrics( + rows: list[dict[str, Any]] | list[Any], + get_metrics_fn: Callable[[str, str], dict[str, Any] | None], +) -> float | None: + """按活跃监控单逐笔拉交易所 metrics 汇总(与持仓卡浮盈亏一致)。""" + total = 0.0 + found = False + for row in rows or []: + ex_sym = _row_field(row, "exchange_symbol") + sym = _row_field(row, "symbol") + direction = _row_field(row, "direction", "long").lower() or "long" + target = ex_sym or sym + if not target: + continue + metrics = get_metrics_fn(target, direction) + if not isinstance(metrics, dict): + continue + upnl = metrics.get("unrealized_pnl") + if upnl is None: + continue + try: + total += float(upnl) + found = True + except (TypeError, ValueError): + continue + return round(total, 2) if found else None def fetch_unrealized_pnl(fetch_positions_fn: Callable[[], list[dict[str, Any]] | None]) -> float | None: @@ -30,3 +96,17 @@ def fetch_unrealized_pnl(fetch_positions_fn: Callable[[], list[dict[str, Any]] | return sum_unrealized_pnl_from_positions(fetch_positions_fn() or []) except Exception: return None + + +def resolve_instance_unrealized_pnl( + fetch_positions_fn: Callable[[], list[dict[str, Any]] | None], + active_rows: list[Any] | None, + get_metrics_fn: Callable[[str, str], dict[str, Any] | None] | None, +) -> float | None: + """先全量持仓汇总,失败或无数据时回退到活跃监控单 metrics。""" + total = fetch_unrealized_pnl(fetch_positions_fn) + if total is not None: + return total + if active_rows and get_metrics_fn: + return sum_unrealized_pnl_from_metrics(active_rows, get_metrics_fn) + return None diff --git a/lib/instance/templates/embed_boot_scripts.html b/lib/instance/templates/embed_boot_scripts.html index 069f1eb..fc9c1cc 100644 --- a/lib/instance/templates/embed_boot_scripts.html +++ b/lib/instance/templates/embed_boot_scripts.html @@ -1021,6 +1021,32 @@ function refreshOrderDefaults(){ }).catch(()=>{}); } +function paintRealtimePnl(v){ + const pnlEl = document.getElementById("realtime-pnl"); + if(!pnlEl) return; + if(v === null || v === undefined || Number.isNaN(Number(v))){ + pnlEl.innerText = "—"; + pnlEl.classList.remove("pnl-pos", "pnl-neg"); + return; + } + const n = Number(v); + const sign = n > 0 ? "+" : ""; + pnlEl.innerText = `${sign}${n.toFixed(2)}U`; + pnlEl.classList.toggle("pnl-pos", n > 0); + pnlEl.classList.toggle("pnl-neg", n < 0); +} +function sumOrdersFloatPnl(orders){ + if(!orders || !orders.length) return null; + let total = 0, found = false; + orders.forEach(o=>{ + if(o.float_pnl != null && !Number.isNaN(Number(o.float_pnl))){ + total += Number(o.float_pnl); + found = true; + } + }); + return found ? total : null; +} + function refreshAccountSnapshot(){ fetch("/api/account_snapshot").then(r=>r.json()).then(data=>{ if (typeof data.funding_usdt !== "undefined") { @@ -1032,19 +1058,7 @@ function refreshAccountSnapshot(){ if(el) el.innerText = `${Number(data.current_capital).toFixed(2)}U`; } if (typeof data.unrealized_pnl !== "undefined") { - const pnlEl = document.getElementById("realtime-pnl"); - if (pnlEl) { - if (data.unrealized_pnl === null || data.unrealized_pnl === undefined) { - pnlEl.innerText = "—"; - pnlEl.classList.remove("pnl-pos", "pnl-neg"); - } else { - const v = Number(data.unrealized_pnl); - const sign = v > 0 ? "+" : ""; - pnlEl.innerText = `${sign}${v.toFixed(2)}U`; - pnlEl.classList.toggle("pnl-pos", v > 0); - pnlEl.classList.toggle("pnl-neg", v < 0); - } - } + paintRealtimePnl(data.unrealized_pnl); } if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) { latestAvailableUsdt = Number(data.available_trading_usdt); @@ -1281,6 +1295,9 @@ function refreshPriceSnapshotConditional(){ }); tickOrderHoldDurations(); } + if(data.order_prices && data.order_prices.length){ + paintRealtimePnl(sumOrdersFloatPnl(data.order_prices)); + } }).catch(()=>{}); } function formatLiveHoldDurationFromMs(openedMs, nowMs){ diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index 4aefedb..ef14d9d 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -1567,6 +1567,32 @@ function refreshOrderDefaults(){ }).catch(()=>{}); } +function paintRealtimePnl(v){ + const pnlEl = document.getElementById("realtime-pnl"); + if(!pnlEl) return; + if(v === null || v === undefined || Number.isNaN(Number(v))){ + pnlEl.innerText = "—"; + pnlEl.classList.remove("pnl-pos", "pnl-neg"); + return; + } + const n = Number(v); + const sign = n > 0 ? "+" : ""; + pnlEl.innerText = `${sign}${n.toFixed(2)}U`; + pnlEl.classList.toggle("pnl-pos", n > 0); + pnlEl.classList.toggle("pnl-neg", n < 0); +} +function sumOrdersFloatPnl(orders){ + if(!orders || !orders.length) return null; + let total = 0, found = false; + orders.forEach(o=>{ + if(o.float_pnl != null && !Number.isNaN(Number(o.float_pnl))){ + total += Number(o.float_pnl); + found = true; + } + }); + return found ? total : null; +} + function refreshAccountSnapshot(){ fetch("/api/account_snapshot").then(r=>r.json()).then(data=>{ if (typeof data.funding_usdt !== "undefined") { @@ -1578,19 +1604,7 @@ function refreshAccountSnapshot(){ if(el) el.innerText = `${Number(data.current_capital).toFixed(2)}U`; } if (typeof data.unrealized_pnl !== "undefined") { - const pnlEl = document.getElementById("realtime-pnl"); - if (pnlEl) { - if (data.unrealized_pnl === null || data.unrealized_pnl === undefined) { - pnlEl.innerText = "—"; - pnlEl.classList.remove("pnl-pos", "pnl-neg"); - } else { - const v = Number(data.unrealized_pnl); - const sign = v > 0 ? "+" : ""; - pnlEl.innerText = `${sign}${v.toFixed(2)}U`; - pnlEl.classList.toggle("pnl-pos", v > 0); - pnlEl.classList.toggle("pnl-neg", v < 0); - } - } + paintRealtimePnl(data.unrealized_pnl); } if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) { latestAvailableUsdt = Number(data.available_trading_usdt); @@ -1870,6 +1884,9 @@ function refreshPriceSnapshotConditional(){ renderOrphanRecoverBanner(data.orphan_live_positions); {% endif %} } + if(data.order_prices && data.order_prices.length){ + paintRealtimePnl(sumOrdersFloatPnl(data.order_prices)); + } }).catch(()=>{}); } function formatLiveHoldDurationFromMs(openedMs, nowMs){ diff --git a/tests/test_instance_live_pnl_lib.py b/tests/test_instance_live_pnl_lib.py new file mode 100644 index 0000000..aa435a3 --- /dev/null +++ b/tests/test_instance_live_pnl_lib.py @@ -0,0 +1,58 @@ +"""instance_live_pnl_lib 单元测试。""" +from __future__ import annotations + +import unittest + +from lib.instance.instance_live_pnl_lib import ( + position_row_contracts, + resolve_instance_unrealized_pnl, + sum_unrealized_pnl_from_metrics, + sum_unrealized_pnl_from_positions, +) + + +class TestInstanceLivePnlLib(unittest.TestCase): + def test_position_row_contracts_from_info(self): + pos = {"contracts": 0, "info": {"positionAmt": "12.5"}} + self.assertAlmostEqual(position_row_contracts(pos), 12.5) + + def test_sum_from_positions_binance_style(self): + positions = [ + {"unrealizedPnl": -0.14, "info": {"positionAmt": "100"}}, + ] + self.assertEqual(sum_unrealized_pnl_from_positions(positions), -0.14) + + def test_sum_from_metrics_fallback(self): + rows = [{"exchange_symbol": "DOGE/USDT:USDT", "symbol": "DOGE/USDT", "direction": "long"}] + + def _metrics(ex_sym, direction): + self.assertEqual(direction, "long") + return {"unrealized_pnl": -0.14} + + self.assertEqual(sum_unrealized_pnl_from_metrics(rows, _metrics), -0.14) + + def test_resolve_prefers_bulk_positions(self): + def _fetch(): + return [{"unrealizedPnl": 1.2, "contracts": 1}] + + def _metrics(_ex, _d): + raise AssertionError("should not call metrics when bulk works") + + total = resolve_instance_unrealized_pnl(_fetch, [], _metrics) + self.assertEqual(total, 1.2) + + def test_resolve_falls_back_to_metrics(self): + def _fetch(): + raise RuntimeError("api down") + + rows = [{"exchange_symbol": "BTC/USDT:USDT", "symbol": "BTC/USDT", "direction": "short"}] + + def _metrics(_ex, direction): + return {"unrealized_pnl": -2.5} if direction == "short" else None + + total = resolve_instance_unrealized_pnl(_fetch, rows, _metrics) + self.assertEqual(total, -2.5) + + +if __name__ == "__main__": + unittest.main()