mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 15:44:18 +08:00
- 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
320 lines
10 KiB
Python
320 lines
10 KiB
Python
"""回测绩效分析器。
|
||
|
||
计算资金曲线和交易记录的各项绩效指标。
|
||
"""
|
||
|
||
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,
|
||
}
|