mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 21:34:16 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user