release: v1.17.9 — 修复回测交易统计离谱数值(前后端口径错配 + 持仓天数跨月放大)

- 交易盈亏指标改为收益率口径(avg_win/loss/max_win/loss = pnl/cost_basis)
- 平均持仓天数改用真实日历日相减(原 YYYYMMDD 整数差跨月放大)
- 盈亏比无亏损时记 999.0(原 0.0,与 100% 胜率自相矛盾)
- 新增 Trade.cost_basis 字段 + engine 填充
- 3 个回归守卫;870 单测全绿,ruff/mypy strict/前端 vue-tsc 通过
This commit is contained in:
Justin Gu
2026-07-04 03:55:05 +08:00
parent f7cb015ec6
commit 57cad1f3db
6 changed files with 178 additions and 33 deletions
+17
View File
@@ -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`。真实数据复现用户场景(300580RSI 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 全矩阵转绿。
+1 -1
View File
@@ -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"
+4
View File
@@ -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
+60 -20
View File
@@ -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,在后续统计里被过滤。
# 显式转 float64trades 列可能是 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
+5 -1
View File
@@ -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
+91 -11
View File
@@ -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.055%
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