From d97f7d20e5e72f5dc0fef2f0a7c4911525c30620 Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 2 Aug 2026 17:05:11 +0800 Subject: [PATCH] Fix funds bar day stats: align trades, win rate, and PL ratio. Co-authored-by: Cursor --- backend/app/api/funds.py | 25 +++--- backend/tests/test_funds_summary.py | 123 ++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 15 deletions(-) create mode 100644 backend/tests/test_funds_summary.py diff --git a/backend/app/api/funds.py b/backend/app/api/funds.py index 8bf1887..f1f187b 100644 --- a/backend/app/api/funds.py +++ b/backend/app/api/funds.py @@ -66,26 +66,21 @@ async def funds_summary(_user: Annotated[str, Depends(require_user)]) -> dict[st exchange = str(st.get("exchange") or s.exchange or "okx").upper() trading_day = datetime.now(SH).strftime("%Y-%m-%d") - closed = db.fetchall( - "SELECT realized_pnl, close_at_ms FROM groups WHERE status='closed'" - ) - pnls = [float(r["realized_pnl"] or 0) for r in closed] - n = len(pnls) - wins = sum(1 for x in pnls if x > 0) - win_rate = (wins / n) if n else 0.0 - - # 当日成交组 + # 顶栏「总交易 / 胜率 / 盈亏比」与交易日同一口径:上海自然日开仓组 G-YYYYMMDD-* day_prefix = trading_day.replace("-", "") day_groups = db.fetchall( "SELECT realized_pnl FROM groups WHERE group_id LIKE ? AND status='closed'", (f"G-{day_prefix}-%",), ) - day_n = len(day_groups) + day_pnls = [float(r["realized_pnl"] or 0) for r in day_groups] + day_n = len(day_pnls) + day_wins = sum(1 for x in day_pnls if x > 0) + day_win_rate = (day_wins / day_n) if day_n else 0.0 pos = st.get("position") or {} - upl = pos realtime = None - if str(pos.get("status") or "") == "open": + pos_st = str(pos.get("status") or "") + if pos_st in ("open", "half_open", "option_closed_perp_pending"): realtime = float(pos.get("net_pnl") or 0) if s.is_sim: @@ -144,9 +139,9 @@ async def funds_summary(_user: Annotated[str, Depends(require_user)]) -> dict[st "mode": mode, "exchange": exchange, "trading_day": trading_day, - "total_trades": day_n if day_n else n, - "win_rate": win_rate, - "profit_loss_ratio": _pl_ratio(pnls), + "total_trades": day_n, + "win_rate": day_win_rate, + "profit_loss_ratio": _pl_ratio(day_pnls), "total_funds": total, "funding_usdt": funding_usdt, "trading_usdt": trading_usdt, diff --git a/backend/tests/test_funds_summary.py b/backend/tests/test_funds_summary.py new file mode 100644 index 0000000..8c88af0 --- /dev/null +++ b/backend/tests/test_funds_summary.py @@ -0,0 +1,123 @@ +"""顶栏资金摘要:总交易/胜率/盈亏比与交易日同口径。""" + +from __future__ import annotations + +import asyncio +from datetime import datetime +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +from app.api import funds as funds_api +from app.models.db import Database, set_db +from app.strategy.engine import set_engine + + +def _insert_closed(db: Database, *, group_id: str, pnl: float) -> None: + now = 1_700_000_000_000 + with db._lock: + db._conn.execute( + """INSERT INTO groups( + group_id, status, bias, option_side, perp_side, option_inst_id, + strike, expiry_ymd, initial_premium, open_at_ms, close_at_ms, + close_reason, realized_pnl, fees, slip_cost + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "closed", + "test", + "call", + "short", + "ETH-USD_UM-260801-2000-C", + 2000.0, + "260801", + 100.0, + now, + now + 1000, + "manual", + pnl, + 1.0, + 0.0, + ), + ) + db._conn.commit() + + +def test_day_stats_not_mixed_with_history(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("MODE", "SIM") + db = Database(tmp_path / "funds_sum.db") + set_db(db) + try: + # 历史 3 胜 1 负 → 75%;当日仅 1 笔亏损 + _insert_closed(db, group_id="G-20260101-01", pnl=10.0) + _insert_closed(db, group_id="G-20260101-02", pnl=20.0) + _insert_closed(db, group_id="G-20260101-03", pnl=5.0) + _insert_closed(db, group_id="G-20260101-04", pnl=-10.0) + _insert_closed(db, group_id="G-20260802-01", pnl=-8.0) + + class _FakeDT: + @staticmethod + def now(tz=None): + from datetime import timezone + + if tz is timezone.utc: + return datetime(2026, 8, 2, 4, 0, tzinfo=timezone.utc) + return datetime(2026, 8, 2, 12, 0, tzinfo=tz or ZoneInfo("Asia/Shanghai")) + + monkeypatch.setattr(funds_api, "datetime", _FakeDT) + + eng = SimpleNamespace( + state=lambda: { + "exchange": "okx", + "position": {"status": "flat", "net_pnl": None}, + } + ) + set_engine(eng) # type: ignore[arg-type] + + body = asyncio.run(funds_api.funds_summary(_user="admin")) + assert body["ok"] is True + assert body["trading_day"] == "2026-08-02" + assert body["total_trades"] == 1 + assert body["win_rate"] == 0.0 + assert body["profit_loss_ratio"] is None + finally: + set_engine(None) # type: ignore[arg-type] + set_db(None) + db.close() + + +def test_day_win_rate_matches_day_trades(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("MODE", "SIM") + db = Database(tmp_path / "funds_sum2.db") + set_db(db) + try: + _insert_closed(db, group_id="G-20260802-01", pnl=10.0) + _insert_closed(db, group_id="G-20260802-02", pnl=20.0) + _insert_closed(db, group_id="G-20260802-03", pnl=-5.0) + + class _FakeDT: + @staticmethod + def now(tz=None): + from datetime import timezone + + if tz is timezone.utc: + return datetime(2026, 8, 2, 4, 0, tzinfo=timezone.utc) + return datetime(2026, 8, 2, 12, 0, tzinfo=tz or ZoneInfo("Asia/Shanghai")) + + monkeypatch.setattr(funds_api, "datetime", _FakeDT) + set_engine( + SimpleNamespace( + state=lambda: { + "exchange": "okx", + "position": {"status": "flat"}, + } + ) # type: ignore[arg-type] + ) + + body = asyncio.run(funds_api.funds_summary(_user="admin")) + assert body["total_trades"] == 3 + assert abs(body["win_rate"] - (2 / 3)) < 1e-9 + assert body["profit_loss_ratio"] is not None + finally: + set_engine(None) # type: ignore[arg-type] + set_db(None) + db.close()