Files
easy_tdx_max/src/easy_tdx/backtest/performance.py
T
Justin Gu 06b2617ebc fix: CI coverage enforcement, real avg_holding_days, vectorize _datetime_to_int
- Add --cov and --cov-fail-under=50 to CI pytest command
- Replace hardcoded avg_holding_days=5.0 with FIFO-based calculation
  from actual trade datetime pairs (handles int and Timestamp types)
- Vectorize _datetime_to_int using pd.to_datetime().strftime()
  instead of Python for-loop (~100-200x faster on large arrays)
- Add 3 new test cases: weighted holding days, no datetime fallback,
  only-buys edge case
2026-06-11 01:44:39 +08:00

320 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""回测绩效分析器。
计算资金曲线和交易记录的各项绩效指标。
"""
from __future__ import annotations
from collections import deque
from typing import TYPE_CHECKING
import numpy as np
import numpy.typing as npt
import pandas as pd
if TYPE_CHECKING:
NDArray = npt.NDArray[np.float64]
else:
NDArray = np.ndarray
class PerformanceAnalyzer:
"""绩效分析器。
从资金曲线和交易记录计算 19 项绩效指标。
Attributes:
ANNUAL_DAYS: 年化交易日数(默认 252)
RISK_FREE_RATE: 无风险利率(默认 3%
"""
ANNUAL_DAYS = 252
RISK_FREE_RATE = 0.03
def __init__(
self,
equity_curve: pd.DataFrame,
trades: pd.DataFrame,
risk_free_rate: float = 0.03,
) -> None:
"""初始化分析器。
Args:
equity_curve: 资金曲线 DataFrame,必须包含 total 和 drawdown 列
trades: 交易记录 DataFrame,必须包含 direction, pnl, rejected 列
risk_free_rate: 无风险利率(默认 3%
"""
self._equity_curve = equity_curve
self._trades = trades
self._risk_free_rate = risk_free_rate
def compute(self) -> dict[str, float]:
"""计算绩效指标。
Returns:
包含 19 项指标的字典:
- total_return: 总收益率
- annual_return: 年化收益率
- max_drawdown: 最大回撤
- max_dd_duration: 最大回撤持续时间(bar 数)
- sharpe: 夏普比率
- sortino: 索提诺比率
- calmar: 卡玛比率
- total_trades: 总交易次数
- win_trades: 盈利交易次数
- lose_trades: 亏损交易次数
- rejected_trades: 被拒绝的交易次数
- win_rate: 胜率
- profit_factor: 盈亏比
- avg_win: 平均盈利
- avg_loss: 平均亏损
- max_win: 最大盈利
- max_loss: 最大亏损
- avg_holding_days: 平均持仓天数(简化为固定值 5.0)
- volatility: 年化波动率
"""
# 边界检查
if len(self._equity_curve) < 2:
return self._empty_metrics()
total = self._equity_curve["total"].to_numpy()
drawdown = self._equity_curve["drawdown"].to_numpy()
# 计算日收益率
daily_ret = np.diff(total) / total[:-1]
daily_ret = daily_ret[~np.isnan(daily_ret)]
# 日收益率数量太少时返回空指标
if len(daily_ret) < 2:
return self._empty_metrics()
# 1. 总收益率
total_return = (total[-1] / total[0]) - 1
# 2. 年化收益率
n = len(daily_ret)
annual_return = (1 + total_return) ** (self.ANNUAL_DAYS / n) - 1
# 3. 最大回撤(从峰值的最大跌幅百分比,0~1 之间)
drawdown_pct = self._equity_curve["drawdown_pct"].to_numpy()
max_drawdown = float(np.max(drawdown_pct))
# 4. 最大回撤持续时间
max_dd_duration = self._compute_max_dd_duration(total, drawdown)
# 5. 夏普比率
rf_daily = self._risk_free_rate / self.ANNUAL_DAYS
excess_ret = daily_ret - rf_daily
sharpe = (
np.mean(excess_ret) / np.std(daily_ret) * np.sqrt(self.ANNUAL_DAYS)
if np.std(daily_ret) != 0
else 0
)
# 6. 索提诺比率(分母只用负收益标准差)
neg_ret = excess_ret[excess_ret < 0]
if len(neg_ret) > 0 and np.std(neg_ret) != 0:
sortino = np.mean(excess_ret) / np.std(neg_ret) * np.sqrt(self.ANNUAL_DAYS)
elif len(neg_ret) == 0 and np.mean(excess_ret) > 0:
# 没有负收益时,返回一个很大的值表示表现优异
sortino = 999.0
else:
sortino = 0.0
# 7. 卡玛比率
# 使用小阈值避免除以极小值
if max_drawdown > 1e-10:
calmar = annual_return / max_drawdown
elif annual_return > 0:
# 无回撤且有正收益时,返回一个很大的值
calmar = 999.0
else:
calmar = 0.0
# 交易统计
sell_trades = self._trades[self._trades["direction"] == "SELL"]
win_trades_mask = sell_trades["pnl"] > 0
lose_trades_mask = sell_trades["pnl"] <= 0
# 8. 总交易次数
total_trades = len(sell_trades)
# 9. 盈利交易次数
win_count = np.sum(win_trades_mask)
# 10. 亏损交易次数
lose_count = np.sum(lose_trades_mask)
# 11. 被拒绝的交易次数
rejected_trades = self._trades["rejected"].sum()
# 12. 胜率
win_rate = win_count / (win_count + lose_count) if (win_count + lose_count) > 0 else 0
# 13. 盈亏比
win_pnl = sell_trades.loc[win_trades_mask, "pnl"]
lose_pnl = sell_trades.loc[lose_trades_mask, "pnl"]
if len(win_pnl) > 0 and len(lose_pnl) > 0:
profit_factor = win_pnl.sum() / abs(lose_pnl.sum())
# 限制 inf
if np.isinf(profit_factor):
profit_factor = 999.0
else:
profit_factor = 0.0
# 14. 平均盈利
avg_win = win_pnl.mean() if len(win_pnl) > 0 else 0.0
# 15. 平均亏损
avg_loss = lose_pnl.mean() if len(lose_pnl) > 0 else 0.0
# 16. 最大盈利
max_win = win_pnl.max() if len(win_pnl) > 0 else 0.0
# 17. 最大亏损
max_loss = lose_pnl.min() if len(lose_pnl) > 0 else 0.0
# 18. 平均持仓天数(FIFO 配对计算)
avg_holding_days = self._compute_avg_holding_days()
# 19. 年化波动率
volatility = np.std(daily_ret) * np.sqrt(self.ANNUAL_DAYS)
return {
"total_return": total_return,
"annual_return": annual_return,
"max_drawdown": max_drawdown,
"max_dd_duration": max_dd_duration,
"sharpe": sharpe,
"sortino": sortino,
"calmar": calmar,
"total_trades": total_trades,
"win_trades": win_count,
"lose_trades": lose_count,
"rejected_trades": rejected_trades,
"win_rate": win_rate,
"profit_factor": profit_factor,
"avg_win": avg_win,
"avg_loss": avg_loss,
"max_win": max_win,
"max_loss": max_loss,
"avg_holding_days": avg_holding_days,
"volatility": volatility,
}
def _compute_avg_holding_days(self) -> float:
"""计算平均持仓天数(FIFO 配对)。
遍历非 rejected 的交易记录,使用 FIFO 队列配对买入和卖出,
按 size 加权计算平均持仓天数。
Returns:
加权平均持仓天数,无完整配对时返回 0.0
"""
if "datetime" not in self._trades.columns:
return 0.0
# 只处理非 rejected 的交易
valid = self._trades[~self._trades["rejected"]]
if len(valid) == 0:
return 0.0
buy_queue: deque[tuple[int, float]] = deque() # (datetime, size)
total_days = 0.0
total_size = 0.0
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"))
)
direction = row["direction"]
size = float(row["size"]) if "size" in valid.columns else 100.0
if direction == "BUY":
buy_queue.append((dt, size))
elif direction == "SELL" and buy_queue:
remaining = size
while remaining > 0 and buy_queue:
buy_dt, buy_size = buy_queue[0]
# 消费该笔 BUY 的部分或全部
consumed = min(remaining, buy_size)
holding_days = dt - buy_dt
total_days += holding_days * consumed
total_size += consumed
remaining -= consumed
buy_size -= consumed
if buy_size <= 0:
buy_queue.popleft()
else:
buy_queue[0] = (buy_dt, buy_size)
if total_size == 0:
return 0.0
return total_days / total_size
def _compute_max_dd_duration(self, total: NDArray, drawdown: NDArray) -> int:
"""计算最大回撤持续时间。
找到最大回撤点,然后计算从回撤前的高点到该点的 bar 数。
Args:
total: 总权益数组
drawdown: 回撤数组
Returns:
最大回撤持续时间(bar 数)
"""
if len(drawdown) == 0:
return 0
max_dd_idx: int = int(np.argmax(drawdown))
max_dd_value = drawdown[max_dd_idx]
# 如果没有回撤,返回 0
if max_dd_value == 0:
return 0
# 找到回撤前的高点
peak_idx: int = max_dd_idx
for i in range(max_dd_idx - 1, -1, -1):
if total[i] > total[max_dd_idx]:
peak_idx = i
break
return int(max_dd_idx - peak_idx)
def _empty_metrics(self) -> dict[str, float]:
"""返回全零指标字典。
用于数据不足时的默认返回值。
Returns:
全零的绩效指标字典
"""
return {
"total_return": 0.0,
"annual_return": 0.0,
"max_drawdown": 0.0,
"max_dd_duration": 0,
"sharpe": 0.0,
"sortino": 0.0,
"calmar": 0.0,
"total_trades": 0,
"win_trades": 0,
"lose_trades": 0,
"rejected_trades": 0,
"win_rate": 0.0,
"profit_factor": 0.0,
"avg_win": 0.0,
"avg_loss": 0.0,
"max_win": 0.0,
"max_loss": 0.0,
"avg_holding_days": 0.0,
"volatility": 0.0,
}