mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 13:24:15 +08:00
Merge pull request #29 from handsomejustin/fix/v1.20.2-mypy-ci
fix(types): v1.20.2 修复 v1.20.1 引入的 CI mypy 失败
This commit is contained in:
+10
-1
@@ -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]` 类型契约。
|
||||
|
||||
### 文档
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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("=== 配置参数 ===")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user