mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 14:34:15 +08:00
排查发现用户反馈的"回测统计数据缺失/异常"并非服务器连接问题, 而是回测引擎与组合优化器自身的代码缺陷: #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:
@@ -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 #25,56 笔交易显示为 500)。改为 `len(trades_df)`。
|
||||
|
||||
### 新增
|
||||
|
||||
- **绩效指标别名键 + 数据异常诊断**(`src/easy_tdx/backtest/performance.py` + `cli.py`)—— performance dict 新增 `sharpe_ratio` / `start_cash` / `end_value` 别名键,避免用户 `.get('sharpe_ratio')` 误用返回 0(issue #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`,零冗余、不新增配置开关。
|
||||
|
||||
@@ -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 生成买卖信号,引擎完成订单模拟、持仓跟踪和绩效分析。
|
||||
|
||||
**单策略回测:**
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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