第3讲|Backtrader 量化交易策略开发

用 Python 和 Backtrader 复现 FF5 多因子策略

今日目标(Learning Goals)

  • 认识 Backtrader 架构与关键组件,跑通“最小可用回测”
  • 掌握与 Backtrader 直接相关的 Python/OOP 最小知识(能读能改模板)
  • 用 Backtrade 回测FF5策略

回顾第2讲:FF5 迷你案例(Excel/WPS)

  • 描述子与控制变量(横截面当月、预测次月收益)
    • 价值 BM = BookEquity / MktCap
    • 盈利 ROE = NetIncome / BookEquity
    • 投资 AG = TotalAssets_t / TotalAssets_{t-1} - 1,取反 AG_pos = -AG
    • 规模控制(仅中性化):LogMktCap = ln(MktCap)
  • 因子工程
    • 行业内去极值(1%-99%)、行业内秩→正态分位标准化
    • 规模中性化:对 LogMktCap 回归取残差
    • 等权合成:Score = mean(z_BM_perp, z_ROE_perp, z_AGpos_perp)
  • 组合构建:Top-N 等权或五分位 Q5 等权;月度调仓,含成本近似

1) 跑通 Notebook 的最小知识

  • Jupyter 基本操作

    • 单元类型:Code / Markdown
    • 运行:Shift+Enter;重启内核:Kernel → Restart
    • 切换内核:选择 “Python (FactorEngineering)”
    • 错误:看红色 Traceback 顶部最后一段;不会就重启内核并从上到下运行
  • Python 最小语法(能看懂本课代码即可)

    • 变量与类型:a=3(int),x=3.5(float),s="abc"(str),b=True(bool),None
    • 容器:list(有序可变)[1,2,3]dict(键值){'k':1}
    • 控制流:if 条件: ...;循环:for col in cols: ...
    • 函数与 lambda:def f(x): return x+1lambda s: s.mean()
    • 常用小技巧:type(x) 看类型;print(x)/display(df.head()) 看数据
  • 模块与导入

    • import pandas as pd(表格数据)
    • import numpy as np(数值计算)
    • import matplotlib.pyplot as plt(画图)
  • 路径与文件(跨平台写法)

    • 相对路径:"./data/xxx.csv"
    • 跨平台:os.path.join(DATA_DIR, "file.csv")
  • 三个“能跑通”的固定语句

    • 读表:df = pd.read_csv(path, dtype=str)
    • 数值化:pd.to_numeric(df['Col'], errors='coerce')
    • 分组对齐:df.groupby(['Trdmnt','Industry'])['BM'].transform(func)

2) 用 pandas 读表与看表

  • 读取 CSV
    • df = pd.read_csv(path, dtype=str):先读为字符串,后续再数值化更安全
  • 选列与筛选
    • 选列:df['Col'];多列:df[['A','B']]
    • 筛选:df[df['A'] > 0];去缺失:df.dropna(subset=['A'])
  • 类型转换
    • pd.to_numeric(x, errors='coerce')pd.to_datetime(x, errors='coerce')
  • 排序与去重
    • df.sort_values(['Stkcd','Trdmnt'])drop_duplicates(keys, keep='last')
  • 小练习(口述):读取月度表,选出 A 股市场类型并按股票-月份排序

3) 按股票与月份分组(groupby/transform)

  • 为什么要分组?

    • 横截面在每月、行业内处理(去极值、标准化、中性化)
    • 时间序列在每个股票内(如 shift(-1) 得到次期收益)
  • 常用语句

    • 分组句柄:grp = df.groupby(['Trdmnt','Industry'])
    • 横截面变换:grp['BM'].transform(func)(输出与原行对齐)
    • 时间序列:df.groupby('Stkcd')['Mretwd'].shift(-1)
  • 关键区别

    • transform vs apply:优先用 transform,避免索引不兼容

4) 数学到代码的映射

  • 因子定义(本讲的 3 个核心)

    • df['BookEquity'] / df['Msmvttl']
    • df['NetIncome'] / df['BookEquity']
    • groupby('Stkcd')['TotalAssets'].pct_change()
    • 方向处理:AG_pos = -AG(越大越好)
  • 横截面标准化步骤

    • Winsor(1%/99%):clip_winsor
    • 秩正态:rank → 分位 → 正态逆(记住“秩标准化到 N(0,1)”即可)
  • 规模中性化

    • 概念:剔除与规模(log(MktCap))的一致性
    • 公式:
    • 实现思路:先按组算 ,再映射回逐行残差
  • 次期收益与评估

    • Ret_t1 = shift(-1);Rank-IC = 每月 Spearman 秩相关
    • 分组收益:按 Score 等分 N 组,算 Q1...Qn 与多空 Qn-Q1

5) 可视化与输出

  • 画累计收益曲线

    • cum = (1 + ret.fillna(0)).cumprod() - 1
    • plt.plot(cum.index, cum.values)
  • 导出结果

    • to_csv('./output/xxx.csv')
    • 关键文件:rank_ic.csvquintile_returns.csvscore_monthly.csvweights_monthly.csv

6) 学生最常见坑与快速排错

  • 列名对不上/缺失
    • df.columns 检查;用 display(df.head()) 确认字段
  • 类型问题
    • 遇到报错先 pd.to_numeric(..., errors='coerce')±inf 替换为 NaN
  • 索引不兼容
    • 优先用 groupby.transform;如果用 apply,赋值前 reindex(df.index)
  • 前视错误
    • 财务年度映射:Year_Fin = year(Trdmnt) - 1
    • 次期收益:groupby('Stkcd')['Mretwd'].shift(-1)

OOP 入门与 Backtrader 映射

  • 核心概念
    • 类(class)、对象/实例(object/instance)
    • 属性(数据)/方法(行为)
    • 继承(子类扩展父类功能)、self(实例自身)
  • 映射
    • bt.Strategy 是一个类;你的策略需要“继承”并重写方法:
      • __init__() 初始化依赖与状态
      • next()/nextopen() 每个 bar 触发的逻辑(后者在开盘前触发)
      • notify_order()/notify_trade() 订单与成交回报

Backtrader 架构与关键特性

  • 架构(事件驱动)
    • Cerebro 引擎 → DataFeed(行情) → Strategy(信号/下单)
      Broker(撮合/现金/仓位) → Analyzer/Observer(统计/可视化)
  • 多标的、重采样(resampledata)、参数优化(optstrategy)、多进程
  • 关键特性
    • order_target_percent:按目标权重调仓
    • cheat_on_open=True + nextopen():开盘前下单,用当日开盘成交
    • 常用 Analyzers:SharpeRatio_ADrawDownTimeReturn
  • 优劣简述
    • 优:清晰、易上手、多资产良好、教学友好
    • 劣:大样本性能/内存、社区维护度一般;高级优化/风险模型需自研
    • 替代:backtesting.py(轻)、bt(组合层)、zipline-reloaded(重)
  • 参考:

最小样例(单标的 Buy&Hold)

import backtrader as bt, pandas as pd

class BuyAndHold(bt.Strategy):
    def next(self):
        if not self.position:
            self.order_target_percent(target=1.0)

df = pd.read_csv('single.csv', parse_dates=['date']).set_index('date')
data = bt.feeds.PandasData(dataname=df[['open','high','low','close','volume']])
cerebro = bt.Cerebro()
cerebro.adddata(data, name='TICKER')
cerebro.addstrategy(BuyAndHold)
cerebro.broker.setcommission(commission=0.001)  # 10 bps
res = cerebro.run()[0]
cerebro.plot()

复现 FF5 回测: 1. 前置约定与文件规范

  • 执行口径
    • t 月末的目标权重,按 t+1 月首个交易日的开盘价执行
    • Backtrader 使用 cheat_on_open=True 并在 next_open() 中下单
  • 数据与文件
    • 日频行情面板:daily_bt(DataFrame)
      • 列:Stkcd, datetime, open, high, low, close, volume
      • datetime 为交易日,升序,时区无关
    • 月度权重宽表:./output/weights_monthly.csv
      • 列:股票代码为列、RebalDate 为索引(或列转索引),值为权重
      • RebalDate 为月末交易日日期(e.g., 2020-01-31)

复现 FF5 回测: 2. 加载日频行情与月度权重

  • 从面板构造 Backtrader feeds;从权重宽表读取“月末键 → 权重”字典
import backtrader as bt
import pandas as pd
import numpy as np

def load_daily_from_panel(daily_bt: pd.DataFrame):
    # daily_bt columns: ['Stkcd','datetime','open','high','low','close','volume']
    feeds = []
    for sid, g in daily_bt.groupby('Stkcd'):
        df = g[['datetime','open','high','low','close','volume']].copy()
        df['openinterest'] = 0
        df = df.set_index('datetime').sort_index()
        feed = bt.feeds.PandasData(dataname=df, name=sid, timeframe=bt.TimeFrame.Days)
        feeds.append(feed)
    return feeds

def load_weights_csv(path='./output/weights_monthly.csv'):
    # 宽表:index=RebalDate(月末),列为股票代码,值为目标权重
    w = {}
    df = pd.read_csv(path, parse_dates=['RebalDate']).set_index('RebalDate').sort_index()
    for dt, row in df.iterrows():
        weights = {c: float(v) for c, v in row.dropna().to_dict().items() if abs(v) > 1e-12}
        w[dt.date()] = weights  # 以月末日期为键
    return w

复现 FF5 回测: 3. 将“月末键”映射为“执行日”(t+1 首个交易日)

  • 在 pandas 侧用交易日历将月末键映射到下一交易日,得到 exec_map
def map_eom_to_next_trading(weights_by_eom: dict, all_days: pd.DatetimeIndex):
    # all_days: 全部交易日 DatetimeIndex(升序)
    exec_map = {}
    for eom, weights in weights_by_eom.items():
        idx = all_days.searchsorted(pd.to_datetime(eom), side='right')
        if idx < len(all_days):
            exec_dt = all_days[idx].date()
            exec_map[exec_dt] = weights
    return exec_map

# 构造交易日历(从 daily_bt 取 union)
# all_days = pd.DatetimeIndex(sorted(daily_bt['datetime'].unique()))
# exec_map = map_eom_to_next_trading(load_weights_csv(), all_days)

复现 FF5 回测: 4. 策略类:月度权重 + 次日开盘成交

  • 使用 cheat_on_open=True,在 next_open() 内设置目标权重
  • 支持两种权重输入:
    • 已映射的 exec_map(推荐):执行日 → 权重
    • 原始 weights_map:月末 → 权重(用“上个交易日”找键)
class MonthlyWeights(bt.Strategy):
    params = dict(weights_map=None, exec_map=None)

    def __init__(self):
        self.datas_by_ticker = {d._name: d for d in self.datas}
        self.exec_map = self.p.exec_map or {}
        self.exec_dates = set(self.exec_map.keys())
    def next_open(self):
        curdate = self.data.datetime.date(0)

        if curdate in self.exec_dates:
            target_w = self.exec_map[curdate]
        else:
            # 兼容:若只提供了月末权重,使用“上一个交易日”作为键
            prev_eod = self.data.datetime.date(-1)
            wm = self.p.weights_map or {}
            target_w = wm.get(prev_eod, {})

        # 先清零,避免遗留持仓
        for d in self.datas:
            self.order_target_percent(d, target=0.0)

        # 再按目标权重分配
        for tk, w in (target_w or {}).items():
            d = self.datas_by_ticker.get(tk)
            if d is not None:
                self.order_target_percent(d, target=float(w))

复现 FF5 回测: 5. 成本与滑点设置(课堂基准)

  • 代码端设置与表格端口径对齐,避免双重扣费
    • 佣金(单边):2.5 bps
    • 卖出印花税:10 bps(仅卖出)
    • 滑点:2 bps(若版本不支持可跳过)
class StampDutyCommission(bt.CommInfoBase):
    params = (('stamp_duty', 0.001), ('commission', 0.00025), ('percabs', True),)
    def _getcommission(self, size, price, pseudoexec):
        comm = abs(size) * price * self.p.commission
        if size < 0:
            comm += abs(size) * price * self.p.stamp_duty
        return comm

复现 FF5 回测: 6. 组装与运行

  • 初始化 Cerebro、添加数据与策略,开启 cheat-on-open
cerebro = bt.Cerebro(stdstats=False)
cerebro.broker.setcash(1_000_000)
cerebro.broker.set_coc(True)  # cheat-on-open
cerebro.broker.addcommissioninfo(
    StampDutyCommission())
try:
    cerebro.broker.set_slippage_perc(
        perc=0.0002)  # 2 bps
except Exception:
    pass

# 加载数据
for feed in load_daily_from_panel(daily_bt):
    cerebro.adddata(feed)
# 读取权重并映射执行日(推荐)
weights_eom = load_weights_csv(
    './output/weights_monthly.csv')
all_days = pd.DatetimeIndex(
    sorted(daily_bt['datetime'].unique()))
exec_map = map_eom_to_next_trading(
    weights_eom, all_days)

# 添加策略
cerebro.addstrategy(
    MonthlyWeights, exec_map=exec_map)  
# 或 weights_map=weights_eom

strat = cerebro.run(maxcpus=1)[0]

复现 FF5 回测: 7. 换手率统计与常用评价指标

  • 换手率近似口径:
class TurnoverAnalyzer(bt.Analyzer):
    def start(self):
        self.turnovers, self.last_w = [], None
    def _weights_now(self):
        v = self.strategy.broker.getvalue()
        w = {}
        for d in self.strategy.datas:
            pos = self.strategy.getposition(d).size
            w[d._name] = 0.0 if v == 0 else (pos * d.close[0]) / v
        return w
    def next(self):
        w_now = self._weights_now()
        if self.last_w is not None:
            keys = set(self.last_w) | set(w_now)
            t = sum(abs(w_now.get(k,0) - self.last_w.get(k,0)) for k in keys)
            dt = bt.num2date(self.strategy.datas[0].datetime[0]).date()
            self.turnovers.append((dt, t))
        self.last_w = w_now
    def get_analysis(self):
        return {'turnover': self.turnovers}
  • 添加分析器并读取结果
cerebro.addanalyzer(bt.analyzers.SharpeRatio_A, _name='sharpe')
cerebro.addanalyzer(bt.analyzers.DrawDown,      _name='dd')
cerebro.addanalyzer(bt.analyzers.TimeReturn,    _name='tr')
cerebro.addanalyzer(TurnoverAnalyzer,           _name='to')

strat = cerebro.run(maxcpus=1)[0]

sr = strat.analyzers.sharpe.get_analysis().get('sharperatio')
dd = strat.analyzers.dd.get_analysis()['max']['drawdown']
ret_daily = pd.Series(strat.analyzers.tr.get_analysis())  # index: datetime
to_series = pd.Series(dict(strat.analyzers.to.get_analysis()['turnover']))
print(f'Sharpe: {sr:.3f} | MaxDD: {dd:.2f}%')

复现 FF5 回测: 8. 与“表格版”一致性验证(月度层面)

  • 将回测日度收益聚合为月度,与表格侧月度收益对齐并比较
ret_m_code = ret_daily.resample('M').apply(lambda x: (1 + x).prod() - 1)
ret_m_excel = (pd.read_csv('./output/monthly_returns_table.csv', parse_dates=['date'])
                 .set_index('date')['ret_m'])

ret_m_excel = ret_m_excel.reindex(ret_m_code.index).dropna()
ret_m_code  = ret_m_code.reindex(ret_m_excel.index)

corr = ret_m_code.corr(ret_m_excel)
print('月度收益相关性 =', corr)
  • 课堂阈值
    • 相关性 ≥ 0.95 理想;0.90–0.95 可接受;< 0.90 需排查
    • 同期累计净值偏差 1–3% 可接受;超阈值优先核查:执行日映射、开盘成交口径、费用/滑点是否双扣、股票池一致性

复现 FF5 回测: 9. 常见问题与排查清单

  • 权重日期不对齐
    • 确认 RebalDate 是月末交易日;映射到下一交易日后再执行
  • 成交价口径不一致
    • 必须在 next_open() 下单,确保开盘价执行;启用 set_coc(True)
  • 成本重复扣减
    • 表格侧若已扣 bps,代码端不要重复;或统一到代码端
  • 股票池不一致
    • 行情数据与权重内的股票集合需一致;缺失则权重会被忽略
  • NaN/极小权重
    • 读入时丢弃绝对值小于阈值的权重(如 1e-12),避免无意义下单

参考资料与延伸阅读

Q&A

  • 没 Python 基础能否完成?可以,模板代码只需按提示替换文件路径与简单参数
  • 我只有分数没有权重?用路径B在 pandas 侧生成 Top-N/Q5 权重
  • 表格版是“收盘成交”怎么办?课堂统一改为“次日开盘成交”;或在表格版重算“开盘成交”以对齐
  • 结果差异较大?优先检查:成交价口径、费用、权重日期、宇宙与缺失数据处理

用 Backtrader 复现 FF5 多因子策略(2 学时) - 授课对象:零金融/编程基础的公共选修课学生 - 承接第2讲:Excel/WPS 完成的 FF5 迷你案例(BM、ROE、AG_pos) - 本讲目标:把“表格版”同一策略,迁移为 Backtrader 代码回测,并进行一致性验证

--- ### 课前准备与环境 - 安装 - `pip install backtrader pandas numpy matplotlib` - 数据组织(两条路径,课堂优先 A) - 路径A:`weights.csv`(date, ticker, weight)含月末权重,表示 t 月末信号 → t+1 月持仓 - 路径B:`scores.csv`(date, ticker, score 或 bucket),课堂用 pandas 生成权重 - 日线行情:`data/*_daily.csv` 每标的一份,列:`date,open,high,low,close,volume` - 注意对齐 - 宇宙/区间与第2讲一致;代码/文件名一致 - “自然月末”需映射到“交易月末”;成交口径统一为“月末信号,次日开盘成交”

参考:manual.pdf 第 4–11 节流程要点

--- ### Python 0→1(只讲 Backtrader 相关) - 基本对象:`int/float/str/bool`;容器:`list/dict/tuple` - 控制流:`if/for`;函数:`def`;模块导入:`import` - 文件操作:`pandas.read_csv`、`DataFrame.to_csv` ```python import pandas as pd df = pd.read_csv('weights.csv', parse_dates=['date']) print(df.head()) def topn(df, n=10): return df.sort_values('score', ascending=False).head(n) ``` - 与回测关系:读写 `weights.csv/scores.csv`,构建权重字典,进行参数配置与结果导出