diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index c9e43d3..863a7b8 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -6892,6 +6892,16 @@ def api_account_snapshot(): active_pnl_rows, get_live_position_exchange_metrics, ) + options_unrealized_pnl = None + if OKX_OPTIONS_ENABLED and exchange_options.apiKey: + try: + from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc + from lib.instance.instance_live_pnl_lib import merge_unrealized_pnl_components + + options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options) + unrealized_pnl = merge_unrealized_pnl_components(unrealized_pnl, options_unrealized_pnl) + except Exception: + options_unrealized_pnl = None return jsonify({ "funding_usdt": funding_usdt, "current_capital": current_capital, @@ -6907,6 +6917,7 @@ def api_account_snapshot(): ), "available_trading_usdt": round(available_trading_usdt, FUNDS_DECIMALS) if available_trading_usdt is not None else None, "unrealized_pnl": unrealized_pnl, + "options_unrealized_pnl": options_unrealized_pnl, "recommended_capital": recommended_capital, "active_count": position_limit_count, "max_active_positions": MAX_ACTIVE_POSITIONS, @@ -7282,12 +7293,22 @@ def api_price_snapshot(): format_mark_display=lambda sym, px: format_price_for_symbol(sym, px), ) + options_unrealized_pnl = None + if OKX_OPTIONS_ENABLED and exchange_options.apiKey: + try: + from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc + + options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options) + except Exception: + options_unrealized_pnl = None + return jsonify({ "updated_at": app_now_str(), "key_prices": key_prices, "order_prices": order_prices, "position_marks": position_marks, "positions_raw_count": len(all_swap_positions), + "options_unrealized_pnl": options_unrealized_pnl, **force_close_template_context( FORCE_CLOSE_ENABLED, FORCE_CLOSE_BJ_HOUR, diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 8d002cb..f88b6e4 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -615,6 +615,19 @@ def fetch_option_positions(ex: ccxt.okx) -> list[dict[str, Any]]: return [] +def fetch_options_unrealized_pnl_usdc(ex: ccxt.okx) -> float | None: + """期权持仓未实现盈亏合计(USDC,统计口径与 USDT 1:1)。""" + total = 0.0 + found = False + for pos in fetch_option_positions(ex): + upl = _safe_float(pos.get("upl")) + if upl is None: + continue + found = True + total += upl + return round(total, 4) if found else None + + def estimate_usdt_to_usdc(ex: ccxt.okx, usdt_amount: float) -> dict[str, Any]: if usdt_amount <= 0: return {"ok": False, "msg": "兑换数量须大于 0"} diff --git a/lib/instance/instance_live_pnl_lib.py b/lib/instance/instance_live_pnl_lib.py index cb84b11..13a9aa3 100644 --- a/lib/instance/instance_live_pnl_lib.py +++ b/lib/instance/instance_live_pnl_lib.py @@ -110,3 +110,18 @@ def resolve_instance_unrealized_pnl( if active_rows and get_metrics_fn: return sum_unrealized_pnl_from_metrics(active_rows, get_metrics_fn) return None + + +def merge_unrealized_pnl_components(*parts: float | None) -> float | None: + """合并永续与期权等多路未实现盈亏(任一路有值即参与合计)。""" + total = 0.0 + found = False + for part in parts: + if part is None: + continue + try: + total += float(part) + found = True + except (TypeError, ValueError): + continue + return round(total, 2) if found else None diff --git a/lib/instance/templates/embed_boot_scripts.html b/lib/instance/templates/embed_boot_scripts.html index e2dc8cc..1833b50 100644 --- a/lib/instance/templates/embed_boot_scripts.html +++ b/lib/instance/templates/embed_boot_scripts.html @@ -1046,6 +1046,26 @@ function sumOrdersFloatPnl(orders){ }); return found ? total : null; } +function combineRealtimeFloatPnl(perpTotal, optionsTotal){ + let total = 0, found = false; + [perpTotal, optionsTotal].forEach(v=>{ + if(v != null && !Number.isNaN(Number(v))){ + total += Number(v); + found = true; + } + }); + return found ? total : null; +} +function paintRealtimePnlFromSnapshot(data){ + if(!data) return; + const perp = data.order_prices && data.order_prices.length + ? sumOrdersFloatPnl(data.order_prices) + : null; + const combined = combineRealtimeFloatPnl(perp, data.options_unrealized_pnl); + if(combined !== null || perp !== null || data.options_unrealized_pnl != null){ + paintRealtimePnl(combined); + } +} function formatOptionsFundingLabel(usdc, usdt) { const parts = []; @@ -1319,7 +1339,9 @@ function refreshPriceSnapshotConditional(){ tickOrderHoldDurations(); } if(data.order_prices && data.order_prices.length){ - paintRealtimePnl(sumOrdersFloatPnl(data.order_prices)); + paintRealtimePnlFromSnapshot(data); + } else if (typeof data.options_unrealized_pnl !== "undefined") { + paintRealtimePnlFromSnapshot(data); } }).catch(()=>{}); } diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index 7385c4e..e1eed8f 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -1597,6 +1597,26 @@ function sumOrdersFloatPnl(orders){ }); return found ? total : null; } +function combineRealtimeFloatPnl(perpTotal, optionsTotal){ + let total = 0, found = false; + [perpTotal, optionsTotal].forEach(v=>{ + if(v != null && !Number.isNaN(Number(v))){ + total += Number(v); + found = true; + } + }); + return found ? total : null; +} +function paintRealtimePnlFromSnapshot(data){ + if(!data) return; + const perp = data.order_prices && data.order_prices.length + ? sumOrdersFloatPnl(data.order_prices) + : null; + const combined = combineRealtimeFloatPnl(perp, data.options_unrealized_pnl); + if(combined !== null || perp !== null || data.options_unrealized_pnl != null){ + paintRealtimePnl(combined); + } +} function formatOptionsFundingLabel(usdc, usdt) { const parts = []; @@ -1912,7 +1932,9 @@ function refreshPriceSnapshotConditional(){ {% endif %} } if(data.order_prices && data.order_prices.length){ - paintRealtimePnl(sumOrdersFloatPnl(data.order_prices)); + paintRealtimePnlFromSnapshot(data); + } else if (typeof data.options_unrealized_pnl !== "undefined") { + paintRealtimePnlFromSnapshot(data); } }).catch(()=>{}); } diff --git a/tests/test_instance_live_pnl_lib.py b/tests/test_instance_live_pnl_lib.py index aa435a3..ab44a66 100644 --- a/tests/test_instance_live_pnl_lib.py +++ b/tests/test_instance_live_pnl_lib.py @@ -4,6 +4,7 @@ from __future__ import annotations import unittest from lib.instance.instance_live_pnl_lib import ( + merge_unrealized_pnl_components, position_row_contracts, resolve_instance_unrealized_pnl, sum_unrealized_pnl_from_metrics, @@ -53,6 +54,12 @@ class TestInstanceLivePnlLib(unittest.TestCase): total = resolve_instance_unrealized_pnl(_fetch, rows, _metrics) self.assertEqual(total, -2.5) + def test_merge_unrealized_pnl_components(self): + self.assertEqual(merge_unrealized_pnl_components(-0.11, 0.02), -0.09) + self.assertEqual(merge_unrealized_pnl_components(None, 0.02), 0.02) + self.assertEqual(merge_unrealized_pnl_components(-0.11, None), -0.11) + self.assertIsNone(merge_unrealized_pnl_components(None, None)) + if __name__ == "__main__": unittest.main()