fix(types): v1.20.2 修复 v1.20.1 引入的 CI mypy 失败

v1.20.1 把 BacktestResult.performance 扩大为 dict[str, float | str]
(为塞 diagnostic_warning 字符串),破坏 6 处下游消费方类型契约,
CI mypy job 转红。重构为更干净的设计:

- 诊断信息走独立的 BacktestResult.diagnostic 字段(performance.py
  PerformanceAnalyzer.diagnostic 属性 → engine 透出 → to_dict 含该字段
  → CLI 显示),performance 字典回归 dict[str, float](保留别名键)。
- optimizer.py 补 npt.NDArray 标注,消除 3 个既有 ndarray type-arg 错误。

测试:932 passed;mypy backtest/+portfolio/ 零错误;ruff 全绿。
This commit is contained in:
GitHub
2026-07-09 20:34:23 +08:00
parent eb51770608
commit a67e5588d0
8 changed files with 48 additions and 33 deletions
+10 -1
View File
@@ -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')` 误用返回 0issue #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')` 误用返回 0issue #22 body)。资金曲线不足 2 点或有效日收益 < 2 时`BacktestResult.diagnostic` 字段填充提示可能数据不全、建议 `easy-tdx ping`),CLI 表格输出显示该提示,不再静默返回全 0。诊断信息独立于数值型 performance 字典,不破坏 `dict[str, float]` 类型契约。
### 文档
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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("=== 配置参数 ===")
+8 -4
View File
@@ -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,
)
+14 -15
View File
@@ -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
+4 -1
View File
@@ -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:
+5 -5
View File
@@ -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)}
+4 -4
View File
@@ -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: