Files
easy_tdx_max/src/easy_tdx/backtest/performance.py
T

465 lines
18 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
import datetime as _dt
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:
"""绩效分析器。
从资金曲线和交易记录计算 25 项绩效指标(19 项经典指标 + 6 项
深度风险指标:Ulcer / VaR / CVaR / SQN / 最大连胜连亏,v1.28 新增)。
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
# 数据异常诊断(资金曲线不足/恒定时填充),供上层透出给用户
self.diagnostic: str | None = None
def compute(self) -> dict[str, float]:
"""计算绩效指标。
Returns:
包含 25 项指标的字典:
- 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: 平均持仓天数(FIFO 配对、按 size 加权,日历日口径)
- volatility: 年化波动率
- ulcer_index: Ulcer 指数(回撤深度平方均值的开方,综合反映
回撤深度与持续时间,越小持有体验越好)
- var_95: 95% 日 VaR(历史分位数法,正数表示单日最大损失幅度)
- cvar_95: 95% 日 CVaR / 期望损失(尾部 5% 日收益均值,正数)
- sqn: 系统质量数(Van Tharp SQN = √N × 单笔收益率均值/标准差,
>2 可用、>4 优秀、>6 极佳的经验分档)
- max_consecutive_wins: 最大连胜笔数(按 SELL 成交顺序统计)
- max_consecutive_losses: 最大连亏笔数
"""
# 边界检查
if len(self._equity_curve) < 2:
self.diagnostic = "资金曲线不足 2 根,无法计算绩效(数据可能为空或不全)"
return self._empty_metrics()
total = self._equity_curve["total"].to_numpy()
drawdown = self._equity_curve["drawdown"].to_numpy()
# 计算日收益率(除零保护:前值为 0 的位置记为 NaN 后一并过滤)
safe_prev = np.where(total[:-1] != 0, total[:-1], np.nan)
daily_ret = np.diff(total) / safe_prev
# 同时过滤 NaN 和 inf(前值为 0 会产生 inf/nan
daily_ret = daily_ret[np.isfinite(daily_ret)]
# 日收益率数量太少时返回空指标
if len(daily_ret) < 2:
self.diagnostic = (
"有效日收益率不足 2 个,绩效全 0(资金曲线可能恒定,常因数据不全或"
"交易未生效;建议 easy-tdx ping 切换服务器后重试)"
)
return self._empty_metrics()
# 1. 总收益率(首根净值为 0 时无法定义,记为 0.0)
total_return = (total[-1] / total[0]) - 1 if total[0] != 0 else 0.0
# 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(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
# 单笔收益率 = 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)
# 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
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. 平均盈利(单笔收益率口径)
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. 平均亏损(单笔收益率口径)
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 = float(np.max(win_returns)) if len(win_returns) > 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()
# 19. 年化波动率
volatility = np.std(daily_ret) * np.sqrt(self.ANNUAL_DAYS)
# 20. Ulcer 指数(Martin:√(mean(回撤幅度²)),深度与持续时间加权)
ulcer_index = float(np.sqrt(np.mean(drawdown_pct**2)))
# 21. 95% 日 VaR(历史分位数法;正数表示损失幅度,便于直觉解读)
var_95 = float(-np.percentile(daily_ret, 5))
# 22. 95% 日 CVaR(VaR 之外尾部收益的均值;样本不足时退化为 VaR)
tail = daily_ret[daily_ret <= -var_95]
cvar_95 = float(-np.mean(tail)) if len(tail) > 0 else var_95
# 23. SQN 系统质量数(√N × 单笔收益率均值 / 标准差)
valid_tr = trade_returns[np.isfinite(trade_returns)]
if len(valid_tr) >= 2 and np.std(valid_tr) > 1e-12:
sqn = float(np.sqrt(len(valid_tr)) * np.mean(valid_tr) / np.std(valid_tr))
else:
sqn = 0.0
# 24/25. 最大连胜/连亏(与 win_rate 同口径:按 SELL 成交顺序)
max_consecutive_wins, max_consecutive_losses = self._max_win_lose_streaks(
sell_trades["pnl"].to_numpy(dtype=np.float64)
)
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,
"ulcer_index": ulcer_index,
"var_95": var_95,
"cvar_95": cvar_95,
"sqn": sqn,
"max_consecutive_wins": max_consecutive_wins,
"max_consecutive_losses": max_consecutive_losses,
# 别名键(兼容常见叫法,避免 .get('sharpe_ratio') 等误用返回 0
"sharpe_ratio": sharpe,
"start_cash": float(total[0]),
"end_value": float(total[-1]),
}
def _compute_avg_holding_days(self) -> float:
"""计算平均持仓天数(FIFO 配对)。
遍历非 rejected 的交易记录,使用 FIFO 队列配对买入和卖出,
按 size 加权计算平均持仓天数。
注意:持仓天数按真实日历日计算(解析 ``YYYYMMDD`` 为 ``date`` 后相减),
而非 YYYYMMDD 整数差——后者在跨月时会放大(如 20240201-20240131=70)。
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
# 组合成交表带 symbol 列时按标的分组配对(避免 A 股的买入被 B 股的
# 卖出错误配对);单标的成交表无该列,走原路径。
groups: list[pd.DataFrame]
if "symbol" in valid.columns:
groups = [g for _, g in valid.groupby("symbol", sort=False)]
else:
groups = [valid]
total_days = 0.0
total_size = 0.0
for group in groups:
days, size = self._fifo_holding_days(group)
total_days += days
total_size += size
if total_size == 0:
return 0.0
return total_days / total_size
def _fifo_holding_days(self, valid: pd.DataFrame) -> tuple[float, float]:
"""对单组(单标的)成交做 FIFO 配对,返回 (加权持仓天数和, 加权数量和)。"""
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():
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((d, size))
elif direction == "SELL" and buy_queue:
remaining = size
while remaining > 0 and buy_queue:
buy_d, buy_size = buy_queue[0]
# 消费该笔 BUY 的部分或全部
consumed = min(remaining, buy_size)
holding_days = (d - buy_d).days
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_d, buy_size)
return total_days, total_size
def _compute_max_dd_duration(self, drawdown: NDArray) -> int:
"""计算最大回撤持续时间(最长水下期)。
以创新高(drawdown == 0)为界切分水下区间,取「从峰值跌落到
重新回到前高」的最长一段 bar 数;末日仍未修复的区间计到最后一根。
与 glossary「最长一次套牢了多久」、grading 的 max_dd_duration 锚点
量纲(30 天/90 天/365 天…)以及前端 computeCombinedMetrics 同口径。
Args:
drawdown: 回撤数组(peak - total,与资金曲线等长)
Returns:
最长水下期(bar 数);全程无回撤时为 0
"""
if len(drawdown) == 0:
return 0
peak_hits = np.flatnonzero(drawdown == 0)
if peak_hits.size == 0:
return 0
# 相邻两个创新高点间隔 ≥2 根才夹着真实的水下段(间隔 1 为连续新高)
gaps = np.diff(peak_hits)
deep_gaps = gaps[gaps > 1]
longest = int(deep_gaps.max()) if deep_gaps.size > 0 else 0
# 末日仍在水下:从最后一次创新高计到最后一根
tail = len(drawdown) - 1 - int(peak_hits[-1])
return int(max(longest, tail))
def _empty_metrics(self) -> dict[str, float]:
"""返回全零指标字典(数据不足时的默认返回值)。
数据异常的诊断说明通过 ``self.diagnostic`` 暴露,由上层(CLI/引擎)
读取后透出给用户,不污染数值型 performance 字典(保持
``dict[str, float]`` 类型,避免下游算术/比较类型报错)。
Returns:
全零的绩效指标字典(含别名键 sharpe_ratio/start_cash/end_value)。
"""
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,
"ulcer_index": 0.0,
"var_95": 0.0,
"cvar_95": 0.0,
"sqn": 0.0,
"max_consecutive_wins": 0,
"max_consecutive_losses": 0,
"sharpe_ratio": 0.0,
"start_cash": 0.0,
"end_value": 0.0,
}
@staticmethod
def _max_win_lose_streaks(pnl_seq: NDArray) -> tuple[int, int]:
"""按成交顺序统计最大连胜/连亏笔数。
pnl > 0 记为胜,pnl <= 0 记为负(与 win_rate 的胜/负口径一致)。
Args:
pnl_seq: SELL 成交的 pnl 序列(时间升序)
Returns:
(最大连胜笔数, 最大连亏笔数)
"""
max_wins = max_losses = cur_wins = cur_losses = 0
for pnl in pnl_seq:
if pnl > 0:
cur_wins += 1
cur_losses = 0
max_wins = max(max_wins, cur_wins)
else:
cur_losses += 1
cur_wins = 0
max_losses = max(max_losses, cur_losses)
return int(max_wins), int(max_losses)