阅读提示

这篇专门讲解 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 buysell
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 设成了双向(应该只设卖出方向的)。

延伸阅读