mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 15:44:18 +08:00
v1.20.1 把 BacktestResult.performance 扩大为 dict[str, float | str] (为塞 diagnostic_warning 字符串),破坏 6 处下游消费方类型契约, CI mypy job 转红。重构为更干净的设计: - 诊断信息走独立的 BacktestResult.diagnostic 字段(performance.py PerformanceAnalyzer.diagnostic 属性 → engine 透出 → to_dict 含该字段 → CLI 显示),performance 字典回归 dict[str, float](保留别名键)。 - optimizer.py 补 npt.NDArray 标注,消除 3 个既有 ndarray type-arg 错误。 测试:932 passed;mypy backtest/+portfolio/ 零错误;ruff 全绿。
378 lines
14 KiB
Python
378 lines
14 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:
|
||
"""绩效分析器。
|
||
|
||
从资金曲线和交易记录计算 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
|
||
# 数据异常诊断(资金曲线不足/恒定时填充),供上层透出给用户
|
||
self.diagnostic: str | None = None
|
||
|
||
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:
|
||
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(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
|
||
|
||
# 单笔收益率 = 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)
|
||
|
||
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,
|
||
# 别名键(兼容常见叫法,避免 .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
|
||
|
||
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)
|
||
|
||
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]:
|
||
"""返回全零指标字典(数据不足时的默认返回值)。
|
||
|
||
数据异常的诊断说明通过 ``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,
|
||
"sharpe_ratio": 0.0,
|
||
"start_cash": 0.0,
|
||
"end_value": 0.0,
|
||
}
|