资讯动态

机器学习量化策略实战:backtrader多股回测与过拟合检验

发布时间:2026/9/11 5:24:12 来源:尧图企业网站定制
简介面向金融量化入门者及Python开发者的机器学习量化投资实战项目集数据获取、特征工程、LightGBM建模与历史回测于一体。项目内置10支股票样例通过命令行即可完成从安装依赖到回测评估的完整流程并输出累积收益、最大回撤、夏普率等核心指标适合希望将机器学习落地到股票策略分析的读者快速上手。资源包共15个文件包含5个Python源码程序、6个结果图表、依赖清单与使用说明文档压缩包仅735KB结构清晰便于二次开发。目前已吸引267人学习源码提供完整回测框架和数据集管理逻辑可作为量化策略研究的基础模板也可扩展至更多股票与自定义特征帮助降低入门门槛并提升策略验证效率。1. 从机器学习到量化投资为什么你的策略需要一套可回测的代码框架把机器学习放进量化投资第一直觉往往是让模型预测明天的涨跌方向。但真正决定策略能不能在真实市场里活下来的不是某个模型有多新而是从原始行情到回测报表这条链路是否干净。一个可复用的量化策略项目通常由特征工程、模型训练、信号生成、回测引擎和结果报告五部分组成。源码的价值不在模型有多惊艳而在特征是否统一、回测是否隔离未来信息。对量化和机器学习应用场景中的工程师来说先读懂一套源码怎样把训练预测回测串成闭环比追求单个指标重要得多。本文就沿着这个闭环落到backtrader多股回测和过拟合验证上。2. 从数据到交易信号量化策略里的特征工程与机器学习模型选择2.1 量化特征的最小集合收益率、波动率与流动性的构造逻辑特征工程这一步决定下限。很多人一上来就把能想到的技术指标全塞进去RSI、MACD、布林带全部堆进一张表模型训练完在训练集上表现很漂亮一换时间段就崩。量价类数据的信噪比极低机器学习算法碰到这种分布第一诉求不是增加特征维度而是降低噪声干扰。常见做法是先构造三个基础维度收益率刻画收益的持续性波动率刻画风险程度成交量变化刻画流动性与资金参与度。特征生成之后先看分布、看缺失、看极端值比直接跑模型更花时间。数据来源方面python爬虫和公开免费数据集都可以支撑这个环节关键不在接口而在时间戳对齐。日线数据必须按交易日对齐停牌日、节假日会导致DataFrame索引错位特征计算的结果就会偏掉。这里给出一份最小可用的特征函数后面的训练、预测、回测三个阶段共用这一份实现。import pandas as pd import numpy as np def build_features(df: pd.DataFrame, lookback: int 5) - pd.DataFrame: df df.copy() # 当前交易日收益率后续波动率的计算基础 df[ret_1] df[close].pct_change(1) # 多日累计对数收益率压缩极端值带来的偏度 df[ret_lb] np.log(df[close] / df[close].shift(lookback)) # 滚动窗口波动率反映短期风险水平 df[vol_lb] df[ret_1].rolling(lookback).std() # 成交量与20日均量的比值衡量资金活跃度 df[vol_ratio] df[volume] / df[volume].rolling(20).mean() return df.dropna()pct_change(1)算的是单日涨跌幅rolling(lookback).std()是过去5日收益率标准差vol_ratio把成交量转化为相对水平而不是绝对量这样不同股票的成交量差异不会主导模型。lookback参数是最值得做敏感性测试的对象后面专门讲怎么验证它对回测结果的影响。标签的构造与特征同等重要。二分类是量化项目里最稳妥的建模方式标签定义为未来N日收益率的符号make_label函数负责这件事。shift(-horizon)把未来收益平移到当前时间点但会造成样本尾部出现NaNdropna(subset[label])把这部分无效样本剔除。def make_label(df: pd.DataFrame, horizon: int 5) - pd.DataFrame: # 未来horizon日的累计收益符号作为分类标签 df df.copy() df[future_ret] df[close].shift(-horizon) / df[close] - 1 df[label] (df[future_ret] 0).astype(int) return df.dropna(subset[label])horizon和lookback一般保持同值取3、5、8、13这类斐波那契数便于后续敏感性分析时观察趋势。标签的正负样本比例很少是均衡的牛市里正样本偏多熊市里接近五五开这个不均衡交给回测去检验不要在训练前硬做重采样。特征与标签必须放在同一个文件里维护把特征函数和标签函数放在同一个独立模块训练脚本、预测脚本、回测脚本都从该模块导入。不要在一个脚本里复制粘贴后再改条件两处实现一旦不同回测结果的解释力就消失了。项目维护中最常见的返工原因不是模型预测不准而是训练和回测用了两套feature列。注意缺失值填充必须在划分训练集之前完成否则填充统计量会泄漏到验证集导致回测指标虚高。2.2 标签构造与分类器选择机器学习分类器在量化投资里的取舍模型选择不需要复杂。逻辑回归、随机森林、LightGBM是三个典型的备选分别代表线性、袋装树、提升树三类思路。量化特征分布噪声很大用回归方式去预测具体价格通常误差离谱更稳定的做法是把问题转换成分类。下表给出三者的差异模型对噪声容忍度训练速度过拟合风险适用数据量逻辑回归低极快可控中小随机森林高中等中受树深影响大中LightGBM高快较高依赖早停大随机森林在量价因子的非线性拟合上有天然优势树深度设到5或6层每个叶节点最少样本数设到50过拟合空间被压得很小。LightGBM数据量大时优势明显但必须配合早停机制否则训练集表现一路走高而验证集不涨。机器学习实战里的调参经验放在量化场景依然有效唯一的区别是时间序列不能随机打乱。from sklearn.model_selection import train_test_split from sklearn.ensemble import RandomForestClassifier feature_cols [ret_1, ret_lb, vol_lb, vol_ratio] X data[feature_cols] y data[label] # 按时间顺序切分不做shuffle X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, shuffleFalse ) model RandomForestClassifier( n_estimators200, max_depth5, min_samples_leaf50, random_state42, ) model.fit(X_train, y_train) prob model.predict_proba(X)[:, 1]shuffleFalse是金融时序与一般机器学习分类任务的根本差异样本顺序承载了行情演化的信息打散会掩盖过拟合的本质。min_samples_leaf50相当于正则把单个叶节点的决策限制得平滑。predict_proba给出的上涨概率稳定在0到1之间作为后续信号的输入比直接使用预测类别要有用得多。stratify参数在时序切分里不要开启类别不均衡是市场常态分层抽样会把不同时期的行情分布强行拉齐让模型学到不存在的平稳性。2.3 模型输出到交易信号的映射阈值排序与仓位控制的实现模型输出不能直接当信号。概率值需要经过阈值和排序两步加工才变成交易指令。阈值决定交易频率属于策略层面的选择要结合回测阶段的收益目标一起调。排序的意义在于当候选股票超过持仓数量上限时只保留概率最高的前N只。threshold 0.55 signal_series pd.Series(prob, indexdata.index) # 超过阈值的股票进入候选池再按概率排序 candidate signal_series[signal_series threshold].sort_values(ascendingFalse) # 每个交易日只保留概率最高的前5只股票 selected candidate.groupby(candidate.index.date).head(5)groupby(candidate.index.date)按日期分组每个交易日只保留那一组里概率最高的五只股票这样信号表直接和账户能承受的持仓数量挂钩。仓位权重方面等权配置是最容易调试的起点初始资金100万、单只股票10%目标仓位剩余现金作为缓冲。如果改用市值加权需要在信号表里额外传入每只股票的市值列回测代码的复杂度会上升一个台阶初期没必要。3. backtrader 多股回测的关键实现策略类编写与订单执行参数3.1 backtrader策略类的固定骨架初始化引用与next方法回测引擎选backtrader是因为它的多股数据接入和订单管理都比较成熟开源社区资料也多。策略类需要继承bt.Strategy__init__中保存数据或指标引用next在每一根bar上被调用这就是典型的事件驱动式回测。代码里唯一要理解的抽象是self.datas是一个列表加载了几只股票就有几个数据对象。import backtrader as bt class MLStrategy(bt.Strategy): params ( (signal_df, None), # 每日信号表由训练阶段生成 (target_pct, 0.1), # 单只股票目标资金占比 ) def __init__(self): self.signal self.params.signal_df def next(self): # 当前bar对应的交易日 current_date self.datas[0].datetime.date(0) for data in self.datas: symbol data._name # 信号表里没有当天数据就跳过 if current_date not in self.signal.index: continue sig self.signal.loc[current_date, symbol] pos_size self.getposition(data).size if sig 1 and pos_size 0: # 开仓目标仓位从0调整到target_pct self.order_target_percent(data, targetself.params.target_pct) elif sig 0 and pos_size 0: # 清仓目标仓位归零 self.order_target_percent(data, target0.0)current_date通过self.datas[0]获取回测框架要求所有数据按时间对齐取第一个数据源的日期作为当前交易日是通用做法。self.signal.loc[current_date, symbol]要求信号表索引是日期、列名是股票代码并且列名与data._name完全一致。next方法内的订单状态判断这段逻辑里最容易踩坑的是重复下单。next在每个交易日对每只股票执行一次如果信号为1且持仓已经存在再下一个目标仓位相同的订单虽然不会改变仓位但会白白产生挂单和撤单流程回测速度下降费用计算也可能出错。所以开仓前用pos_size 0判断当前无持仓清仓前用pos_size 0判断有持仓才执行。order_target_percent会自动把仓位调整到指定比例按账户权益的10%持仓剩余资金留在现金账户这比buy()固定股数的方式更贴近实际资金管理逻辑。唯一要留意的是目标仓位和手续费之间的相互作用手续费会消耗一点现金导致最终持仓比例与估值稍有偏差这在回测精度要求不高的场景中可以接受。提示不要在连续多根bar里反复调用order_target_percent做微调目标仓位的微小变化会放大换手费用纯回测净值看似平滑实盘成本早就把利润吃掉了。3.2 多股数据接入与时间对齐PandasData逐股加载方案多股回测时不同股票的上市时间、停牌规则、交易日期集并不一致。backtrader会把所有数据按时间对齐缺失日期的股票在那一根bar上不触发next中的循环但仍需注意订单买卖时价格获取的匹配。常见做法是先把每只股票整理成独立DataFrame再逐股包装成PandasData对象加入Cerebro。cerebro bt.Cerebro() for symbol, df in stock_data.items(): # datetime列必须是DatetimeIndex或可解析的时间列 data bt.feeds.PandasData(datanamedf, datetimedate) data._name symbol cerebro.adddata(data) cerebro.addstrategy(MLStrategy, signal_dfsignal_table) cerebro.broker.setcash(1000000.0)_name属性是识别每只股票的钥匙策略方法都用它来做信号查询和日志输出。如果DataFrame的列名与backtrader默认字段不一致比如open列叫Open必须在PandasData构造时显式映射否则数据解析会静默失败策略结果异常难排查。信号表本身也需要按股票代码整理成与_name相同的命名否则self.signal.loc[current_date, symbol]会抛出KeyError。多股策略的日志输出建议把data._name和data.datetime.date(0)一起打印否则回测结束根本不知道哪只股票在什么时候触发过交易也很难核对信号表和实际执行的偏差。3.3 回测结果可信度参数表手续费、滑点与最小交易单位不设手续费和滑点的回测没有任何参考价值。手续费、滑点和最小交易单位三者共同决定了回测到实盘之间的成本鸿沟。下表给出一套日常项目可以复用的初始化参数参数推荐初始值调整策略手续费率万2.5到万3按实际账户佣金乘2计双边成本滑点0.1%到0.2%流动性差或资金量大时上调最小交易单位100股A股按手取整港股美股各按规则调cerebro.broker.setcash(1000000.0) # 单边手续费万3回测报告里要意识到这是单边 cerebro.broker.setcommission(commission0.0003, stocklikeTrue) # 设置按成交额百分比的滑点 cerebro.broker.set_slippage_perc(perc0.001)setcommission的参数是按单边计算的实际操作中买入卖出各收一次综合成本要按双边看。set_slippage_perc会将市价订单的成交价格在开仓方向偏移0.1%模拟市场冲击成本。如果发现回测对滑点变化非常敏感比如从0.1%调到0.2%收益就由正转负说明策略本身的毛利空间太薄真实环境很难存活。4. 源码工程结构与使用说明把训练预测回测串成闭环4.1 目录布局的分层设计与配置文件的唯一入口一套能长期维护的量化源码目录结构决定项目的可维护性。常见做法是将配置、数据、特征、模型、回测脚本分层放置模块间的依赖方向保持单向。实际工程里机器学习项目耗时最长的往往不是模型训练而是数据组织和结果复盘目录分层能做到什么程度几乎决定了复盘效率。下面这个结构适合从几万元到几千万元资金的团队quant_project/ ├── config.yaml # 唯一的参数入口 ├── data/ # 原始行情数据日线级别 ├── features.py # 特征和标签构造唯一实现 ├── train_model.py # 训练模型并保存产出物 ├── predict_signals.py # 加载模型生成交易信号 ├── backtest.py # 回测入口输出净值与指标 └── requirements.txt # 依赖清单config.yaml是唯一的配置入口训练和回测都从它读取参数调整股票池或回测时间时不用翻代码features.py是唯一特征实现训练和回测都导入它避免两套特征逻辑的偏差train_model.py和predict_signals.py一个负责训练产出模型文件一个负责预测产出信号表职责互不重叠。这几个文件合在一起就是完整项目的源码核心。4.2 避免未来函数与数据泄漏训练回测只用同一份特征代码特征实现只有一个副本还不够还要确保特征计算的时点语义正确。常见隐患是当天的收盘价在收盘后才被知晓如果用当天收盘价计算特征再去预测同一个交易日是否上涨这就是典型的未来函数。严格处理方式是所有涉及价格的输出都至少向后平移一天。# 规避方式所有价格特征向后平移一天 df[ret_1] df[close].pct_change(1).shift(1) df[vol_lb] df[ret_1].rolling(5).std()这种写法的结果是当T日收盘后发出信号时模型使用的全部是T日及之前已经确定的数据信号真正生效在T1日开盘或收盘。训练和回测都使用同一份移位后的特征矩阵未来函数的风险就降得很低。数据泄漏的范围更广除了时间顺序问题还包括训练样本覆盖了验证时间段所以切分必须严格按时间顺序进行。4.3 从python环境安装到跑通回测的使用说明与常见问题配置文件的最小写法完整使用说明要覆盖环境准备、训练、信号、回测四个环节。环境准备部分建议使用虚拟环境机器学习库之间依赖关系复杂直接在全局环境安装容易冲突。# config.yaml 最小配置 data_dir: ./data start_date: 2018-01-01 end_date: 2022-12-31 stock_pool: - 000001.SZ - 600519.SH lookback: 5 horizon: 5 threshold: 0.55 initial_cash: 1000000lookback和horizon在这里被显式配置后续做参数敏感性测试时只需要改这两个值。start_date表示回测区间起点训练集的起点通常要更早在train_model.py内单独设置两者不要混在一起。python -m venv .venv source .venv/bin/activate pip install -r requirements.txt python train_model.py --config config.yaml python predict_signals.py --config config.yaml python backtest.py --config config.yaml跑完train_model.py会在models目录生成模型文件跑完predict_signals.py生成signals.csvbacktest.py读取这张表输出回测报告。遇到结果异常时优先检查config.yaml里的stock_pool是否与data目录下文件名一致文件前缀不匹配是最常见的启动报错来源。提示Windows下激活命令改为.venv\Scripts\activate用VSCode打开项目根目录后选择虚拟环境解释器pyright能立即识别已安装的包环境配置问题会少很多。5. 回测过拟合的检验技巧与策略鲁棒性的进阶验证5.1 参数敏感性分析判断过拟合边界过拟合不会直接报错它会在参数微调时暴露。验证时把核心参数按序列扫一遍比如lookback设为3、5、8、13天持仓数设为3、5、10只观察夏普比率和最大回撤的变化趋势。每次扫描只动一个参数其余条件固定结果才有可比性。for lookback in [3, 5, 8, 13]: feat build_features(price_data, lookbacklookback) lab make_label(feat, horizonlookback) sharpe run_backtest(feat, lab) print(flookback {lookback}, sharpe {sharpe:.2f})如果只有某个特定参数值效果好两侧参数值收益断崖式下跌这不是发现金矿而是模型在记忆噪声。健康的参数区间应该表现为收益在一个较宽的范围内平稳变化比如5到8天都能产出正收益只是数值略有波动。敏感性分析结果建议直接以表格形式写进项目的README这比任何口头解释都有说服力。5.2 滚动窗口回测验证样本外稳定性单次回测只能说明一个时间段。滚动窗口回测把数据切分成多个训练与验证段前段训练模型后段验证效果然后整体前移重复运行最后汇总各段样本外结果的复合收益。这样做能把市场风格切换、季节性波动都暴露在验证里。for end_year in range(2019, 2023): train_end f{end_year}-01-01 train full_data[full_data.index train_end] val full_data[(full_data.index train_end) (full_data.index f{end_year 1}-01-01)] oos_return run_train_val(train, val) results.append(oos_return)样本外收益如果每年都稳定为正说明策略确实有持续性如果只在某一两年赚钱大概率是风格巧合而不是模型能力。做完滚动窗口后把稳健的参数区间固定下来再算一次完整样本的日内最高亏损和单笔最大回撤这套结果就可以作为项目交付版本的依据。最终交付的回测脚本把这套滚动验证的入口参数默认设成扫出来的稳定值任何一台机器clone下来跑出来的报告都应该是同一份数字。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价