fix(backtest): v1.20.3 修复回测绩效统计两个 bug(issues #30 #31)

#31: RebalanceEngine 缺失价格导致净值假崩塌
  已持仓标的当日缺 K 线(停牌/日历错位)时 prices.get(code,0)=0,
  持仓市值记 0 → 净值单日暴跌(159915 在 20210208 缺一天,持仓 ~93%,
  净值 1.1M→91,845,全期最大回撤 -92%)。
  修复:last_known_price forward-fill,缺失日沿用最近已知收盘价。
  附带:_compute_performance 最大回撤改正值口径(与 BacktestEngine 一致)。
  验证:真实 ETF 数据 max_drawdown 24.01%(backtrader 基准 27%),
  total_return 220.56% 不变。

#30: PortfolioTracker 交易静默漏单
  apply_trades 用 trade.datetime 作 dict key、df["datetime"].to_numpy()[i]
  查找;两端类型不一致(int vs datetime64)时永不命中,交易被静默丢弃,
  净值恒定(total_return=0 但 trades 表有 PnL)。
  修复:改为按"位置索引"匹配(归一化 datetime 后查位置),类型无关。
  验证:int-trade+datetime64-df 修复前净值=100000(恒定),修复后=100289。

测试:新增 4 回归测试(未修复代码上均失败,修复后通过);
全套 936 passed;mypy 改动文件零错误;ruff 全绿。
This commit is contained in:
GitHub
2026-07-10 15:12:47 +08:00
parent 06be298c92
commit d84a08b7f2
6 changed files with 212 additions and 8 deletions
+14
View File
@@ -2,6 +2,20 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
## [1.20.3] — 2026-07-10
**修复回测绩效统计两个准确性 bug**issues #30 / #31)—— 用户反馈升级到 1.20.2 后回测数据仍然不对:#31 调仓回测最大回撤荒谬(-92%),#30 单标的回测总收益恒为 0(但交易表有盈亏)。排查后定位为两处独立缺陷,逐一修复并补回归测试。
### 修复
- **RebalanceEngine 缺失价格导致净值假崩塌**(`src/easy_tdx/portfolio/rebalance.py`)—— 已持仓标的当日缺 K 线(停牌/上市晚/日历错位)时,`prices.get(code, 0)` 返回 0,该标的持仓市值被记为 0,净值单日暴跌(issue #31159915 在 20210208 缺一天数据,持仓占 ~93%,净值从 1.1M 瞬跌至 91,845,全期最大回撤 -92%)。新增 `last_known_price` forward-fill:缺失日沿用最近已知收盘价估值(停牌标的的标准做法)。修复后真实 ETF 数据最大回撤 24.01%(用户 backtrader 基准 27%),总收益 220.56% 不变。
- **RebalanceEngine 最大回撤符号口径**`src/easy_tdx/portfolio/rebalance.py`)—— `_compute_performance` 此前用 `(total-peak)/peak + np.min` 返回**负**最大回撤,与 `BacktestEngine.PerformanceAnalyzer`(正值 `[0,1]`)、CLI/文档约定不一致。改为 `(peak-total)/peak + np.max` 正值口径。
- **PortfolioTracker 交易静默漏单**`src/easy_tdx/backtest/portfolio.py`)—— `apply_trades``trade.datetime` 作 dict key、用 `df["datetime"].to_numpy()[i]` 查找;两端类型不一致(int YYYYMMDD vs datetime64)时 `trade_map.get(dt)` 永不命中,全部交易被静默丢弃,净值恒等于初始资金(issue #30`total_return=0, volatility=0, end_value=100000`,但 trades 表有 PnL,因 `_compute_pnls` 不依赖 df 查找)。改为按"位置索引"匹配:预构建归一化 datetime→位置映射,trade.datetime 无论 Timestamp/int/datetime64 都能正确命中,彻底消除该静默失败。
### 测试
- 新增 4 个回归测试:`test_apply_trades_int_datetime_vs_datetime64_df` / `test_apply_trades_timestamp_vs_int_df`#30int↔datetime64 类型不一致仍正确撮合);`test_missing_price_does_not_collapse_equity` / `test_max_drawdown_sign_positive`#31,缺数据不假崩塌 + 回撤正值)。四测试在未修复代码上**均失败**,修复后通过。全套 936 passed;mypy 改动文件零错误;ruff 全绿。
## [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 错误。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "easy-tdx"
version = "1.20.2"
version = "1.20.3"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md"
requires-python = ">=3.10"
+60 -5
View File
@@ -39,6 +39,58 @@ class PortfolioTracker:
self._position = np.zeros(self._n)
self._avg_price = np.zeros(self._n)
self._initial_cash = initial_cash
# 预构建 datetime → 位置索引 映射,供 apply_trades 按"位置"匹配交易。
# 关键:trade.datetime 与 df["datetime"] 的具体类型(Timestamp / int /
# datetime64)必须一致才能作 dict key 命中;过去直接用原始值匹配,
# 一旦两端类型不一致(如 datetime 列被预处理成 int、混合数据源、或
# pandas 版本哈希差异)就静默漏掉全部交易 → 净值恒定(issue #30)。
# 这里把匹配收敛到归一化后的位置索引,与类型无关。
self._datetime_to_pos = self._build_datetime_index(df["datetime"])
@staticmethod
def _build_datetime_index(dt_col: pd.Series) -> dict[object, int]:
"""构建 datetime 值 → 位置索引 的映射。
对 datetime 列做归一化(datetime64 → int YYYYMMDD,与 OrderSimulator
的 _find_bar_index 同款逻辑),保证无论上游 datetime 是哪种类型,
查询键都能命中同一张表。
Args:
dt_col: DataFrame 的 datetime 列
Returns:
{归一化后的 datetime 值: 位置索引},重复值取首次出现位置。
"""
if pd.api.types.is_datetime64_any_dtype(dt_col):
keys = dt_col.dt.strftime("%Y%m%d").astype("int64").to_numpy()
else:
keys = dt_col.to_numpy()
mapping: dict[object, int] = {}
for i, k in enumerate(keys):
if k not in mapping:
mapping[k] = i
return mapping
def _find_pos(self, trade_dt: object) -> int | None:
"""把 trade.datetime 归一化后查 df 中的位置索引。
与 _build_datetime_index 使用同一套归一化规则,保证类型无关命中。
Args:
trade_dt: 交易时间(int YYYYMMDD / Timestamp / datetime64
Returns:
df 中的位置索引,未命中返回 None
"""
# datetime-likeTimestamp / datetime64)→ int YYYYMMDD 再查
if hasattr(trade_dt, "strftime"):
try:
key: object = int(trade_dt.strftime("%Y%m%d"))
except (ValueError, AttributeError):
return None
else:
key = trade_dt
return self._datetime_to_pos.get(key)
def apply_trades(self, trades: list[Trade]) -> None:
"""应用交易记录,更新内部状态数组。
@@ -46,17 +98,20 @@ class PortfolioTracker:
Args:
trades: 交易列表
"""
# 构建 datetime → [Trade] 映射(支持同 bar 多笔交易)
# 构建 位置索引 → [Trade] 映射(支持同 bar 多笔交易)
# 用位置索引而非原始 datetime 值作 key,与 trade.datetime 的具体类型
# 无关,杜绝 int vs datetime64 类型不一致导致的静默漏单(issue #30)。
trade_map: dict[int, list[Trade]] = {}
for trade in trades:
if trade.rejected:
continue
trade_map.setdefault(trade.datetime, []).append(trade)
pos = self._find_pos(trade.datetime)
if pos is None:
continue
trade_map.setdefault(pos, []).append(trade)
# 遍历每个 bar
for i in range(self._n):
dt = self._datetime[i]
# 继承前一个 bar 的状态(除了第一个 bar)
if i > 0:
self._cash[i] = self._cash[i - 1]
@@ -64,7 +119,7 @@ class PortfolioTracker:
self._avg_price[i] = self._avg_price[i - 1]
# 处理该 bar 的所有交易
for trade in trade_map.get(dt, []):
for trade in trade_map.get(i, []):
if trade.direction == "BUY":
cost = trade.size * trade.price + trade.commission + trade.slippage
self._cash[i] -= cost
+21 -2
View File
@@ -73,6 +73,10 @@ class RebalanceEngine:
states: list[PortfolioState] = []
trades_list: list[dict[str, object]] = []
equity_records: list[dict[str, object]] = []
# 已持仓标的的最近已知收盘价。当某标的当日缺 K 线(停牌/上市晚/日历
# 错位)时,用上一已知价估值,避免持仓市值被错误记为 0 而造成净值单日
# 假崩塌(issue #31159915 在 20210208 缺一天数据 → -92% 假回撤)。
last_known_price: dict[str, float] = {}
for dt in all_dates:
date_int = int(dt.strftime("%Y%m%d"))
@@ -85,6 +89,13 @@ class RebalanceEngine:
if not row.empty:
prices[code] = float(row["close"].iloc[0])
# forward-fill:当日无数据的标的沿用最近已知价(仅对已持仓标的有影响)
for code in holdings:
if code not in prices and code in last_known_price:
prices[code] = last_known_price[code]
# 记录本日出现的最新价,供后续缺失日沿用
last_known_price.update(prices)
position_value = sum(holdings.get(c, 0) * prices.get(c, 0) for c in holdings)
total_value = cash + position_value
@@ -212,8 +223,16 @@ class RebalanceEngine:
n_days = len(total)
annual_return = (1 + total_return) ** (252 / max(n_days, 1)) - 1
peak = np.maximum.accumulate(total)
drawdown = (total - peak) / peak
max_drawdown = float(np.min(drawdown))
# 与 BacktestEngine.PerformanceAnalyzer 一致的正值口径:
# drawdown_pct = (peak - total) / peak ∈ [0,1]max_drawdown 取最大值。
# 此前用 (total-peak)/peak + np.min 返回负值,与 CLI/文档约定不符。
drawdown_pct = np.divide(
peak - total,
peak,
out=np.zeros_like(peak),
where=(peak != 0),
)
max_drawdown = float(np.max(drawdown_pct))
daily_ret = np.diff(total) / total[:-1]
daily_ret = daily_ret[~np.isnan(daily_ret)]
sharpe = (
+72
View File
@@ -287,3 +287,75 @@ def test_empty_trades() -> None:
assert (equity["cash"] == 100000).all()
# 所有 bar 持仓应为 0
assert (positions["size"] == 0).all()
def test_apply_trades_int_datetime_vs_datetime64_df() -> None:
"""issue #30trade.datetime(int) 与 df datetime(datetime64) 类型不一致时,
交易仍应被正确应用,而非静默漏单导致净值恒定。
复现:过去 apply_trades 用 trade.datetime 作 dict key、用
df["datetime"].to_numpy()[i] 查找;两端类型不一致(int vs datetime64
时 trade_map.get(dt) 永不命中,全部交易被丢弃 → 净值恒等于初始资金,
但 trades 表里仍有 PnL_compute_pnls 不依赖 df 查找)。
"""
# df 的 datetime 列为 datetime64(真实 get_stock_kline 路径)
df = pd.DataFrame(
{
"datetime": pd.date_range("2024-01-01", periods=10, freq="D"),
"close": [10, 11, 12, 11, 10, 13, 14, 13, 15, 16],
}
)
# trade.datetime 为 int YYYYMMDD(类型与 df 不一致)
buy = Trade(
datetime=20240101,
direction="BUY",
size=100,
price=10.0,
commission=5.0,
slippage=0.0,
)
sell = Trade(
datetime=20240106,
direction="SELL",
size=100,
price=13.0,
commission=6.0,
slippage=0.0,
)
tracker = PortfolioTracker(df, initial_cash=100000)
tracker.apply_trades([buy, sell])
equity = tracker.equity_curve
# 卖出后现金 = 100000 - 100*10 - 5 + 100*13 - 6 = 100289
# 修复前此处为 100000(交易被静默丢弃)
assert equity["cash"].iloc[-1] == 100289.0
# 净值不应恒等于初始资金(交易生效)
assert equity["total"].iloc[-1] != 100000.0
def test_apply_trades_timestamp_vs_int_df() -> None:
"""issue #30 反向:trade.datetime(Timestamp) 与 df datetime(int) 不一致。"""
df = pd.DataFrame(
{
"datetime": [
int(d.strftime("%Y%m%d")) for d in pd.date_range("2024-01-01", periods=10, freq="D")
],
"close": [10, 11, 12, 11, 10, 13, 14, 13, 15, 16],
}
)
buy = Trade(
datetime=pd.Timestamp("2024-01-01"),
direction="BUY",
size=100,
price=10.0,
commission=5.0,
slippage=0.0,
)
tracker = PortfolioTracker(df, initial_cash=100000)
tracker.apply_trades([buy])
# 修复前交易被丢弃、持仓为 0
assert tracker.positions["size"].iloc[0] == 100
+44
View File
@@ -86,3 +86,47 @@ class TestRebalanceEngine:
assert result.performance["total_trades"] == len(result.trades)
# 修复前 total_trades == len(equity_curve)(天数),明显大于交易笔数
assert result.performance["total_trades"] != len(result.equity_curve)
def test_missing_price_does_not_collapse_equity(self):
"""issue #31:已持仓标的当日缺 K 线时,市值不应被记为 0 导致净值假崩塌。
复现:一只标的在中段缺若干交易日数据,且被持有;修复前该标的缺数据
的日子市值按 0 计,净值单日暴跌,max_drawdown 荒谬(如 -92%)。
forward-fill 后用最近已知价估值,净值曲线平滑、max_drawdown 合理。
"""
data = _make_market(n_stocks=3, n_days=120, seed=7)
# 让第一只标的中段缺 5 天数据(模拟停牌/日历错位)
target = "000000"
df0 = data[target]
keep_mask = ~df0["datetime"].isin(df0["datetime"].iloc[55:60])
data[target] = df0[keep_mask].reset_index(drop=True)
engine = RebalanceEngine(
optimizer=EqualWeightOptimizer(),
n_stocks=3,
rebalance_freq="M",
cash=1_000_000,
)
result = engine.run(data, start_date=20240101, end_date=20240430)
ec = result.equity_curve.sort_values("datetime").reset_index(drop=True)
prev = ec["total"].shift(1)
pct_chg = (ec["total"] - prev) / prev
# 无单日 >50% 假崩塌(修复前会出现接近 -100% 的尖刺)
assert pct_chg.min() > -0.5, f"单日跌幅 {pct_chg.min():.2%} 异常,疑似缺数据假崩塌"
# 最大回撤合理(< 90%)且为正值
md = result.performance["max_drawdown"]
assert 0.0 <= md < 0.9, f"max_drawdown={md:.4f} 不合理"
def test_max_drawdown_sign_positive(self):
"""issue #31 附带:max_drawdown 应为正值 [0,1],与 BacktestEngine 约定一致。"""
engine = RebalanceEngine(
optimizer=EqualWeightOptimizer(),
n_stocks=3,
rebalance_freq="M",
cash=1_000_000,
)
result = engine.run(_make_market(), start_date=20240101, end_date=20240430)
md = result.performance["max_drawdown"]
assert 0.0 <= md <= 1.0, f"max_drawdown={md} 应落在 [0,1]"