fix(backtest): v1.20.1 修复回测引擎 3 个 bug(issues #22 #23 #25)

排查发现用户反馈的"回测统计数据缺失/异常"并非服务器连接问题,
而是回测引擎与组合优化器自身的代码缺陷:

#23: 首根 bar 访问 close[-1] 崩溃
  _SeriesAccessor 负向越界改返回 NaN(不抛 IndexError);
  BacktestEngine 新增 warmup_bars 参数跳过指标预热期。

#25-A: FactorWeightedOptimizer 权重坍缩
  n_stocks=2 且得分接近时,减最小值把低分标的权重压到 ~6e-8,
  等于单股满仓、n_stocks 被无视,进而出现持仓1只/-99.98%回撤。
  新增 _apply_weight_floor 权重下限保证入选标的都有实质权重。

#25-B: RebalanceEngine total_trades 统计错误
  total_trades = len(equity_curve)(天数)改为 len(trades_df)(真实笔数)。

#22: 绩效别名键 + 数据异常诊断
  performance dict 新增 sharpe_ratio/start_cash/end_value 别名键;
  资金曲线异常时返回 diagnostic_warning 而非静默全 0,CLI 显示提示。

文档:README 加入回测手册导航;backtest_usage.md 补充 warmup 说明。
测试:新增 6 个回归测试,更新 3 个;932 passed。
This commit is contained in:
GitHub
2026-07-09 20:16:12 +08:00
parent 8bf4ffb322
commit 00b8c6374a
15 changed files with 285 additions and 26 deletions
+19
View File
@@ -2,6 +2,25 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
## [1.20.1] — 2026-07-09
**修复回测引擎 3 个用户高频踩坑的 bug**issues #22 / #23 / #25)—— 用户最初反馈"回测统计数据缺失/异常",排查后发现并非服务器连接问题(已建议 `easy-tdx ping`),而是回测引擎与组合优化器自身的代码缺陷:首根 bar 访问历史数据崩溃、再平衡 `n_stocks` 被无视、交易笔数统计成天数。本次逐一修复并补回归测试,同时在数据异常时给出诊断提示而非静默返回全 0。
### 修复
- **首根 bar 回溯访问不再崩溃**(`src/easy_tdx/backtest/strategy.py` + `engine.py`)—— 文档示例 `self.data.close[-1]` / `[-2]``bar_index=0` 时越界抛 `IndexError`issue #23)。`_SeriesAccessor` 负向越界改为返回 `NaN``BacktestEngine` 新增 `warmup_bars` 参数,预热期前 N 根不调用 `next()`、不产生信号。含回归测试。
- **`FactorWeightedOptimizer` 权重坍缩**`src/easy_tdx/portfolio/optimizer.py`)—— `n_stocks=2` 且因子得分接近时,"减最小值 + 1e-8"把低分标的权重压到 ~`6e-8`,等于单股满仓,`n_stocks` 被实际无视,进而出现"持仓 1 只"、`-99.98%` 回撤等荒谬结果(issue #25)。新增 `_apply_weight_floor` 权重下限(每只 ≥ `1/(N*10)`),保证入选标的都有实质权重且和仍为 1。
- **再平衡 `total_trades` 统计错误**`src/easy_tdx/portfolio/rebalance.py`)—— `_compute_performance``total_trades` 设成 `len(equity_curve)`(天数),而非真实交易笔数(issue #2556 笔交易显示为 500)。改为 `len(trades_df)`
### 新增
- **绩效指标别名键 + 数据异常诊断**(`src/easy_tdx/backtest/performance.py` + `cli.py`)—— performance dict 新增 `sharpe_ratio` / `start_cash` / `end_value` 别名键,避免用户 `.get('sharpe_ratio')` 误用返回 0issue #22 body)。资金曲线不足 2 点或有效日收益 < 2 时返回 `diagnostic_warning`(提示可能数据不全、建议 `easy-tdx ping`),CLI 表格输出显示该提示,不再静默返回全 0。
### 文档
- **README 回测手册导航**`README.md`)—— 在「回测引擎」章节顶部加入 `docs/backtest_usage.md` 完整使用手册的醒目提示。
- **`backtest_usage.md` 补充 warmup 与回溯容错说明**(`docs/backtest_usage.md`)—— 记录 `warmup_bars` 参数语义、负向索引越界返回 `NaN` 的行为。
## [1.20.0] — 2026-07-08
**服务器失败时自动 ping 切换,无需手动 `easy-tdx ping`** —— 解决普通用户最困惑的痛点:连不上服务器或返回空数据时,之前必须手动跑 `easy-tdx ping` 才能恢复,普通人根本不知道该这么做。现在 Python API / CLI / Web API **三入口全部自动**——服务器连不上或返回空统计指数时,自动测速、切到延迟最低的可用服务器、重试,全程对用户透明。收敛在 `_reconnect.py` 单点注入 8 个 client 的 `_execute`,零冗余、不新增配置开关。
+4
View File
@@ -301,6 +301,10 @@ easy-tdx chanlun SH 600519 --multi-level 5MIN
### 回测引擎
> 📖 **完整使用手册**[docs/backtest_usage.md](docs/backtest_usage.md) ——
> 涵盖策略编写(`init()`/`next()`)、行情数据访问、指标注册、订单模拟、
> 绩效指标、组合回测、调仓引擎与完整示例。回测相关用法以该手册为准。
内置向量回测引擎,加载 Python 策略文件即可跑回测。策略继承 `Strategy` 基类,在 `init()` 注册指标,在 `next()` 逐 bar 生成买卖信号,引擎完成订单模拟、持仓跟踪和绩效分析。
**单策略回测:**
+19
View File
@@ -110,6 +110,9 @@ def next(self):
prev2 = self.data.close[-2]
```
> **提示**:回溯索引(如 `[-1]`、`[-2]`)在回测首根 bar 数据不足时返回
> `NaN` 而非报错。如需完全跳过指标预热期,设置 `warmup_bars`(见下文)。
**标准列**`open`, `close`, `high`, `low`, `vol`, `amount`
```python
@@ -230,9 +233,25 @@ engine = BacktestEngine(
execution="next_open", # 成交价规则
position_mode="full", # 仓位模式
reject_policy="reduce", # 拒绝策略
warmup_bars=0, # 指标预热 bar 数(前 N 根不调用 next(),不产生信号)
)
```
### 指标预热(warmup
技术指标(如 MA20、MACD)在前若干根 bar 的值是 `NaN` 或不稳定的。为避免
预热期产生错误信号或访问越界:
- **回溯访问容错**`self.data.close[-1]` / `[-2]` 等负向索引在首根 bar
(数据不足)时返回 `NaN` 而非抛 `IndexError`,策略无需手动加 `bar_index > 0`
守卫。
- **warmup_bars 参数**:设置后引擎在前 `warmup_bars` 根不调用 `next()`、不
产生信号(资金曲线照常推进)。例如用 MA20 策略时可设 `warmup_bars=20`
```python
engine = BacktestEngine(MyStrategy, cash=100000, warmup_bars=20)
```
### 成交价规则
| 模式 | 成交价 | 说明 |
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "easy-tdx"
version = "1.20.0"
version = "1.20.1"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md"
requires-python = ">=3.10"
+4
View File
@@ -256,6 +256,10 @@ def _print_table(result: Any) -> None:
click.echo(f"交易次数: {perf.get('total_trades', 0)}")
click.echo()
if perf.get("diagnostic_warning"):
click.echo(f"⚠ 诊断: {perf['diagnostic_warning']}")
click.echo()
click.echo("=== 配置参数 ===")
click.echo(f"初始资金: {config.get('cash', 0):.2f}")
click.echo(f"佣金率: {config.get('commission', 0):.4f}")
+11
View File
@@ -63,6 +63,7 @@ class BacktestEngine:
chanlun_level: str | None = None,
slippage_model: SlippageModel | None = None,
execution_model: ExecutionModel | None = None,
warmup_bars: int = 0,
):
"""Initialize engine.
@@ -83,6 +84,9 @@ class BacktestEngine:
when provided).
execution_model: Pluggable execution model (bypasses OrderSimulator
when provided).
warmup_bars: 指标预热 bar 数。前 ``warmup_bars`` 根不调用
``next()``、不产生信号(指标 NaN 期过滤),避免早期数据不足
导致的越界或误信号。默认 0(向后兼容)。
"""
self._strategy_cls = strategy if isinstance(strategy, type) else type(strategy)
self._strategy_instance = strategy if isinstance(strategy, Strategy) else None
@@ -99,6 +103,7 @@ class BacktestEngine:
self._chanlun_level = chanlun_level
self._slippage_model = slippage_model
self._execution_model = execution_model
self._warmup_bars = max(int(warmup_bars), 0)
def run(self, df: pd.DataFrame, chanlun_result: Any | None = None) -> BacktestResult:
"""Run backtest.
@@ -279,6 +284,12 @@ class BacktestEngine:
for i in range(len(df)):
strat._set_bar_index(i)
# warmup 期(指标 NaN 预热)不调用 next()、不产生信号,但仍推进
# bar_index 与后续 PortfolioTracker 的资金曲线对齐。
if i < self._warmup_bars:
continue
strat._call_next()
bar_signals = strat._clear_signals()
+24 -7
View File
@@ -49,7 +49,7 @@ class PerformanceAnalyzer:
self._trades = trades
self._risk_free_rate = risk_free_rate
def compute(self) -> dict[str, float]:
def compute(self) -> dict[str, float | str]:
"""计算绩效指标。
Returns:
@@ -76,7 +76,7 @@ class PerformanceAnalyzer:
"""
# 边界检查
if len(self._equity_curve) < 2:
return self._empty_metrics()
return self._empty_metrics("资金曲线不足 2 根,无法计算绩效(数据可能为空或不全)")
total = self._equity_curve["total"].to_numpy()
drawdown = self._equity_curve["drawdown"].to_numpy()
@@ -89,7 +89,10 @@ class PerformanceAnalyzer:
# 日收益率数量太少时返回空指标
if len(daily_ret) < 2:
return self._empty_metrics()
return self._empty_metrics(
"有效日收益率不足 2 个,绩效全 0(资金曲线可能恒定,常因数据不全或"
"交易未生效;建议 easy-tdx ping 切换服务器后重试)"
)
# 1. 总收益率(首根净值为 0 时无法定义,记为 0.0)
total_return = (total[-1] / total[0]) - 1 if total[0] != 0 else 0.0
@@ -224,6 +227,10 @@ class PerformanceAnalyzer:
"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:
@@ -330,15 +337,19 @@ class PerformanceAnalyzer:
return int(max_dd_idx - peak_idx)
def _empty_metrics(self) -> dict[str, float]:
def _empty_metrics(self, diagnostic: str | None = None) -> dict[str, float | str]:
"""返回全零指标字典。
用于数据不足时的默认返回值。
用于数据不足时的默认返回值。``diagnostic`` 非空时一并返回,便于
上层(CLI/UI)提示用户绩效全 0 的原因(典型为数据不全)。
Args:
diagnostic: 可选的诊断说明,写入返回字典的 ``diagnostic_warning`` 键。
Returns:
全零的绩效指标字典
全零的绩效指标字典(可选含 ``diagnostic_warning``)。
"""
return {
metrics: dict[str, float | str] = {
"total_return": 0.0,
"annual_return": 0.0,
"max_drawdown": 0.0,
@@ -358,4 +369,10 @@ class PerformanceAnalyzer:
"max_loss": 0.0,
"avg_holding_days": 0.0,
"volatility": 0.0,
"sharpe_ratio": 0.0,
"start_cash": 0.0,
"end_value": 0.0,
}
if diagnostic is not None:
metrics["diagnostic_warning"] = diagnostic
return metrics
+4 -2
View File
@@ -50,11 +50,13 @@ class _SeriesAccessor:
key: 0=当前值, -1=前一根, -2=前两根,依此类推
Returns:
对应位置的 float 值
对应位置的 float 值;越界(回测早期数据不足)返回 ``nan``,
而非抛 IndexError —— 策略里写 ``close[-1]`` 在首根 bar 不会崩溃,
与 warmup 机制配合避免指标预热期误信号。
"""
idx = self._bar_index + key
if idx < 0:
raise IndexError(f"索引 {key} 超出范围(bar_index={self._bar_index}")
return float("nan")
return float(self._series[idx])
def __len__(self) -> int:
+1 -1
View File
@@ -108,7 +108,7 @@ class BacktestResult:
config: 配置参数字典
"""
performance: dict[str, float]
performance: dict[str, float | str]
equity_curve: pd.DataFrame
trades: pd.DataFrame
positions: pd.DataFrame
+55 -1
View File
@@ -6,6 +6,7 @@ from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
import numpy as np
import numpy.typing as npt
import pandas as pd
if TYPE_CHECKING:
@@ -46,6 +47,47 @@ def get_optimizer(name: str) -> WeightOptimizer:
return _OPTIMIZER_REGISTRY[name]()
def _apply_weight_floor(weights: npt.NDArray[np.float64], floor: float) -> npt.NDArray[np.float64]:
"""对权重向量施加下限 floor,保证每只标的都有实质权重,和仍为 1.0。
把低于 floor 的权重抬到 floor,需补偿的总量按比例从高于 floor 的权重
中扣除(迭代直至稳定)。若所有权重都需抬到 floor(floor 过大)则回退
为等权,避免负值。
Args:
weights: 原始权重(和为 1.0)。
floor: 单标的权重下限。
Returns:
调整后权重(和为 1.0,每项 ≥ floor,除非 floor*N > 1 时等权)。
"""
n = len(weights)
if n == 0 or floor <= 0:
return weights
# floor 过大(floor*N > 1)无法满足,直接等权。
if floor * n >= 1.0:
return np.full(n, 1.0 / n)
w = weights.astype(np.float64).copy()
for _ in range(n + 1): # 最多迭代 N 次即收敛
below = w < floor
if not below.any():
break
deficit = np.sum(np.where(below, floor - w, 0.0))
w = np.where(below, floor, w)
# 从未触底的部分按比例扣除缺口
above = w > floor
surplus = w[above].sum()
if surplus <= 0:
break
w[above] -= deficit * (w[above] / surplus)
# 兜底归一(消除浮点累积误差)
total = w.sum()
if total > 0:
w = w / total
return w
@register_optimizer("equal")
class EqualWeightOptimizer(WeightOptimizer):
"""等权 — 取 top-N 等权分配。"""
@@ -67,7 +109,13 @@ class EqualWeightOptimizer(WeightOptimizer):
@register_optimizer("factor_weighted")
class FactorWeightedOptimizer(WeightOptimizer):
"""因子加权 — 按因子得分加权。"""
"""因子加权 — 按因子得分加权。
对 top-N 的因子得分做线性归一化后施加权重下限(floor),避免在 N
较小(如 n_stocks=2)且得分接近时,"减最小值" 把低分标的权重压到
接近 0、实际等于单股满仓(见 issue #25)。floor 保证每只入选标的都
拿到至少 ``1/(N*10)`` 的权重,剩余按得分比例分配,权重和仍为 1.0。
"""
def optimize(
self,
@@ -89,6 +137,12 @@ class FactorWeightedOptimizer(WeightOptimizer):
w = 1.0 / len(top)
return {row["code"]: w for _, row in top.iterrows()}
weights = scores / total
# 权重下限:每只入选标的至少 1/(N*10),防止线性归一化在小 N +
# 得分接近时把低分权重压到 ~0(n_stocks 被实际忽略)。
n = len(top)
floor = 1.0 / (n * 10)
weights = _apply_weight_floor(weights, floor)
return {row["code"]: float(weights[i]) for i, (_, row) in enumerate(top.iterrows())}
+6 -3
View File
@@ -137,7 +137,7 @@ class RebalanceEngine:
if trades_list
else pd.DataFrame(columns=["datetime", "direction", "code", "shares", "price", "cost"])
)
performance = self._compute_performance(equity_curve)
performance = self._compute_performance(equity_curve, trades_df)
return RebalanceResult(
rebalance_dates=rebalance_dates,
states=states,
@@ -202,7 +202,9 @@ class RebalanceEngine:
holdings.update(new_holdings)
return trades_list, cash, holdings
def _compute_performance(self, equity_curve: pd.DataFrame) -> dict[str, float]:
def _compute_performance(
self, equity_curve: pd.DataFrame, trades_df: pd.DataFrame
) -> dict[str, float]:
if len(equity_curve) < 2:
return {"total_return": 0.0, "annual_return": 0.0, "max_drawdown": 0.0, "sharpe": 0.0}
total = equity_curve["total"].to_numpy()
@@ -224,7 +226,7 @@ class RebalanceEngine:
"annual_return": annual_return,
"max_drawdown": max_drawdown,
"sharpe": sharpe,
"total_trades": len(equity_curve),
"total_trades": len(trades_df),
}
def _empty_result(self) -> RebalanceResult:
@@ -240,5 +242,6 @@ class RebalanceEngine:
"annual_return": 0.0,
"max_drawdown": 0.0,
"sharpe": 0.0,
"total_trades": 0,
},
)
+31 -8
View File
@@ -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:
+79 -3
View File
@@ -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 #23DualMAStrategy 在首根 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
+15
View File
@@ -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):
+12
View File
@@ -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)