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
+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]"