Files
easy_tdx_max/tests/unit/test_backtest_performance.py
T

647 lines
21 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.
"""单元测试:绩效分析器。
测试 PerformanceAnalyzer 的各项指标计算。
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from easy_tdx.backtest.performance import PerformanceAnalyzer
def _make_equity_curve(n: int = 252, total_return: float = 0.1) -> pd.DataFrame:
"""创建测试用资金曲线。
Args:
n: bar 数量
total_return: 总收益率(例如 0.1 表示 10%)
Returns:
包含 datetime, total, drawdown 的 DataFrame
"""
# 计算每日收益率
daily_ret = (1 + total_return) ** (1 / n) - 1
# 生成权益曲线
initial = 100000
total = initial * np.cumprod(np.full(n, 1 + daily_ret))
# 计算回撤
peak = np.maximum.accumulate(total)
drawdown = peak - total
drawdown_pct = np.divide(drawdown, peak, out=np.zeros_like(drawdown), where=(peak != 0))
return pd.DataFrame(
{
"datetime": np.arange(n),
"total": total,
"drawdown": drawdown,
"drawdown_pct": drawdown_pct,
}
)
def _make_trades() -> pd.DataFrame:
"""创建测试用交易记录。
Returns:
包含 datetime, direction, pnl, cost_basis, rejected 的 DataFrame
4 条交易: BUY@20240101, SELL@20240106(pnl=500), BUY@20240110, SELL@20240115(pnl=-500)
注意:avg_win/avg_loss/max_win/max_loss 现为「单笔收益率」口径
= pnl / cost_basis)。此处 cost_basis=10000,故收益率 = pnl/10000。
"""
return pd.DataFrame(
{
"datetime": [20240101, 20240106, 20240110, 20240115],
"direction": ["BUY", "SELL", "BUY", "SELL"],
"pnl": [0, 500, 0, -500],
"cost_basis": [0.0, 10000.0, 0.0, 10000.0],
"rejected": [False, False, False, False],
}
)
def test_total_return() -> None:
"""测试总收益率计算。"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = _make_trades()
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# 总收益率应接近 0.110%
assert abs(metrics["total_return"] - 0.1) < 0.01
def test_max_drawdown_never_exceeds_100_pct() -> None:
"""测试最大回撤永远不超过 100%(从峰值的跌幅)。"""
# 模拟先涨 5 倍再腰斩的资金曲线
total = np.concatenate(
[
np.linspace(100000, 600000, 126), # 涨到 60 万
np.linspace(600000, 300000, 126), # 跌到 30 万
]
)
peak = np.maximum.accumulate(total)
drawdown = peak - total
drawdown_pct = np.divide(drawdown, peak, out=np.zeros_like(drawdown), where=(peak != 0))
equity = pd.DataFrame(
{
"datetime": np.arange(252),
"total": total,
"drawdown": drawdown,
"drawdown_pct": drawdown_pct,
}
)
trades = _make_trades()
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# 最大回撤 = 从峰值跌 50%(30万 / 60万),不应超过 1.0
assert 0.0 <= metrics["max_drawdown"] <= 1.0, (
f"max_drawdown = {metrics['max_drawdown']:.2%}, should be in [0, 100%]"
)
assert abs(metrics["max_drawdown"] - 0.5) < 0.01
def test_max_drawdown_zero_when_monotonic() -> None:
"""测试单调递增时最大回撤接近 0。"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = _make_trades()
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# 单调递增时回撤应很小(浮点误差)
assert metrics["max_drawdown"] < 0.01
def test_sharpe_positive_for_profit() -> None:
"""测试正收益时夏普比率为正。"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = _make_trades()
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# 正收益时夏普比率应大于 0
assert metrics["sharpe"] > 0
def test_win_rate() -> None:
"""测试胜率计算。"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = _make_trades()
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# 1 赢 1 输,胜率应接近 0.5
assert abs(metrics["win_rate"] - 0.5) < 0.01
def test_total_trades() -> None:
"""测试总交易次数。"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = _make_trades()
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# 只有 SELL 交易才算完整交易
assert metrics["total_trades"] == 2
def test_empty_trades() -> None:
"""测试空交易记录。"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = pd.DataFrame({"direction": [], "pnl": [], "rejected": []})
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# 空 trades 时交易相关指标应为 0
assert metrics["total_trades"] == 0
assert metrics["win_trades"] == 0
assert metrics["lose_trades"] == 0
assert metrics["win_rate"] == 0
def test_all_keys_present() -> None:
"""测试所有核心指标 + 别名键都存在。"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = _make_trades()
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
expected_keys = {
"total_return",
"annual_return",
"max_drawdown",
"max_dd_duration",
"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",
"volatility",
# 别名键(issue #22:兼容 .get('sharpe_ratio') 等常见叫法)
"sharpe_ratio",
"start_cash",
"end_value",
}
assert expected_keys.issubset(set(metrics.keys()))
def test_alias_keys_match_canonical() -> None:
"""issue #22: 别名键与标准键值一致。"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = _make_trades()
metrics = PerformanceAnalyzer(equity, trades).compute()
assert metrics["sharpe_ratio"] == metrics["sharpe"]
assert metrics["start_cash"] == pytest.approx(equity["total"].iloc[0])
assert metrics["end_value"] == pytest.approx(equity["total"].iloc[-1])
def test_empty_equity_curve() -> None:
"""测试空资金曲线返回全零指标 + 诊断提示。"""
equity = pd.DataFrame({"total": [], "drawdown": [], "drawdown_pct": []})
trades = _make_trades()
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# 数值指标应为 0
numeric_metrics = {k: v for k, v in metrics.items() if isinstance(v, int | float)}
assert all(v == 0 for v in numeric_metrics.values())
# issue #22:数据不全时给出诊断提示(analyzer.diagnostic),而非静默全 0
assert analyzer.diagnostic is not None
assert isinstance(analyzer.diagnostic, str)
def test_single_point_equity_curve() -> None:
"""测试只有一个点的资金曲线返回全零指标 + 诊断提示。"""
equity = pd.DataFrame({"total": [100000], "drawdown": [0], "drawdown_pct": [0.0]})
trades = _make_trades()
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# 数值指标应为 0(需要至少 2 个点才能计算收益率)
numeric_metrics = {k: v for k, v in metrics.items() if isinstance(v, int | float)}
assert all(v == 0 for v in numeric_metrics.values())
assert analyzer.diagnostic is not None
def test_profit_factor() -> None:
"""测试盈亏比计算。"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = _make_trades()
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# 盈利 500,亏损 500,盈亏比应为 1.0
assert abs(metrics["profit_factor"] - 1.0) < 0.01
def test_avg_win_and_loss() -> None:
"""测试平均盈亏计算(单笔收益率口径 = pnl / cost_basis)。"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = _make_trades()
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# 1 笔盈利 500 / cost_basis 10000 = 0.055%
assert abs(metrics["avg_win"] - 0.05) < 0.001
# 1 笔亏损 -500 / cost_basis 10000 = -0.05-5%
assert abs(metrics["avg_loss"] - (-0.05)) < 0.001
def test_max_win_and_loss() -> None:
"""测试最大盈亏计算(单笔收益率口径 = pnl / cost_basis)。"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = _make_trades()
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# 最大盈利收益率 = 500 / 10000 = 0.05
assert abs(metrics["max_win"] - 0.05) < 0.001
# 最大亏损收益率 = -500 / 10000 = -0.05
assert abs(metrics["max_loss"] - (-0.05)) < 0.001
def test_annual_return() -> None:
"""测试年化收益率计算。"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = _make_trades()
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# 252 天 10% 收益,年化收益率应接近 0.1
assert abs(metrics["annual_return"] - 0.1) < 0.01
def test_volatility() -> None:
"""测试年化波动率计算。"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = _make_trades()
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# 波动率应大于 0
assert metrics["volatility"] > 0
def test_rejected_trades() -> None:
"""测试被拒绝交易计数。"""
equity = _make_equity_curve(n=252, total_return=0.1)
# 创建包含被拒绝交易的记录
trades = pd.DataFrame(
{
"direction": ["BUY", "SELL", "SELL", "SELL"],
"pnl": [0, 500, 0, -500],
"rejected": [False, False, True, False],
}
)
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# 应有 1 笔被拒绝的交易
assert metrics["rejected_trades"] == 1
def test_win_trades_and_lose_trades_count() -> None:
"""测试盈亏交易计数。"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = _make_trades()
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# 1 笔盈利,1 笔亏损
assert metrics["win_trades"] == 1
assert metrics["lose_trades"] == 1
def test_avg_holding_days_fifo() -> None:
"""测试平均持仓天数(FIFO 配对计算)。"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = _make_trades()
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# BUY@20240101 → SELL@20240106: 5 天
# BUY@20240110 → SELL@20240115: 5 天
# 平均 = (5 + 5) / 2 = 5.0
assert metrics["avg_holding_days"] == 5.0
def test_avg_holding_days_weighted() -> None:
"""测试加权平均持仓天数(不同持仓期)。"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = pd.DataFrame(
{
"datetime": [20240101, 20240111, 20240120, 20240123],
"direction": ["BUY", "SELL", "BUY", "SELL"],
"pnl": [0, 500, 0, -200],
"rejected": [False, False, False, False],
}
)
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# BUY@20240101 → SELL@20240111: 10 天
# BUY@20240120 → SELL@20240123: 3 天
# 平均 = (10 + 3) / 2 = 6.5
assert metrics["avg_holding_days"] == 6.5
def test_avg_holding_days_no_datetime() -> None:
"""测试 trades 没有 datetime 列时返回 0.0。"""
equity = _make_equity_curve(n=252, total_return=0.1)
# 不含 datetime 列的交易记录
trades = pd.DataFrame(
{
"direction": ["BUY", "SELL"],
"pnl": [0, 500],
"rejected": [False, False],
}
)
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
assert metrics["avg_holding_days"] == 0.0
def test_avg_holding_days_only_buys() -> None:
"""测试只有买入没有卖出时返回 0.0。"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = pd.DataFrame(
{
"datetime": [20240101, 20240105],
"direction": ["BUY", "BUY"],
"pnl": [0, 0],
"rejected": [False, False],
}
)
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
assert metrics["avg_holding_days"] == 0.0
def test_max_dd_duration() -> None:
"""测试最大回撤持续时间计算。"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = _make_trades()
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# 单调递增时最大回撤持续时间应为 0
assert metrics["max_dd_duration"] == 0
def test_sortino() -> None:
"""测试索提诺比率计算。"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = _make_trades()
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# 正收益时索提诺比率应大于 0
assert metrics["sortino"] > 0
def test_calmar() -> None:
"""测试卡玛比率计算。"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = _make_trades()
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# 卡玛比率 = annual_return / max_drawdown
# 由于 max_drawdown 很小,calmar 会很大
assert metrics["calmar"] > 0
# ---------------------------------------------------------------------------
# 除零边界回归(审计复审 N2 / 首轮 #11)
#
# performance.py 在计算日收益率时对 total[:-1]==0 的位置做了 safe_prev 守卫
# (记为 NaN 后 np.isfinite 过滤),并对 total[0]==0 的总收益率做了 0.0 兜底。
# 若有人不慎改回旧的 np.diff(total)/total[:-1],这些测试应当红灯。
# ---------------------------------------------------------------------------
def _metrics_from_total(values: list[float]) -> dict[str, float]:
"""从一组 total 值构造最小资金曲线并计算指标。"""
total = np.array(values, dtype=float)
peak = np.maximum.accumulate(total)
# 与生产回测一致:drawdown = peak - totaldrawdown_pct = drawdown / peak
drawdown = peak - total
drawdown_pct = np.divide(drawdown, peak, out=np.zeros_like(drawdown), where=(peak != 0))
equity = pd.DataFrame(
{
"datetime": np.arange(len(total)),
"total": total,
"drawdown": drawdown,
"drawdown_pct": drawdown_pct,
}
)
return PerformanceAnalyzer(equity, _make_trades()).compute()
def test_metrics_handles_zero_intermediate_equity() -> None:
"""中间净值出现 0 时,日收益率除零不抛异常、返回有限值(审计复审 N2)。
total=[100, 0, 105, 0, 110]:第 1、3 根前值为 0,旧实现 diff/total[:-1]
会得到 inf,进而污染均值/方差计算或触发 RuntimeWarning。修复后这些位置
被 safe_prev 记为 NaN 并由 isfinite 过滤。
"""
metrics = _metrics_from_total([100, 0, 105, 0, 110])
# 所有数值型指标必须有限(非 inf、非 NaN)
finite_keys = {
"total_return",
"annual_return",
"max_drawdown",
"sharpe",
"sortino",
"calmar",
"volatility",
"win_rate",
"profit_factor",
}
for key in finite_keys:
val = metrics[key]
assert np.isfinite(val), f"{key} 不是有限值: {val}"
def test_metrics_handles_zero_first_equity() -> None:
"""首根净值为 0 时 total_return 兜底为 0.0 而非除零(审计复审 N2)。
total[0]==0 时 (total[-1]/total[0]) - 1 会除零;修复后直接记 0.0。
"""
metrics = _metrics_from_total([0, 100, 105, 110, 115])
# total_return 走 total[0]==0 分支,应为有限值
assert np.isfinite(metrics["total_return"]), f"total_return 非有限值: {metrics['total_return']}"
# 不抛异常即说明 max_drawdown 等也未受影响
assert np.isfinite(metrics["max_drawdown"])
def test_metrics_all_zero_equity_does_not_raise() -> None:
"""全 0 资金曲线不应产生 inf/nan,也不应抛异常(审计复审 N2 极端场景)。"""
# total 全 0 → safe_prev 全 NaN → daily_ret 过滤后为空 → 走 _empty_metrics
metrics = _metrics_from_total([0, 0, 0, 0, 0])
# 全 0 资金曲线收益率数据不足,应安全返回有限值(多数为 0)
assert np.isfinite(metrics["total_return"])
assert np.isfinite(metrics["max_drawdown"])
assert np.isfinite(metrics["sharpe"])
# ── 回归测试:交易统计语义修复 ───────────────────────────────────────────────
def test_avg_holding_days_crosses_month_boundary() -> None:
"""跨月持仓天数必须用真实日历日计算,而非 YYYYMMDD 整数差。
回归守卫:旧实现 ``20240201 - 20240131 = 70``(整数差,错误),
新实现解析为 date 后相减 = 1 天。
"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = pd.DataFrame(
{
"datetime": [20240131, 20240201],
"direction": ["BUY", "SELL"],
"pnl": [0, 100],
"cost_basis": [0.0, 10000.0],
"rejected": [False, False],
}
)
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# 1月31日 → 2月1日 = 1 个真实日历日(旧 bug 会得到 70)
assert metrics["avg_holding_days"] == 1.0
def test_profit_factor_no_losing_trades_is_large() -> None:
"""全部盈利、无亏损交易时 profit_factor 应为 999.0 而非 0.0。
回归守卫:旧实现在 ``len(lose_pnl)==0`` 时直接返回 0.0
与 100% 胜率并列显示时自相矛盾(胜率 100% 却盈亏比 0)。
"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = pd.DataFrame(
{
"datetime": [20240101, 20240106, 20240110, 20240115],
"direction": ["BUY", "SELL", "BUY", "SELL"],
"pnl": [0, 500, 0, 300],
"cost_basis": [0.0, 10000.0, 0.0, 10000.0],
"rejected": [False, False, False, False],
}
)
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
assert metrics["win_trades"] == 2
assert metrics["lose_trades"] == 0
assert metrics["profit_factor"] == 999.0
def test_avg_win_zero_when_no_cost_basis_column() -> None:
"""trades 无 cost_basis 列时 avg_win/avg_loss/max_win/max_loss 应回退为 0.0。
回归守卫:engine._trades_to_df 现会输出 cost_basis 列,但若上游构造的
trades DataFrame 缺该列(如旧式直接拼装),不应抛 KeyError,应记 0.0。
"""
equity = _make_equity_curve(n=252, total_return=0.1)
trades = pd.DataFrame(
{
"datetime": [20240101, 20240106],
"direction": ["BUY", "SELL"],
"pnl": [0, 500],
"rejected": [False, False],
}
)
analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute()
# 无 cost_basis → 单笔收益率无法计算 → 记 0.0,不抛异常
assert metrics["avg_win"] == 0.0
assert metrics["max_win"] == 0.0
# ── 回归测试:回撤持续 = 最长水下期 ─────────────────────────────────────────
# 旧实现从最大回撤点向前找「第一根高于谷底的 bar」,下跌途中那几乎总是
# 紧邻的上一根,导致单标的回测的回撤持续恒为 1。新口径(与 glossary、
# grading 锚点量纲、前端 computeCombinedMetrics 一致):从峰值跌落到重新
# 创新高的最长 bar 数;末日仍未修复则计到最后一根。
def test_max_dd_duration_longest_underwater_span() -> None:
"""回撤持续应取最长水下期(峰值 → 重新创新高),而非恒为 1。
total=[100, 110, 105, 95, 90, 100, 110, 120, 115]
idx1 峰值 110 → idx6 回到 110,水下 5 根(旧实现会返回 1);
idx7 峰值 120 后回落,末日未修复,尾段 1 根。
"""
metrics = _metrics_from_total([100, 110, 105, 95, 90, 100, 110, 120, 115])
assert metrics["max_dd_duration"] == 5
def test_max_dd_duration_unclosed_counts_to_last_bar() -> None:
"""末日仍未修复的水下期应计到最后一根。"""
metrics = _metrics_from_total([100, 95, 90, 92])
# idx0 峰值后再未回到 100,水下 3 根
assert metrics["max_dd_duration"] == 3
def test_max_dd_duration_consecutive_peaks_not_counted() -> None:
"""连续创新高(无水下 bar)不计入回撤持续,全程无回撤时为 0。"""
metrics = _metrics_from_total([100, 105, 110, 120])
assert metrics["max_dd_duration"] == 0