diff --git a/lib/hub/hub_monitor_totals_lib.py b/lib/hub/hub_monitor_totals_lib.py index 9bba504..7c7f4f3 100644 --- a/lib/hub/hub_monitor_totals_lib.py +++ b/lib/hub/hub_monitor_totals_lib.py @@ -7,6 +7,7 @@ from lib.hub.hub_options_funds_lib import ( options_float_pnl_usdt, options_open_position_count as count_options_positions, ) +from lib.hub.hub_position_metrics import is_option_like_position def _coerce_float(value: Any) -> float | None: @@ -27,6 +28,27 @@ def position_unrealized_pnl(pos: dict[str, Any]) -> float: def _open_positions(agent: dict[str, Any] | None) -> list[dict[str, Any]]: + if not isinstance(agent, dict): + return [] + positions = agent.get("positions") + if not isinstance(positions, list): + return [] + out: list[dict[str, Any]] = [] + for p in positions: + if not isinstance(p, dict): + continue + if is_option_like_position(p): + continue + try: + c = abs(float(p.get("contracts") or 0)) + except (TypeError, ValueError): + c = 0.0 + if c > 1e-12: + out.append(p) + return out + + +def _raw_open_positions(agent: dict[str, Any] | None) -> list[dict[str, Any]]: if not isinstance(agent, dict): return [] positions = agent.get("positions") @@ -79,8 +101,11 @@ def aggregate_monitor_board_totals( ag = row.get("agent") if isinstance(row.get("agent"), dict) else {} open_pos = _open_positions(ag) open_position_count += len(open_pos) + raw_pos = _raw_open_positions(ag) + contaminated = any(is_option_like_position(p) for p in raw_pos) agent_upnl = _coerce_float(ag.get("total_unrealized_pnl")) - if agent_upnl is not None: + # 子代理若把期权混进永续合计,改按过滤后腿求和;期权浮盈由下方 options 段计入 + if agent_upnl is not None and not contaminated: float_pnl_u += agent_upnl else: float_pnl_u += sum(position_unrealized_pnl(p) for p in open_pos) diff --git a/lib/hub/hub_position_metrics.py b/lib/hub/hub_position_metrics.py index f14227e..fc62640 100644 --- a/lib/hub/hub_position_metrics.py +++ b/lib/hub/hub_position_metrics.py @@ -2,6 +2,7 @@ from __future__ import annotations import math +import re from typing import Any, Callable @@ -23,6 +24,45 @@ def _coerce_float(*values: Any) -> float | None: return None +# OKX ccxt: ETH/USD:USD-260806-1875-C ; instId: ETH-USD-260806-1875-C +_OPTION_SYM_RE = re.compile( + r"(?:^|[/:])[A-Z0-9]+(?:-USD)?(?::USD)?-\d{6}-\d+-(?:C|P|CALL|PUT)$", + re.IGNORECASE, +) + + +def is_option_like_position(pos: dict[str, Any] | None) -> bool: + """识别期权仓(子代理/中控浮盈合计须排除,避免按永续线性公式误算).""" + if not isinstance(pos, dict): + return False + info = pos.get("info") if isinstance(pos.get("info"), dict) else {} + inst_type = str( + info.get("instType") + or info.get("inst_type") + or pos.get("type") + or "" + ).upper() + if inst_type in ("OPTION", "OPT"): + return True + sym = str( + pos.get("symbol") + or info.get("instId") + or info.get("instrument_name") + or info.get("contract") + or "" + ).strip() + if not sym: + return False + if _OPTION_SYM_RE.search(sym.replace(" ", "")): + return True + su = sym.upper() + if su.endswith("-C") or su.endswith("-P") or su.endswith("-CALL") or su.endswith("-PUT"): + # 永续多为 BTC/USDT:USDT;期权常带到期日段 + if re.search(r"-\d{6}-\d+-(?:C|P|CALL|PUT)$", su): + return True + return False + + CONTRACTS_QTY_DECIMALS = 2 diff --git a/manual_trading_hub/agent.py b/manual_trading_hub/agent.py index 009d215..304f1d2 100644 --- a/manual_trading_hub/agent.py +++ b/manual_trading_hub/agent.py @@ -578,7 +578,14 @@ def _status_inner(x_control_token: str | None) -> Any: positions_out: list[dict[str, Any]] = [] total_upnl = 0.0 try: - raw = ex.fetch_positions() or [] + # OKX 统一账户 fetch_positions 可能混入期权;优先只要永续,再二次过滤 + if EXCHANGE_KIND == "okx": + try: + raw = ex.fetch_positions(params={"instType": "SWAP"}) or [] + except Exception: + raw = ex.fetch_positions() or [] + else: + raw = ex.fetch_positions() or [] except Exception as e: return JSONResponse( { @@ -592,9 +599,13 @@ def _status_inner(x_control_token: str | None) -> Any: status_code=200, ) + from lib.hub.hub_position_metrics import is_option_like_position + for p in raw: if not isinstance(p, dict): continue + if is_option_like_position(p): + continue c = _position_contracts(p) if abs(c) < 1e-12: continue @@ -807,13 +818,23 @@ def emergency_close_all(x_control_token: str | None = Header(default=None, alias closed: list[dict[str, Any]] = [] try: - raw = ex.fetch_positions() or [] + if EXCHANGE_KIND == "okx": + try: + raw = ex.fetch_positions(params={"instType": "SWAP"}) or [] + except Exception: + raw = ex.fetch_positions() or [] + else: + raw = ex.fetch_positions() or [] except Exception as e: raise HTTPException(status_code=502, detail=f"fetch_positions: {e}") from e + from lib.hub.hub_position_metrics import is_option_like_position + for p in raw: if not isinstance(p, dict): continue + if is_option_like_position(p): + continue c = _position_contracts(p) if abs(c) < 1e-12: continue diff --git a/tests/test_hub_agent_mark_price.py b/tests/test_hub_agent_mark_price.py index 5f5cb9c..3a19028 100644 --- a/tests/test_hub_agent_mark_price.py +++ b/tests/test_hub_agent_mark_price.py @@ -14,6 +14,7 @@ sys.path.insert(0, str(ROOT)) from lib.hub.hub_position_metrics import ( # noqa: E402 enrich_ccxt_position_metrics_out, estimate_linear_swap_upnl_usdt, + is_option_like_position, parse_position_unrealized_pnl, resolve_position_display_upnl, ) @@ -89,6 +90,20 @@ class TestHubAgentMarkPrice(unittest.TestCase): ) self.assertAlmostEqual(shown, 7.86, places=2) + def test_is_option_like_position(self): + self.assertTrue( + is_option_like_position({"symbol": "ETH/USD:USD-260806-1875-C", "contracts": 1}) + ) + self.assertTrue( + is_option_like_position( + {"symbol": "x", "info": {"instType": "OPTION", "instId": "ETH-USD-260806-1875-P"}} + ) + ) + self.assertFalse( + is_option_like_position({"symbol": "BTC/USDT:USDT", "contracts": 10}) + ) + self.assertFalse(is_option_like_position({"symbol": "ETH/USDT:USDT"})) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_hub_monitor_totals_lib.py b/tests/test_hub_monitor_totals_lib.py index dc7d6e5..238e3aa 100644 --- a/tests/test_hub_monitor_totals_lib.py +++ b/tests/test_hub_monitor_totals_lib.py @@ -1,75 +1,114 @@ +"""中控监控区今日统计聚合.""" +import unittest + from lib.hub.hub_monitor_totals_lib import aggregate_monitor_board_totals +from lib.hub.hub_trades_lib import summarize_trades -def test_aggregate_monitor_board_totals_sums_rows(): - rows = [ - { - "day_stats": { - "ok": True, - "opens_today": 2, - "trade_stats": { - "closed_count": 1, - "win_count": 1, - "loss_count": 0, - "win_pnl_u": 5.5, - "loss_pnl_u": 0, +class TestHubMonitorTotals(unittest.TestCase): + def test_aggregate_monitor_board_totals_sums_rows(self): + rows = [ + { + "day_stats": { + "ok": True, + "opens_today": 2, + "trade_stats": { + "closed_count": 1, + "win_count": 1, + "loss_count": 0, + "win_pnl_u": 5.5, + "loss_pnl_u": 0, + }, }, + "agent": {"positions": [{"contracts": 1}], "total_unrealized_pnl": 1.2}, }, - "agent": {"positions": [{"contracts": 1}], "total_unrealized_pnl": 1.2}, - }, - { - "day_stats": { - "ok": True, - "opens_today": 1, - "trade_stats": { - "closed_count": 2, - "win_count": 0, - "loss_count": 2, - "win_pnl_u": 0, - "loss_pnl_u": -3.0, + { + "day_stats": { + "ok": True, + "opens_today": 1, + "trade_stats": { + "closed_count": 2, + "win_count": 0, + "loss_count": 2, + "win_pnl_u": 0, + "loss_pnl_u": -3.0, + }, }, + "agent": {"positions": [], "total_unrealized_pnl": 0}, }, - "agent": {"positions": [], "total_unrealized_pnl": 0}, - }, - ] - out = aggregate_monitor_board_totals(rows, trading_day="2026-07-04", reset_hour=8) - assert out["open_count"] == 3 - assert out["closed_count"] == 3 - assert out["win_count"] == 1 - assert out["loss_count"] == 2 - assert out["win_pnl_u"] == 5.5 - assert out["loss_pnl_u"] == -3.0 - assert out["open_position_count"] == 1 - assert out["float_pnl_u"] == 1.2 + ] + out = aggregate_monitor_board_totals(rows, trading_day="2026-07-04", reset_hour=8) + self.assertEqual(out["open_count"], 3) + self.assertEqual(out["closed_count"], 3) + self.assertEqual(out["win_count"], 1) + self.assertEqual(out["loss_count"], 2) + self.assertEqual(out["win_pnl_u"], 5.5) + self.assertEqual(out["loss_pnl_u"], -3.0) + self.assertEqual(out["open_position_count"], 1) + self.assertEqual(out["float_pnl_u"], 1.2) + + def test_aggregate_monitor_board_totals_includes_options(self): + rows = [ + { + "capabilities": ["options"], + "options": { + "ok": True, + "enabled": True, + "positions": [{"inst_id": "X"}, {"inst_id": "Y"}], + "upl_total_usdc": 1.5, + }, + "agent": {"positions": [], "total_unrealized_pnl": 0}, + } + ] + out = aggregate_monitor_board_totals(rows, trading_day="2026-07-04", reset_hour=8) + self.assertEqual(out["options_open_position_count"], 2) + self.assertEqual(out["open_position_count"], 2) + self.assertEqual(out["options_float_pnl_u"], 1.5) + self.assertEqual(out["float_pnl_u"], 1.5) + + def test_aggregate_excludes_option_like_agent_positions(self): + """子代理误把期权当永续上报时:不算进持仓数,浮盈只用期权 snap.""" + rows = [ + { + "capabilities": ["options"], + "options": { + "ok": True, + "enabled": True, + "positions": [{"inst_id": "ETH-USD-260806-1875-C"}], + "upl_total_usdc": -0.4, + }, + "agent": { + "positions": [ + { + "symbol": "ETH/USD:USD-260806-1875-C", + "contracts": 66, + "unrealized_pnl": 27.6, + }, + { + "symbol": "BTC/USDT:USDT", + "contracts": 1, + "unrealized_pnl": -4.66, + }, + ], + "total_unrealized_pnl": 22.94, + }, + } + ] + out = aggregate_monitor_board_totals(rows, trading_day="2026-08-05", reset_hour=8) + self.assertEqual(out["open_position_count"], 2) + self.assertEqual(out["options_open_position_count"], 1) + self.assertEqual(out["options_float_pnl_u"], -0.4) + self.assertEqual(out["float_pnl_u"], round(-4.66 + (-0.4), 4)) + + def test_summarize_trades_win_loss_amounts(self): + stats = summarize_trades( + [{"pnl_amount": 2.5}, {"pnl_amount": -1.0}, {"pnl_amount": 0}] + ) + self.assertEqual(stats["win_count"], 1) + self.assertEqual(stats["loss_count"], 1) + self.assertEqual(stats["win_pnl_u"], 2.5) + self.assertEqual(stats["loss_pnl_u"], -1.0) -def test_aggregate_monitor_board_totals_includes_options(): - rows = [ - { - "capabilities": ["options"], - "options": { - "ok": True, - "enabled": True, - "positions": [{"inst_id": "X"}, {"inst_id": "Y"}], - "upl_total_usdc": 1.5, - }, - "agent": {"positions": [], "total_unrealized_pnl": 0}, - } - ] - out = aggregate_monitor_board_totals(rows, trading_day="2026-07-04", reset_hour=8) - assert out["options_open_position_count"] == 2 - assert out["open_position_count"] == 2 - assert out["options_float_pnl_u"] == 1.5 - assert out["float_pnl_u"] == 1.5 - - -def test_summarize_trades_win_loss_amounts(): - from lib.hub.hub_trades_lib import summarize_trades - - stats = summarize_trades( - [{"pnl_amount": 2.5}, {"pnl_amount": -1.0}, {"pnl_amount": 0}] - ) - assert stats["win_count"] == 1 - assert stats["loss_count"] == 1 - assert stats["win_pnl_u"] == 2.5 - assert stats["loss_pnl_u"] == -1.0 +if __name__ == "__main__": + unittest.main()