阅读提示

这篇专门讲解 BacktestBase 的调仓审计日志(rebalance_log)和现金管理机制。如果你想理解每次调仓具体发生了什么——成交了多少股、花了多少钱、手续费多少——这篇会给你完整的答案。

导言

每次调仓(Rebalance),BacktestBase 内部会执行一次完整的撮合过程:

1
目标权重 → 计算目标股数 → 取整到整数手 → 计算实际成交金额 → 更新持仓 → 计算净值

rebalance_log 记录了这个过程里每一笔交易的明细——日期、股票代码、买入/卖出方向、成交价格、股数、手数、金额、手续费。如果回测出了问题,rebalance_log 是最重要的调试数据。

rebalance_log 字段说明

1
2
log = engine.rebalance_log
print(log.columns.tolist())
字段 类型 含义
rebalance_date datetime 调仓日期
code string 证券代码
name string 证券名称(中文名)
side string buy 或 sell
price float 成交价格(元)
shares int 成交股数
lots int 成交手数
value float 成交金额(元)
commission float 手续费(元)
slippage float 滑点损失(元,可选)
target_weight float 调仓前目标权重
actual_weight float 调仓后实际权重

常见分析场景

场景 1:看总交易成本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
log = engine.rebalance_log

# 按买入/卖出分开统计
buys = log[log["side"] == "buy"]
sells = log[log["side"] == "sell"]

print(f"买入总额:{buys['value'].sum():,.2f}")
print(f"卖出总额:{sells['value'].sum():,.2f}")
print(f"总手续费:{log['commission'].sum():,.2f}")
print(f"总滑点损失:{log['slippage'].sum():,.2f}")
print(f"总交易成本:{(log['commission'].sum() + log['slippage'].sum()):,.2f}")

# 交易成本占初始资金的比例
total_cost = log['commission'].sum() + log['slippage'].sum()
cost_ratio = total_cost / engine.amount
print(f"交易成本率:{cost_ratio:.2%}")

场景 2:看哪只股票换手最频繁

1
2
3
4
5
6
7
8
9
# 按股票代码统计换手次数
turnover_count = log.groupby("code")["side"].count().sort_values(ascending=False)
print("换手最频繁的10只股票:")
print(turnover_count.head(10))

# 看某只股票的详细调仓记录
code = "000001.SZ"
stock_log = log[log["code"] == code]
print(stock_log[["rebalance_date", "side", "shares", "price", "value"]])

场景 3:看整数手的碎片化损耗

整数手约束会导致”凑不够一手”的碎片化损耗:

1
2
3
4
5
6
# 目标股数 vs 实际成交股数的差异
log["target_shares"] = log["value"] / log["price"] # 目标股数(连续)
log["round_lot_loss"] = log["target_shares"] - log["shares"] # 碎片损耗

print(f"总碎片损耗股数:{log['round_lot_loss'].sum():,.0f}")
print(f"碎片损耗占总目标股数:{log['round_lot_loss'].sum() / log['target_shares'].sum():.2%}")

碎片化损耗在资金小、持仓多时尤其严重。如果总损耗超过 1%,说明初始资金可能太小,或者每组股票数量过多。

场景 4:审计停牌导致的”被迫卖出”

1
2
3
4
5
6
# 找出被迫卖出(停牌后转现金)的记录
forced_sells = log[log["slippage"] > 0] # slippage>0 通常意味着无法按目标价格成交

if len(forced_sells) > 0:
print("被迫卖出记录:")
print(forced_sells[["rebalance_date", "code", "side", "value", "slippage"]])

场景 5:重建每日持仓明细

1
2
3
4
5
6
7
8
9
# 从 rebalance_log 重建持仓变化
trades = log[["rebalance_date", "code", "side", "shares", "price"]].copy()
trades["signed_shares"] = trades.apply(
lambda r: r["shares"] if r["side"] == "buy" else -r["shares"], axis=1
)

# 累计持仓
position_from_log = trades.groupby(["code", "rebalance_date"])["signed_shares"].sum()
print(position_from_log.head(20))

actual_weight 与 position 的关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# actual_weight:每日每只股票占总资产的比例
print(engine.actual_weight.head())

# position:每日每只股票的持股数(整数手 × lot_size)
print(engine.position.head())

# 验证:actual_weight × 总资产 ≈ position × 价格
total_value = engine.daily_position_value # Series
for date in engine.actual_weight.index[:5]:
for code in engine.actual_weight.columns[:-1]: # 排除 cash 列
weight = engine.actual_weight.loc[date, code]
pos = engine.position.loc[date, code]
price = engine.daily_pnl.loc[date, code] # 这里简化了
if pd.notna(weight) and pd.notna(pos):
assert abs(weight * total_value.loc[date] - pos * price) < 1, "数据不一致!"

开发者侧:rebalance_log 的生成时机

rebalance_log 不是”每天都生成一行”,而是每次调仓时生成。如果某只股票从调仓日1到调仓日2之间没有发生调仓,对应的 rebalance_log 里就不会有记录——这是合理的,因为没有交易就没有审计日志。

1
2
3
4
5
6
# 调仓次数
print(f"调仓次数:{engine.rebalance_log['rebalance_date'].nunique()}")

# 调仓频率
rebal_dates = engine.rebalance_log['rebalance_date'].unique()
print(f"平均每次调仓股票数:{len(engine.rebalance_log) / len(rebal_dates):.1f}")

现金管理的细节

现金流入

  • 卖出证券:卖出金额扣除手续费后进入现金。
  • 停牌转现金:trade_status_mode="to_cash" 时,停牌股票在下一个交易日开盘被卖出(或等比例转现金)。

现金流出

  • 买入证券:买入金额从现金扣除,加上手续费。
  • 整数手碎片:每次买股的碎片化”凑不够一手”部分资金留在现金。

现金的跟踪

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 从 position 和 price 重建现金余额
cash_series = []
for date in engine.nav.index:
# 当日持仓市值
position_value = 0
for code in engine.actual_weight.columns[:-1]:
weight = engine.actual_weight.loc[date, code]
if pd.notna(weight):
position_value += weight * engine.nav.loc[date] * engine.amount

# 当日净值 → 反推现金
nav = engine.nav.loc[date]
total_assets = nav * engine.amount
cash = total_assets - position_value
cash_series.append(cash)

import pandas as pd
cash_df = pd.Series(cash_series, index=engine.nav.index, name="cash")
print(cash_df.describe())

常见问题

1. rebalance_log 是空的

1
2
print(engine.rebalance_log)
# Empty DataFrame

原因:actual_weight 全为 0(资金太小买不了任何股票)。见 04 篇的诊断流程。

2. side 字段全是 buy

如果策略是纯多头,每次调仓都会先卖出旧持仓(sell),再买入新持仓(buy)。全是 buy 说明要么是初始建仓,要么是 position 保留了旧股票但 rebalance_log 没有记录(可能是 trade_status_mode="hold")。

3. 手续费比预期高

A 股印花税是卖出时收取的(0.1%),所以 commission 里卖出交易的手续费应该明显高于买入。如果买入和卖出手续费差不多,可能是 ptc 设成了双向(应该只设卖出方向的)。

延伸阅读