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
+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