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
+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)}