diff --git a/CHANGELOG.md b/CHANGELOG.md index b71bcec..4070eb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 +## [1.20.2] — 2026-07-09 + +**修复 v1.20.1 引入的 CI mypy 失败** —— v1.20.1 把 `BacktestResult.performance` 类型扩大为 `dict[str, float | str]`(为塞进 `diagnostic_warning` 字符串),破坏了 6 处下游消费方(portfolio/combo/optimizer/ranker 假设 `dict[str, float]` 做算术比较),CI mypy job 转红。本次重构为更干净的设计:诊断信息走独立的 `BacktestResult.diagnostic` 字段,performance 字典恢复 `dict[str, float]` 类型契约。顺手修复 `optimizer.py` 的 3 个既有 ndarray type-arg 错误。 + +### 修复 + +- **诊断信息独立字段**(`src/easy_tdx/backtest/{performance,engine,types,cli}.py`)—— `PerformanceAnalyzer.diagnostic` 属性承载数据异常提示,`BacktestResult.diagnostic: str | None` 透出,`to_dict()` 含该字段,CLI 表格显示。performance 字典回归 `dict[str, float]`(含 `sharpe_ratio`/`start_cash`/`end_value` 别名键),下游算术/比较不再类型报错。 +- **`optimizer.py` ndarray 类型标注**(`src/easy_tdx/portfolio/optimizer.py`)—— `FactorWeightedOptimizer`/`RiskParityOptimizer` 的 `scores`/`vol` 局部变量、`MeanVarianceOptimizer.objective` 参数补 `npt.NDArray[np.float64]` 标注,消除 3 个既有 mypy type-arg 错误。 + ## [1.20.1] — 2026-07-09 **修复回测引擎 3 个用户高频踩坑的 bug**(issues #22 / #23 / #25)—— 用户最初反馈"回测统计数据缺失/异常",排查后发现并非服务器连接问题(已建议 `easy-tdx ping`),而是回测引擎与组合优化器自身的代码缺陷:首根 bar 访问历史数据崩溃、再平衡 `n_stocks` 被无视、交易笔数统计成天数。本次逐一修复并补回归测试,同时在数据异常时给出诊断提示而非静默返回全 0。 @@ -14,7 +23,7 @@ ### 新增 -- **绩效指标别名键 + 数据异常诊断**(`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。 +- **绩效指标别名键 + 数据异常诊断**(`src/easy_tdx/backtest/performance.py` + `cli.py` + `types.py`)—— performance dict 新增 `sharpe_ratio` / `start_cash` / `end_value` 别名键,避免用户 `.get('sharpe_ratio')` 误用返回 0(issue #22 body)。资金曲线不足 2 点或有效日收益 < 2 时,`BacktestResult.diagnostic` 字段填充提示(可能数据不全、建议 `easy-tdx ping`),CLI 表格输出显示该提示,不再静默返回全 0。诊断信息独立于数值型 performance 字典,不破坏 `dict[str, float]` 类型契约。 ### 文档 diff --git a/pyproject.toml b/pyproject.toml index caee920..c0ffa7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.20.1" +version = "1.20.2" 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 1704fa2..ab93710 100644 --- a/src/easy_tdx/backtest/cli.py +++ b/src/easy_tdx/backtest/cli.py @@ -256,8 +256,8 @@ 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']}") + if getattr(result, "diagnostic", None): + click.echo(f"⚠ 诊断: {result.diagnostic}") click.echo() click.echo("=== 配置参数 ===") diff --git a/src/easy_tdx/backtest/engine.py b/src/easy_tdx/backtest/engine.py index cde4d98..970b61f 100644 --- a/src/easy_tdx/backtest/engine.py +++ b/src/easy_tdx/backtest/engine.py @@ -166,11 +166,12 @@ class BacktestEngine: # Step 4: Performance analysis trades_df = self._trades_to_df(trades) - performance = PerformanceAnalyzer( + analyzer = PerformanceAnalyzer( tracker.equity_curve, trades_df, risk_free_rate=0.03, - ).compute() + ) + performance = analyzer.compute() # Config snapshot config = { @@ -187,6 +188,7 @@ class BacktestEngine: trades=trades_df, positions=tracker.positions, config=config, + diagnostic=analyzer.diagnostic, ) def _execute_with_model(self, signals: list[Signal], df: pd.DataFrame) -> list[Trade]: @@ -503,10 +505,11 @@ class BacktestEngine: Returns: BacktestResult with empty DataFrames """ - perf = PerformanceAnalyzer( + analyzer = PerformanceAnalyzer( pd.DataFrame(columns=["total", "drawdown", "drawdown_pct"]), pd.DataFrame(columns=["direction", "pnl", "rejected"]), - ).compute() + ) + perf = analyzer.compute() return BacktestResult( performance=perf, @@ -529,4 +532,5 @@ class BacktestEngine: columns=["datetime", "size", "avg_price", "market_value", "unrealized_pnl"] ), config={}, + diagnostic=analyzer.diagnostic, ) diff --git a/src/easy_tdx/backtest/performance.py b/src/easy_tdx/backtest/performance.py index 96f5325..1cb3816 100644 --- a/src/easy_tdx/backtest/performance.py +++ b/src/easy_tdx/backtest/performance.py @@ -48,8 +48,10 @@ class PerformanceAnalyzer: self._equity_curve = equity_curve self._trades = trades self._risk_free_rate = risk_free_rate + # 数据异常诊断(资金曲线不足/恒定时填充),供上层透出给用户 + self.diagnostic: str | None = None - def compute(self) -> dict[str, float | str]: + def compute(self) -> dict[str, float]: """计算绩效指标。 Returns: @@ -76,7 +78,8 @@ class PerformanceAnalyzer: """ # 边界检查 if len(self._equity_curve) < 2: - return self._empty_metrics("资金曲线不足 2 根,无法计算绩效(数据可能为空或不全)") + self.diagnostic = "资金曲线不足 2 根,无法计算绩效(数据可能为空或不全)" + return self._empty_metrics() total = self._equity_curve["total"].to_numpy() drawdown = self._equity_curve["drawdown"].to_numpy() @@ -89,10 +92,11 @@ class PerformanceAnalyzer: # 日收益率数量太少时返回空指标 if len(daily_ret) < 2: - return self._empty_metrics( + self.diagnostic = ( "有效日收益率不足 2 个,绩效全 0(资金曲线可能恒定,常因数据不全或" "交易未生效;建议 easy-tdx ping 切换服务器后重试)" ) + return self._empty_metrics() # 1. 总收益率(首根净值为 0 时无法定义,记为 0.0) total_return = (total[-1] / total[0]) - 1 if total[0] != 0 else 0.0 @@ -337,19 +341,17 @@ class PerformanceAnalyzer: return int(max_dd_idx - peak_idx) - def _empty_metrics(self, diagnostic: str | None = None) -> dict[str, float | str]: - """返回全零指标字典。 + def _empty_metrics(self) -> dict[str, float]: + """返回全零指标字典(数据不足时的默认返回值)。 - 用于数据不足时的默认返回值。``diagnostic`` 非空时一并返回,便于 - 上层(CLI/UI)提示用户绩效全 0 的原因(典型为数据不全)。 - - Args: - diagnostic: 可选的诊断说明,写入返回字典的 ``diagnostic_warning`` 键。 + 数据异常的诊断说明通过 ``self.diagnostic`` 暴露,由上层(CLI/引擎) + 读取后透出给用户,不污染数值型 performance 字典(保持 + ``dict[str, float]`` 类型,避免下游算术/比较类型报错)。 Returns: - 全零的绩效指标字典(可选含 ``diagnostic_warning``)。 + 全零的绩效指标字典(含别名键 sharpe_ratio/start_cash/end_value)。 """ - metrics: dict[str, float | str] = { + return { "total_return": 0.0, "annual_return": 0.0, "max_drawdown": 0.0, @@ -373,6 +375,3 @@ class PerformanceAnalyzer: "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/types.py b/src/easy_tdx/backtest/types.py index 4fe64e8..f5d235c 100644 --- a/src/easy_tdx/backtest/types.py +++ b/src/easy_tdx/backtest/types.py @@ -106,13 +106,15 @@ class BacktestResult: trades: 成交记录 DataFrame positions: 持仓快照 DataFrame config: 配置参数字典 + diagnostic: 数据异常诊断(资金曲线不足/恒定时的提示),无异常时为 None """ - performance: dict[str, float | str] + performance: dict[str, float] equity_curve: pd.DataFrame trades: pd.DataFrame positions: pd.DataFrame config: dict[str, Any] + diagnostic: str | None = None def to_dict(self) -> dict[str, Any]: """将结果转换为 JSON 兼容字典。 @@ -125,6 +127,7 @@ class BacktestResult: "trades": self.trades.to_dict(orient="records"), "positions": self.positions.to_dict(orient="records"), "config": self.config, + "diagnostic": self.diagnostic, } def to_json(self) -> str: diff --git a/src/easy_tdx/portfolio/optimizer.py b/src/easy_tdx/portfolio/optimizer.py index c52b6f1..b7ba0de 100644 --- a/src/easy_tdx/portfolio/optimizer.py +++ b/src/easy_tdx/portfolio/optimizer.py @@ -126,7 +126,7 @@ class FactorWeightedOptimizer(WeightOptimizer): top = factor_scores.nlargest(n_stocks, "score") if len(top) == 0: return {} - scores = top["score"].to_numpy(dtype=np.float64) + scores: npt.NDArray[np.float64] = top["score"].to_numpy(dtype=np.float64) if len(scores) > 10: q95 = np.percentile(scores, 95) q05 = np.percentile(scores, 5) @@ -160,10 +160,10 @@ class RiskParityOptimizer(WeightOptimizer): if len(top) == 0: return {} if "volatility" in top.columns: - vol = top["volatility"].to_numpy(dtype=np.float64) + vol: npt.NDArray[np.float64] = top["volatility"].to_numpy(dtype=np.float64) else: - scores = top["score"].abs().to_numpy(dtype=np.float64) - vol = 1.0 / (scores + 1e-8) + scores: npt.NDArray[np.float64] = top["score"].abs().to_numpy(dtype=np.float64) + vol = (1.0 / (scores + 1e-8)).astype(np.float64) vol = np.maximum(vol, 1e-8) inv_vol = 1.0 / vol total = inv_vol.sum() @@ -205,7 +205,7 @@ class MeanVarianceOptimizer(WeightOptimizer): variances = 1.0 / (np.abs(scores) + 1e-8) ** 2 cov = np.diag(variances) - def objective(w: np.ndarray) -> float: + def objective(w: npt.NDArray[np.float64]) -> float: return float(w @ cov @ w) constraints = {"type": "eq", "fun": lambda w: float(np.sum(w) - 1.0)} diff --git a/tests/unit/test_backtest_performance.py b/tests/unit/test_backtest_performance.py index 19afa14..66ab8cc 100644 --- a/tests/unit/test_backtest_performance.py +++ b/tests/unit/test_backtest_performance.py @@ -233,9 +233,9 @@ def test_empty_equity_curve() -> None: # 数值指标应为 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) + # issue #22:数据不全时给出诊断提示(analyzer.diagnostic),而非静默全 0 + assert analyzer.diagnostic is not None + assert isinstance(analyzer.diagnostic, str) def test_single_point_equity_curve() -> None: @@ -249,7 +249,7 @@ def test_single_point_equity_curve() -> None: # 数值指标应为 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 + assert analyzer.diagnostic is not None def test_profit_factor() -> None: