mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 14:34:15 +08:00
Merge pull request #27 from handsomejustin/fix/v1.20.1-backtest-bugs
fix(backtest): v1.20.1 修复回测引擎 3 个 bug(issues #22 #23 #25)
This commit is contained in:
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from easy_tdx.backtest.performance import PerformanceAnalyzer
|
||||
|
||||
@@ -173,7 +174,7 @@ def test_empty_trades() -> None:
|
||||
|
||||
|
||||
def test_all_keys_present() -> None:
|
||||
"""测试所有 19 个指标都存在。"""
|
||||
"""测试所有核心指标 + 别名键都存在。"""
|
||||
equity = _make_equity_curve(n=252, total_return=0.1)
|
||||
trades = _make_trades()
|
||||
|
||||
@@ -200,33 +201,55 @@ def test_all_keys_present() -> None:
|
||||
"max_loss",
|
||||
"avg_holding_days",
|
||||
"volatility",
|
||||
# 别名键(issue #22:兼容 .get('sharpe_ratio') 等常见叫法)
|
||||
"sharpe_ratio",
|
||||
"start_cash",
|
||||
"end_value",
|
||||
}
|
||||
|
||||
assert set(metrics.keys()) == expected_keys
|
||||
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
|
||||
assert all(v == 0 for v in metrics.values())
|
||||
# 数值指标应为 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:数据不全时给出诊断提示,而非静默全 0
|
||||
assert "diagnostic_warning" in metrics
|
||||
assert isinstance(metrics["diagnostic_warning"], 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 个点才能计算收益率)
|
||||
assert all(v == 0 for v in metrics.values())
|
||||
# 数值指标应为 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 "diagnostic_warning" in metrics
|
||||
|
||||
|
||||
def test_profit_factor() -> None:
|
||||
|
||||
@@ -91,11 +91,15 @@ class TestSeriesAccessor:
|
||||
assert acc[-2] == 1.0
|
||||
|
||||
def test_index_out_of_bounds_negative(self) -> None:
|
||||
"""测试索引越界(负方向)。"""
|
||||
"""测试索引越界(负方向)返回 NaN,而非抛 IndexError。
|
||||
|
||||
回测早期 bar_index=0 时 close[-1] 等回溯访问不应崩溃(见 issue #23),
|
||||
返回 NaN 让策略自然跳过预热期。
|
||||
"""
|
||||
arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
acc = _SeriesAccessor(arr, bar_index=0)
|
||||
with pytest.raises(IndexError, match="索引 -1 超出范围"):
|
||||
_ = acc[-1]
|
||||
val = acc[-1]
|
||||
assert np.isnan(val)
|
||||
|
||||
def test_len(self) -> None:
|
||||
"""测试 __len__ 返回数组长度。"""
|
||||
@@ -463,3 +467,75 @@ class TestStrategyBase:
|
||||
for i in range(len(df)):
|
||||
strategy._set_bar_index(i)
|
||||
strategy._call_next()
|
||||
|
||||
|
||||
class TestWarmupAndLookback:
|
||||
"""issue #23: close[-1] 在首根 bar 不应崩溃;warmup 期不产生信号。"""
|
||||
|
||||
def test_lookback_negative_returns_nan_at_bar_zero(self) -> None:
|
||||
"""_SeriesAccessor 负向越界返回 NaN(非 IndexError)。"""
|
||||
arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
acc = _SeriesAccessor(arr, bar_index=0)
|
||||
assert np.isnan(acc[-1])
|
||||
assert np.isnan(acc[-2])
|
||||
|
||||
def test_engine_no_crash_on_close_minus_one(self) -> None:
|
||||
"""文档示例:next() 里访问 close[-1]/close[-2] 不应抛 IndexError。
|
||||
|
||||
回归 issue #23:DualMAStrategy 在首根 bar 访问 close[-1] 崩溃。
|
||||
"""
|
||||
from easy_tdx.backtest.engine import BacktestEngine
|
||||
|
||||
class LookbackStrategy(Strategy):
|
||||
def init(self) -> None:
|
||||
pass
|
||||
|
||||
def next(self) -> None:
|
||||
# 文档记录的访问方式
|
||||
_ = self.data.close[0]
|
||||
_ = self.data.close[-1]
|
||||
_ = self.data.close[-2]
|
||||
|
||||
df = _make_df(n=50)
|
||||
engine = BacktestEngine(LookbackStrategy, cash=100000)
|
||||
# 修复前:抛 IndexError;修复后:正常跑完
|
||||
result = engine.run(df)
|
||||
assert len(result.equity_curve) == 50
|
||||
|
||||
def test_warmup_bars_skips_early_next(self) -> None:
|
||||
"""warmup_bars=N 时前 N 根不调用 next()、不产生信号。"""
|
||||
from easy_tdx.backtest.engine import BacktestEngine
|
||||
|
||||
next_bars: list[int] = []
|
||||
|
||||
class TrackingStrategy(Strategy):
|
||||
def init(self) -> None:
|
||||
pass
|
||||
|
||||
def next(self) -> None:
|
||||
next_bars.append(self._bar_index)
|
||||
self.buy(size=100)
|
||||
|
||||
df = _make_df(n=20)
|
||||
engine = BacktestEngine(TrackingStrategy, cash=100000, warmup_bars=5)
|
||||
engine.run(df)
|
||||
# warmup 期(bar 0~4)不被调用
|
||||
assert next_bars == list(range(5, 20))
|
||||
|
||||
def test_warmup_bars_default_zero_backward_compat(self) -> None:
|
||||
"""默认 warmup_bars=0:每根 bar 都调用 next()(向后兼容)。"""
|
||||
from easy_tdx.backtest.engine import BacktestEngine
|
||||
|
||||
next_count = 0
|
||||
|
||||
class CountStrategy(Strategy):
|
||||
def init(self) -> None:
|
||||
pass
|
||||
|
||||
def next(self) -> None:
|
||||
nonlocal next_count
|
||||
next_count += 1
|
||||
|
||||
df = _make_df(n=15)
|
||||
BacktestEngine(CountStrategy, cash=100000).run(df)
|
||||
assert next_count == 15
|
||||
|
||||
@@ -53,6 +53,21 @@ class TestFactorWeighted:
|
||||
w = FactorWeightedOptimizer().optimize(scores, n_stocks=3)
|
||||
assert w["A"] > w["C"]
|
||||
|
||||
def test_no_weight_collapse_small_n(self):
|
||||
"""issue #25: n_stocks=2 且得分接近时,权重不应坍缩到接近 0。
|
||||
|
||||
修复前 scores=[0.5, 0.34] 经"减最小值"后权重变成 ~1.0 / ~6e-8,
|
||||
等于单股满仓、n_stocks=2 被忽略。修复后每只标的都有实质权重。
|
||||
"""
|
||||
scores = pd.DataFrame({"code": ["A", "B"], "score": [0.50, 0.34]})
|
||||
w = FactorWeightedOptimizer().optimize(scores, n_stocks=2)
|
||||
assert len(w) == 2
|
||||
assert abs(sum(w.values()) - 1.0) < 1e-6
|
||||
# 两只都应有实质权重(≥ 0.05),低分股不再被压到 ~0
|
||||
assert min(w.values()) >= 0.05
|
||||
# 高分股权重仍更高
|
||||
assert w["A"] > w["B"]
|
||||
|
||||
|
||||
class TestRiskParity:
|
||||
def test_weights_sum_to_one(self):
|
||||
|
||||
@@ -74,3 +74,15 @@ class TestRebalanceEngine:
|
||||
result = engine.run(_make_market(), start_date=20240101, end_date=20240430)
|
||||
assert len(result.trades) > 0
|
||||
assert "BUY" in result.trades["direction"].values
|
||||
|
||||
def test_total_trades_matches_trade_rows(self):
|
||||
"""issue #25: performance['total_trades'] 应等于真实交易笔数,而非天数。"""
|
||||
engine = RebalanceEngine(
|
||||
optimizer=EqualWeightOptimizer(),
|
||||
n_stocks=3,
|
||||
rebalance_freq="M",
|
||||
)
|
||||
result = engine.run(_make_market(), start_date=20240101, end_date=20240430)
|
||||
assert result.performance["total_trades"] == len(result.trades)
|
||||
# 修复前 total_trades == len(equity_curve)(天数),明显大于交易笔数
|
||||
assert result.performance["total_trades"] != len(result.equity_curve)
|
||||
|
||||
Reference in New Issue
Block a user