阅读提示

这篇是实战调试手册,目标是让你在回测结果不符合预期时,能快速定位问题。核心是 BacktestBase 的四大诊断点和对应的修复思路。

导言

跑完第一个回测,你可能遇到几种”不对劲”的情况:

  • 净值曲线是平的,像一根直线。
  • 明明设了 100 万资金,但 actual_weight 全是 0。
  • 策略跑了两天就停了,后面的日期都没数据。
  • 报告里的收益统计看起来不错,但自己算了一下对不上。

这些问题的根源大多在同一个地方:权重矩阵到实际成交之间的损耗。Betalens 提供了一套诊断顺序,按部就班查下去,通常能在 5 分钟内找到原因。

诊断流程总览

当回测结果不符合预期时,按这个顺序查:

1
2
3
4
5
6
7
① actual_weight ──→ 有没有真实的股票权重?
├─ 是 → ② position ──→ 资金够买整数手吗?
│ ├─ 是 → ③ trade_status_matrix ──→ 有股票被停牌了吗?
│ │ ├─ 是 → ④ rebalance_log ──→ 成交价/手数/金额对吗?
│ │ └─ 否 → 因子本身的问题
│ └─ 否(资金太小)→ 提高 amount 或降低股票数量
└─ 否 → 权重矩阵本身的问题(分组失败 / 权重生成逻辑)

诊断点详解

① 检查 engine.actual_weight

actual_weight 是经过停牌处理、整数手取整之后,真正参与净值计算的权重。如果它和输入的 weight 差异很大,说明回测引擎做了大幅调整。

1
2
3
4
5
6
7
8
9
10
import pandas as pd

engine = BacktestBase(...) # 先跑完回测

# 查看实际权重的头几行
print(engine.actual_weight.head())

# 对比输入权重和实际权重
print("输入权重和 =", engine.weight.iloc[:, :-1].sum(axis=1).mean()) # 不含 cash 列
print("实际权重和 =", engine.actual_weight.iloc[:, :-1].sum(axis=1).mean())

常见问题actual_weight 接近 0,但 weight 有值。这通常是因为 position 全是 0(见诊断点②)。

② 检查 engine.position

position 是每日持仓(股数),由 actual_weight 和当日收盘价计算得出。如果它是 0,说明资金买不了任何整数手。

1
2
3
4
5
6
7
# 看第一天的持仓
first_date = engine.actual_weight.index[0]
print(engine.position.loc[first_date].nlargest(10, keep='all'))

# 快速诊断:哪些股票有持仓?
has_position = engine.position.sum(axis=0) > 0
print(f"有持仓的股票数量:{has_position.sum()} / {len(has_position)}")

常见原因

  • amount 太小。例如 100 万买 A 股,每手 100 股,最便宜的股票也要几百元一手,1 万元买不了几手。
  • 分组太细,导致每组只有很少的股票,而每只需要买 1 手,资金分散后每只都买不了。

解决思路

1
2
3
4
5
# 方法1:提高初始资金
engine = BacktestBase(weight=weights, amount=10_000_000, ...) # 1000万

# 方法2:减少分组数或扩大每组股票数量
labeled = single_characteristic(data, "股息率(报告期)", {"股息率(报告期)": 5}) # 分成5组而非10组

③ 检查 engine.trade_status_matrix

trade_status_matrix 记录每天每只股票的交易状态还原值:1 正常、0 停牌、-1 未上市。如果大量股票在某些日期是 0BacktestBase 会按 trade_status_mode 处理。

1
2
3
4
5
6
7
8
9
10
11
import numpy as np

# 统计停牌比例
ts = engine.trade_status_matrix
停牌比例 = (ts == 0).sum().sum() / ts.size
print(f"停牌比例:{停牌比例:.2%}")

# 看哪天停牌最多
停牌日 = (ts == 0).sum(axis=1)
print("停牌最多的5天:")
print(停牌日.nlargest(5))

trade_status_mode 的四种策略

模式 行为 适用场景
to_cash(默认) 停牌股票转现金,按当日现金比例分配 保守、模拟真实交易
hold 继续持有,停牌日不操作 长期持有、减少换手
redistribute 停牌股票的权重重新分配给其他正常交易的股票 希望满仓运行
as_normal 忽略停牌状态,按正常价格成交(不推荐) 回测对照实验

如果想查看具体某只股票的停牌情况:

1
2
3
4
5
# 看"平安银行"(000001.SZ)的停牌日期
code = "000001.SZ"
停牌日期 = engine.trade_status_matrix[engine.trade_status_matrix[code] == 0].index
print(f"停牌日期数:{len(停牌日期)}")
print(停牌日期[:10])

④ 检查 engine.rebalance_log

rebalance_log 是调仓审计日志,记录每次调仓的详情:日期、股票代码、成交价、买入/卖出股数、成交手数、成交金额。

1
2
3
4
5
6
log = engine.rebalance_log
print(log.head(20))

# 看交易费用
print(f"总手续费:{log['commission'].sum():,.2f}")
print(f"总滑点损失:{log['slippage'].sum():,.2f}")

常见问题

  • 交易费用太高:可能是调仓太频繁(rebal_freq 设成了 "D" 每天调仓)。A 股默认印花税 + 佣金对日内交易非常不友好。
  • 买入/卖出数量是 0:说明该股票当天无法成交(可能是停牌日)。

综合诊断脚本

把以上四点打包成一个函数,方便每次回测后调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
def diagnose_backtest(engine, top_n=10):
"""回测综合诊断,按顺序检查四个关键点。"""
print("=" * 60)
print("① actual_weight 检查")
print("=" * 60)
aw = engine.actual_weight
print(f"行数(调仓日):{len(aw)},列数(股票+cash):{len(aw.columns)}")
print(f"平均总权重(不含cash):{aw.iloc[:, :-1].sum(axis=1).mean():.4f}")

if aw.iloc[:, :-1].abs().sum(axis=1).mean() < 0.01:
print("⚠️ 警告:实际股票权重接近0!请检查 ② position")
print()

print("=" * 60)
print("② position 检查")
print("=" * 60)
pos = engine.position
has_pos = (pos.sum(axis=0) > 0).sum()
print(f"有持仓的股票数量:{has_pos} / {len(pos.columns)}")
print(f"非零持仓日数:{(pos > 0).sum(axis=0).nlargest(top_n).to_string()}")
print()

print("=" * 60)
print("③ trade_status_matrix 检查")
print("=" * 60)
ts = engine.trade_status_matrix
停牌率 = (ts == 0).sum().sum() / ts.size
未上市率 = (ts == -1).sum().sum() / ts.size
print(f"停牌率:{停牌率:.2%},未上市率:{未上市率:.2%}")
print()

print("=" * 60)
print("④ rebalance_log 检查")
print("=" * 60)
log = engine.rebalance_log
if log is not None and len(log) > 0:
print(f"调仓记录总数:{len(log)}")
print(f"买入次数:{(log['side'] == 'buy').sum()},卖出次数:{(log['side'] == 'sell').sum()}")
print(f"总成交金额:{log['value'].sum():,.2f}")
else:
print("无调仓记录")
print("=" * 60)

在回测后调用:

1
2
engine = BacktestBase(weight=weights, symbol="Dividend", amount=1_000_000, ...)
diagnose_backtest(engine)

开发者侧:整数手约束的工程实现

从源码角度看,BacktestBase 的成交逻辑在 betalens/backtest/backtest.py 里,核心步骤是:

1
2
3
4
5
6
7
8
# 伪代码
for each rebalance_date:
for each stock_code in target_weights:
target_value = target_weight * portfolio_value
price = get_price(rebalance_date, stock_code)
shares = floor(target_value / price / lot_size) * lot_size # 取整到整手
actual_value = shares * price
engine.actual_weight.loc[rebalance_date, stock_code] = actual_value / portfolio_value
  • lot_size 默认 100(A 股)。
  • floor(向下取整)是整数手约束的核心——永远买不到零头,这个零头差就是”碎片化损耗”。
  • 碎片化损耗在小资金、多持仓时尤其明显。100 万分成 20 只股票,每只 5 万,买 3 元股能买约 166 手(满足),但买 20 元股只能买约 250 手,两者的碎片损耗差异巨大。

常见问题速查

症状 最可能的原因 修复方向
净值曲线平直 actual_weight 全 0 提高 amount,减少持仓数
回测中途净值下跌 停牌导致被动转现金 检查 trade_status_matrix,用 trade_status_mode="redistribute"
收益和报告不一致 用了输入 weight 而非 actual_weight 始终以 engine.actual_weight 为准
每天都在交易 rebal_freq="D" 改成 "W"(每周)或 "M"(每月)
某只股票一直没成交 停牌日期太多 get_tradable_pool 时设 include_abnormal=True 观察,或看 trade_status_matrix

延伸阅读