mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 16:54:20 +08:00
465 lines
18 KiB
Python
465 lines
18 KiB
Python
"""回测绩效分析器。
|
||
|
||
计算资金曲线和交易记录的各项绩效指标。
|
||
"""
|
||
|
||
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,在后续统计里被过滤。
|
||
# 显式转 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)
|
||
|
||
# 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)
|