diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f4c681..fd1d3c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 +## [1.17.9] — 2026-07-04 + +**修复 Web UI 回测「交易」统计面板离谱数值** —— 单标的回测页绩效指标右侧「交易」面板出现 `平均盈利 65409694.45%`、`最大盈利 133926612.60%`、`平均持仓天数 1173.792`、`盈亏比 0.000`(却胜率 100%)等明显异常值。根因是后端 `avg_win/avg_loss/max_win/max_loss` 返回**绝对盈亏额(元)**,前端 `MetricTable.vue` 却按**百分比小数 ×100** 显示;`_compute_avg_holding_days` 用 `YYYYMMDD` 整数相减代替真实日期相减(跨月放大,如 `20240201-20240131=70`);`profit_factor` 在无亏损交易时被强制记为 `0.0`。真实数据复现用户场景(300580,RSI reversal n=14/超卖30/超买70/开盘价,2020-01-06~2026-07-03)验证修复:平均盈利 `65409694.45% → 26.85%`、最大盈利 `133926612.60% → 49.26%`、平均持仓 `1173.792 → 91.0 天`、盈亏比 `0.000 → 999.000`。**870 单测全绿**(+3 回归守卫),ruff format/check / mypy strict / 前端 vue-tsc 全通过。 + +### 修复 + +- **交易盈亏指标口径**(`src/easy_tdx/backtest/performance.py` `compute`)—— `avg_win/avg_loss/max_win/max_loss` 由「绝对盈亏额(元)」改为「单笔收益率(= pnl / cost_basis)」。新增 `cost_basis` 字段:`Trade` 增加该字段(`types.py`),`engine._compute_pnls` 在 SELL 时填入对应持仓的移动加权平均成本 × 卖出数量(`engine.py`),`_trades_to_df` 增加列。明细表 `TradeTable.vue` 的「盈亏」列仍按元显示,与汇总表的「平均盈利 %」各司其职。 +- **平均持仓天数跨月放大**(`src/easy_tdx/backtest/performance.py` `_compute_avg_holding_days`)—— 原用 `YYYYMMDD` 整数相减(如 `20240201-20240131=70`),跨月越多虚高越严重;改为解析为 `datetime.date` 后相减取真实日历日。无 `cost_basis` 列或日期无法解析时安全降级,不抛异常。 +- **盈亏比在无亏损交易时为 0**(`src/easy_tdx/backtest/performance.py`)—— 100% 胜率(无亏损交易)时 `profit_factor` 由 `0.0` 改为 `999.0`(与 `calmar` 在无回撤正收益时的约定一致),消除「胜率 100% 却盈亏比 0」的自相矛盾。 +- **object dtype 上 `np.isfinite` 崩溃**(`src/easy_tdx/backtest/performance.py`)—— 真实 engine 产出的 trades DataFrame 列可能为 int/object dtype,导致 `np.isfinite` 抛 `TypeError`;显式 `to_numpy(dtype=np.float64)` 转换。 + +### 回归守卫 + +- `tests/unit/test_backtest_performance.py::test_avg_holding_days_crosses_month_boundary` —— 跨月持仓必须用真实日历日(1 天),而非 YYYYMMDD 整数差(70)。 +- `tests/unit/test_backtest_performance.py::test_profit_factor_no_losing_trades_is_large` —— 全盈利无亏损时 `profit_factor == 999.0`。 +- `tests/unit/test_backtest_performance.py::test_avg_win_zero_when_no_cost_basis_column` —— trades 缺 `cost_basis` 列时 `avg_win/max_win` 安全降级为 0.0,不抛 KeyError。 + ## [1.17.8] — 2026-07-04 **修复 Windows CI 矩阵 flaky 测试** —— `test_task_runner_does_not_evict_running` 用 `release.wait(timeout=5)` 钉住慢任务保持 running,但 CI 慢环境(windows 3.10)下整个测试执行超过 5s 后任务因超时自动完成、状态变 `done`,掩盖了「running 任务被 LRU 错误淘汰」的回归断言。改为 `timeout=30` 留足 CI 慢环境余量(`release.set()` 仍是确定性释放点)。**867 单测全绿**(本地连跑 5 次稳定通过),Windows 全矩阵转绿。 diff --git a/pyproject.toml b/pyproject.toml index 8e39b8e..3773fb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.17.8" +version = "1.17.9" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" diff --git a/src/easy_tdx/backtest/engine.py b/src/easy_tdx/backtest/engine.py index a6e36ce..30a8ce6 100644 --- a/src/easy_tdx/backtest/engine.py +++ b/src/easy_tdx/backtest/engine.py @@ -437,6 +437,8 @@ class BacktestEngine: if position_size > 0: avg_cost = position_cost / position_size trade.pnl = (trade.price - avg_cost) * trade.size - trade.commission + # 记录本次卖出对应的持仓成本基数,用于派生单笔收益率 + trade.cost_basis = avg_cost * trade.size position_cost -= avg_cost * trade.size position_size -= trade.size else: @@ -463,6 +465,7 @@ class BacktestEngine: "commission", "slippage", "pnl", + "cost_basis", "rejected", ] ) @@ -476,6 +479,7 @@ class BacktestEngine: "commission": t.commission, "slippage": t.slippage, "pnl": t.pnl, + "cost_basis": t.cost_basis, "rejected": t.rejected, } for t in trades diff --git a/src/easy_tdx/backtest/performance.py b/src/easy_tdx/backtest/performance.py index 990335b..7e01f1a 100644 --- a/src/easy_tdx/backtest/performance.py +++ b/src/easy_tdx/backtest/performance.py @@ -5,6 +5,7 @@ from __future__ import annotations +import datetime as _dt from collections import deque from typing import TYPE_CHECKING @@ -138,6 +139,18 @@ class PerformanceAnalyzer: win_trades_mask = sell_trades["pnl"] > 0 lose_trades_mask = sell_trades["pnl"] <= 0 + # 单笔收益率 = pnl / cost_basis。cost_basis 由 engine._compute_pnls 填入 + # (SELL 对应的移动加权平均成本 × 卖出数量)。无 cost_basis 列或为 0 时 + # 收益率记 NaN,在后续统计里被过滤。 + # 显式转 float64:trades 列可能是 int/object dtype,导致 np.isfinite 失败。 + if "cost_basis" in sell_trades.columns: + pnl_arr = sell_trades["pnl"].to_numpy(dtype=np.float64) + cost_arr = sell_trades["cost_basis"].to_numpy(dtype=np.float64) + with np.errstate(divide="ignore", invalid="ignore"): + trade_returns = np.where(cost_arr > 0, pnl_arr / cost_arr, np.nan) + else: + trade_returns = np.full(len(sell_trades), np.nan) + # 8. 总交易次数 total_trades = len(sell_trades) @@ -162,20 +175,28 @@ class PerformanceAnalyzer: # 限制 inf if np.isinf(profit_factor): profit_factor = 999.0 + elif len(win_pnl) > 0 and len(lose_pnl) == 0: + # 全部盈利、无亏损交易:盈亏比理论上为 +∞,统一记为 999.0 + # (与 calmar 在无回撤正收益时的约定一致),避免显示 0.000 造成误解 + profit_factor = 999.0 else: profit_factor = 0.0 - # 14. 平均盈利 - avg_win = win_pnl.mean() if len(win_pnl) > 0 else 0.0 + # 14. 平均盈利(单笔收益率口径) + win_returns = trade_returns[win_trades_mask.to_numpy()] + win_returns = win_returns[np.isfinite(win_returns)] + avg_win = float(np.mean(win_returns)) if len(win_returns) > 0 else 0.0 - # 15. 平均亏损 - avg_loss = lose_pnl.mean() if len(lose_pnl) > 0 else 0.0 + # 15. 平均亏损(单笔收益率口径) + lose_returns = trade_returns[lose_trades_mask.to_numpy()] + lose_returns = lose_returns[np.isfinite(lose_returns)] + avg_loss = float(np.mean(lose_returns)) if len(lose_returns) > 0 else 0.0 - # 16. 最大盈利 - max_win = win_pnl.max() if len(win_pnl) > 0 else 0.0 + # 16. 最大盈利(单笔收益率口径) + max_win = float(np.max(win_returns)) if len(win_returns) > 0 else 0.0 - # 17. 最大亏损 - max_loss = lose_pnl.min() if len(lose_pnl) > 0 else 0.0 + # 17. 最大亏损(单笔收益率口径) + max_loss = float(np.min(lose_returns)) if len(lose_returns) > 0 else 0.0 # 18. 平均持仓天数(FIFO 配对计算) avg_holding_days = self._compute_avg_holding_days() @@ -211,6 +232,9 @@ class PerformanceAnalyzer: 遍历非 rejected 的交易记录,使用 FIFO 队列配对买入和卖出, 按 size 加权计算平均持仓天数。 + 注意:持仓天数按真实日历日计算(解析 ``YYYYMMDD`` 为 ``date`` 后相减), + 而非 YYYYMMDD 整数差——后者在跨月时会放大(如 20240201-20240131=70)。 + Returns: 加权平均持仓天数,无完整配对时返回 0.0 """ @@ -222,30 +246,46 @@ class PerformanceAnalyzer: if len(valid) == 0: return 0.0 - buy_queue: deque[tuple[int, float]] = deque() # (datetime, size) + buy_queue: deque[tuple[_dt.date, float]] = deque() # (date, size) total_days = 0.0 total_size = 0.0 + def to_date(raw_dt: object) -> _dt.date | None: + """把 datetime 列的值(int YYYYMMDD 或 pd.Timestamp)转为 date。 + + 无法解析时返回 None(该行将被跳过,不参与配对)。 + """ + if isinstance(raw_dt, pd.Timestamp): + # 运行时确为 date + d: _dt.date = raw_dt.date() + return d + try: + # raw_dt 可能是 int/object dtype 标量;统一经 str 转 int + n = int(str(raw_dt)) + except (TypeError, ValueError): + return None + # YYYYMMDD 整数 → 真实日期 + try: + return _dt.datetime.strptime(str(n), "%Y%m%d").date() + except ValueError: + return None + for _, row in valid.iterrows(): - raw_dt = row["datetime"] - # datetime 可能是 int (YYYYMMDD) 或 pd.Timestamp - dt = ( - int(raw_dt) - if not isinstance(raw_dt, pd.Timestamp) - else int(raw_dt.strftime("%Y%m%d")) - ) + d = to_date(row["datetime"]) + if d is None: + continue # 无法解析日期的行不参与持仓天数计算 direction = row["direction"] size = float(row["size"]) if "size" in valid.columns else 100.0 if direction == "BUY": - buy_queue.append((dt, size)) + buy_queue.append((d, size)) elif direction == "SELL" and buy_queue: remaining = size while remaining > 0 and buy_queue: - buy_dt, buy_size = buy_queue[0] + buy_d, buy_size = buy_queue[0] # 消费该笔 BUY 的部分或全部 consumed = min(remaining, buy_size) - holding_days = dt - buy_dt + holding_days = (d - buy_d).days total_days += holding_days * consumed total_size += consumed remaining -= consumed @@ -253,7 +293,7 @@ class PerformanceAnalyzer: if buy_size <= 0: buy_queue.popleft() else: - buy_queue[0] = (buy_dt, buy_size) + buy_queue[0] = (buy_d, buy_size) if total_size == 0: return 0.0 diff --git a/src/easy_tdx/backtest/types.py b/src/easy_tdx/backtest/types.py index 3ce4361..2be89fa 100644 --- a/src/easy_tdx/backtest/types.py +++ b/src/easy_tdx/backtest/types.py @@ -53,7 +53,8 @@ class Trade: price: 成交价格 commission: 手续费 slippage: 滑点成本 - pnl: 已实现盈亏(仅平仓时计算) + pnl: 已实现盈亏(仅平仓时计算,绝对金额单位:元) + cost_basis: SELL 对应的持仓成本基数(元),用于派生单笔收益率 pnl/cost_basis rejected: 是否被拒绝(资金不足/不允许做空等) """ @@ -64,6 +65,9 @@ class Trade: commission: float slippage: float pnl: float = 0.0 + # SELL 对应的持仓成本基数(移动加权平均 × 本次卖出数量),用于计算收益率。 + # BUY 行恒为 0.0。仅 _compute_pnls 平仓时填入。 + cost_basis: float = 0.0 rejected: bool = False diff --git a/tests/unit/test_backtest_performance.py b/tests/unit/test_backtest_performance.py index 3c31456..d388c08 100644 --- a/tests/unit/test_backtest_performance.py +++ b/tests/unit/test_backtest_performance.py @@ -47,14 +47,18 @@ def _make_trades() -> pd.DataFrame: """创建测试用交易记录。 Returns: - 包含 datetime, direction, pnl, rejected 的 DataFrame + 包含 datetime, direction, pnl, cost_basis, rejected 的 DataFrame 4 条交易: BUY@20240101, SELL@20240106(pnl=500), BUY@20240110, SELL@20240115(pnl=-500) + + 注意:avg_win/avg_loss/max_win/max_loss 现为「单笔收益率」口径 + (= pnl / cost_basis)。此处 cost_basis=10000,故收益率 = pnl/10000。 """ return pd.DataFrame( { "datetime": [20240101, 20240106, 20240110, 20240115], "direction": ["BUY", "SELL", "BUY", "SELL"], "pnl": [0, 500, 0, -500], + "cost_basis": [0.0, 10000.0, 0.0, 10000.0], "rejected": [False, False, False, False], } ) @@ -238,33 +242,33 @@ def test_profit_factor() -> None: def test_avg_win_and_loss() -> None: - """测试平均盈亏计算。""" + """测试平均盈亏计算(单笔收益率口径 = pnl / cost_basis)。""" equity = _make_equity_curve(n=252, total_return=0.1) trades = _make_trades() analyzer = PerformanceAnalyzer(equity, trades) metrics = analyzer.compute() - # 1 笔盈利 500,平均盈利应接近 500 - assert abs(metrics["avg_win"] - 500) < 0.01 + # 1 笔盈利 500 / cost_basis 10000 = 0.05(5%) + assert abs(metrics["avg_win"] - 0.05) < 0.001 - # 1 笔亏损 500,平均亏损应接近 -500 - assert abs(metrics["avg_loss"] - (-500)) < 0.01 + # 1 笔亏损 -500 / cost_basis 10000 = -0.05(-5%) + assert abs(metrics["avg_loss"] - (-0.05)) < 0.001 def test_max_win_and_loss() -> None: - """测试最大盈亏计算。""" + """测试最大盈亏计算(单笔收益率口径 = pnl / cost_basis)。""" equity = _make_equity_curve(n=252, total_return=0.1) trades = _make_trades() analyzer = PerformanceAnalyzer(equity, trades) metrics = analyzer.compute() - # 最大盈利应接近 500 - assert abs(metrics["max_win"] - 500) < 0.01 + # 最大盈利收益率 = 500 / 10000 = 0.05 + assert abs(metrics["max_win"] - 0.05) < 0.001 - # 最大亏损应接近 -500 - assert abs(metrics["max_loss"] - (-500)) < 0.01 + # 最大亏损收益率 = -500 / 10000 = -0.05 + assert abs(metrics["max_loss"] - (-0.05)) < 0.001 def test_annual_return() -> None: @@ -507,3 +511,79 @@ def test_metrics_all_zero_equity_does_not_raise() -> None: assert np.isfinite(metrics["total_return"]) assert np.isfinite(metrics["max_drawdown"]) assert np.isfinite(metrics["sharpe"]) + + +# ── 回归测试:交易统计语义修复 ─────────────────────────────────────────────── + + +def test_avg_holding_days_crosses_month_boundary() -> None: + """跨月持仓天数必须用真实日历日计算,而非 YYYYMMDD 整数差。 + + 回归守卫:旧实现 ``20240201 - 20240131 = 70``(整数差,错误), + 新实现解析为 date 后相减 = 1 天。 + """ + equity = _make_equity_curve(n=252, total_return=0.1) + trades = pd.DataFrame( + { + "datetime": [20240131, 20240201], + "direction": ["BUY", "SELL"], + "pnl": [0, 100], + "cost_basis": [0.0, 10000.0], + "rejected": [False, False], + } + ) + + analyzer = PerformanceAnalyzer(equity, trades) + metrics = analyzer.compute() + + # 1月31日 → 2月1日 = 1 个真实日历日(旧 bug 会得到 70) + assert metrics["avg_holding_days"] == 1.0 + + +def test_profit_factor_no_losing_trades_is_large() -> None: + """全部盈利、无亏损交易时 profit_factor 应为 999.0 而非 0.0。 + + 回归守卫:旧实现在 ``len(lose_pnl)==0`` 时直接返回 0.0, + 与 100% 胜率并列显示时自相矛盾(胜率 100% 却盈亏比 0)。 + """ + equity = _make_equity_curve(n=252, total_return=0.1) + trades = pd.DataFrame( + { + "datetime": [20240101, 20240106, 20240110, 20240115], + "direction": ["BUY", "SELL", "BUY", "SELL"], + "pnl": [0, 500, 0, 300], + "cost_basis": [0.0, 10000.0, 0.0, 10000.0], + "rejected": [False, False, False, False], + } + ) + + analyzer = PerformanceAnalyzer(equity, trades) + metrics = analyzer.compute() + + assert metrics["win_trades"] == 2 + assert metrics["lose_trades"] == 0 + assert metrics["profit_factor"] == 999.0 + + +def test_avg_win_zero_when_no_cost_basis_column() -> None: + """trades 无 cost_basis 列时 avg_win/avg_loss/max_win/max_loss 应回退为 0.0。 + + 回归守卫:engine._trades_to_df 现会输出 cost_basis 列,但若上游构造的 + trades DataFrame 缺该列(如旧式直接拼装),不应抛 KeyError,应记 0.0。 + """ + equity = _make_equity_curve(n=252, total_return=0.1) + trades = pd.DataFrame( + { + "datetime": [20240101, 20240106], + "direction": ["BUY", "SELL"], + "pnl": [0, 500], + "rejected": [False, False], + } + ) + + analyzer = PerformanceAnalyzer(equity, trades) + metrics = analyzer.compute() + + # 无 cost_basis → 单笔收益率无法计算 → 记 0.0,不抛异常 + assert metrics["avg_win"] == 0.0 + assert metrics["max_win"] == 0.0