diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b5a54..b71bcec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`,零冗余、不新增配置开关。 diff --git a/README.md b/README.md index 1244051..e2a0408 100644 --- a/README.md +++ b/README.md @@ -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 生成买卖信号,引擎完成订单模拟、持仓跟踪和绩效分析。 **单策略回测:** diff --git a/docs/backtest_usage.md b/docs/backtest_usage.md index fae68a4..eaa0f9e 100644 --- a/docs/backtest_usage.md +++ b/docs/backtest_usage.md @@ -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) +``` + ### 成交价规则 | 模式 | 成交价 | 说明 | diff --git a/pyproject.toml b/pyproject.toml index 532d384..caee920 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/easy_tdx/backtest/cli.py b/src/easy_tdx/backtest/cli.py index 177a2cc..1704fa2 100644 --- a/src/easy_tdx/backtest/cli.py +++ b/src/easy_tdx/backtest/cli.py @@ -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}") diff --git a/src/easy_tdx/backtest/engine.py b/src/easy_tdx/backtest/engine.py index 30a8ce6..cde4d98 100644 --- a/src/easy_tdx/backtest/engine.py +++ b/src/easy_tdx/backtest/engine.py @@ -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() diff --git a/src/easy_tdx/backtest/performance.py b/src/easy_tdx/backtest/performance.py index 7e01f1a..96f5325 100644 --- a/src/easy_tdx/backtest/performance.py +++ b/src/easy_tdx/backtest/performance.py @@ -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 diff --git a/src/easy_tdx/backtest/strategy.py b/src/easy_tdx/backtest/strategy.py index 818de25..77c7dad 100644 --- a/src/easy_tdx/backtest/strategy.py +++ b/src/easy_tdx/backtest/strategy.py @@ -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: diff --git a/src/easy_tdx/backtest/types.py b/src/easy_tdx/backtest/types.py index 2be89fa..4fe64e8 100644 --- a/src/easy_tdx/backtest/types.py +++ b/src/easy_tdx/backtest/types.py @@ -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 diff --git a/src/easy_tdx/portfolio/optimizer.py b/src/easy_tdx/portfolio/optimizer.py index e397a63..c52b6f1 100644 --- a/src/easy_tdx/portfolio/optimizer.py +++ b/src/easy_tdx/portfolio/optimizer.py @@ -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())} diff --git a/src/easy_tdx/portfolio/rebalance.py b/src/easy_tdx/portfolio/rebalance.py index d856082..e2fa71c 100644 --- a/src/easy_tdx/portfolio/rebalance.py +++ b/src/easy_tdx/portfolio/rebalance.py @@ -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, }, ) diff --git a/tests/unit/test_backtest_performance.py b/tests/unit/test_backtest_performance.py index d388c08..19afa14 100644 --- a/tests/unit/test_backtest_performance.py +++ b/tests/unit/test_backtest_performance.py @@ -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: diff --git a/tests/unit/test_backtest_strategy.py b/tests/unit/test_backtest_strategy.py index bde0e52..57b6fd2 100644 --- a/tests/unit/test_backtest_strategy.py +++ b/tests/unit/test_backtest_strategy.py @@ -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 diff --git a/tests/unit/test_portfolio_optimizer.py b/tests/unit/test_portfolio_optimizer.py index 2a64f53..f01b1da 100644 --- a/tests/unit/test_portfolio_optimizer.py +++ b/tests/unit/test_portfolio_optimizer.py @@ -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): diff --git a/tests/unit/test_portfolio_rebalance.py b/tests/unit/test_portfolio_rebalance.py index 1987fdb..b88c986 100644 --- a/tests/unit/test_portfolio_rebalance.py +++ b/tests/unit/test_portfolio_rebalance.py @@ -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)