
简介量化交易与深度学习的系统性参考文档围绕PyTorch与Backtrader构建LSTM股票预测回测框架展开适合具备一定Python基础、希望将深度学习引入量化策略的开发者与研究者。文档共47页为PDF格式整体2.06MB完整覆盖量化交易概念、PyTorch动态计算图优势、Backtrader核心组件、LSTM原理、多源数据获取与预处理、模型训练调优、回测集成、参数优化及策略评估等环节章节层级清晰支持目录跳转与大纲定位。已有142人学习。读者可按章节从环境安装、数据清洗归一化、时间序列特征构造到LSTM模型定义与训练、Backtrader策略集成逐步操作同时了解收益率、夏普比率、最大回撤等回测指标及网格搜索、随机搜索等优化方法并学习过拟合识别与多周期回测思路。文档适合用作量化回测框架设计的入门与进阶参考帮助读者打通数据、模型、回测与评估的完整链路。1. 量化交易回测框架设计这个标题在解决什么问题做量化交易的人很多是从“双均线金叉”这类规则策略入门的但做到后面都会发现规则越简单越容易在某一段行情里集体失效。这个项目标题给了一条更陡峭但更通用的路用 PyTorch 搭 LSTM 模型预测股票走势再用 Backtrader 跑回测验证预测信号到底能不能产生稳定收益。它要解决的核心不是“AI 会不会炒股”而是把深度学习预测和传统回测引擎打通让模型输出变成可重复验证、可复盘、可优化的策略闭环。适合两类人一类是已经会写 Python、但回测框架只是听说过的量化新手另一类是已经用规则策略跑了一段时间、想转机器学习增强信号的交易者。下面按我自己落地这类方案的习惯把数据、模型、回测、避坑按顺序讲清楚。2. 先把数据备好LSTM 股票预测的输入格式与切分LSTM 不是拿来什么数据都能直接训练的。它的输入要求是“三维张量”样本数、时间步数、特征数。很多人上来就报错就是因为把二维 DataFrame 直接扔进 nn.LSTM。所以整个框架的第一步不是模型而是把股票行情整理成模型能吃的形状。2.1 数据源选择用本地 CSV 还是直接拉接口常见做法是先用本地 CSV 做原型别一开始就依赖网络接口。股票日线数据从 tushare、baostock 这类数据源下载后保存成 CSV后续每次都从本地读。原因是回测框架要反复跑网络接口不稳定时会影响复现。我一般会在项目目录里放一个data/文件夹按股票代码命名例如600519.csv列名统一为date, open, high, low, close, volume。读取时只保留需要的列并按日期排序这一步特别关键。很多免费接口返回的数据不是严格按时间正序排列的必须显式做排序并重置索引否则后面切窗口时会把未来数据混进去。import pandas as pd df pd.read_csv(data/600519.csv) df[date] pd.to_datetime(df[date]) df df.sort_values(date).reset_index(dropTrue) df df[[date, open, high, low, close, volume]] # 只保留单只股票的复权收盘价LSTM 用收盘价序列做预测是最常见的做法 close df[close].values.astype(float32) print(close[:5])这里只用了收盘价作为预测目标。为什么不用 OHLC 全部丢进去对于日线级别的涨跌预测成交量和高低点的噪声很大第一版框架里只盯收盘价能让模型快速收敛。等基线跑通后再逐步加入特征这是做量化项目的通用节奏。2.2 归一化与滑动窗口LSTM 吃的是哪种“股价”LSTM 对输入数值范围敏感股价从几十到几百甚至上千直接喂进去会让梯度震荡。最稳妥的归一化方式是 MinMaxScaler把价格压缩到 [0, 1] 区间。这里有一个新手容易踩的坑归一化的平均值和最小值只能由训练集统计出来不能用全量数据去 fit。否则就相当于让模型在训练时偷看了未来的价格分布回测结果虚高实盘一定翻车。import numpy as np from sklearn.preprocessing import MinMaxScaler scaler MinMaxScaler() # 这里先划分训练集再 fit scaler只统计训练集的 min/max train_size int(len(close) * 0.7) train_close close[:train_size].reshape(-1, 1) test_close close[train_size:].reshape(-1, 1) scaler.fit(train_close) train_scaled scaler.transform(train_close).flatten() test_scaled scaler.transform(test_close).flatten()滑动窗口决定 LSTM 每次看多少根历史 K 线。窗口越长模型能捕捉的依赖越久但计算量和过拟合风险也越大。日线级别我一般取 20 到 60 之间默认 30。窗口太长会把老旧的行情状态也带进来反而干扰短周期涨跌判断。def build_sequences(data, window_size30): X, y [], [] for i in range(window_size, len(data)): # X: 过去 window_size 个交易日的价格y: 下一个交易日的价格 X.append(data[i - window_size:i]) y.append(data[i]) return np.array(X, dtypefloat32), np.array(y, dtypefloat32) window 30 X_train, y_train build_sequences(train_scaled, window) X_test, y_test build_sequences(test_scaled, window) print(X_train.shape, y_train.shape) # 输出示例(样本数, 30, 1) (样本数,)这里有三个参数需要记住window_size30是时间步数特征数为 1 是“只用收盘价”train_size0.7是切分比例。如果后续要加入成交量或技术指标只需要把特征维度改成多列同时保证 LSTM 的input_size与之对应即可。2.3 训练集/验证集/测试集切分避免未来函数很多人在做普通机器学习任务时习惯了随机切分比如train_test_split(shuffleTrue)。但时间序列数据绝对不能随机打乱。LSTM 学习的是时间依赖规律一旦乱序未来数据会出现在训练集正确率虚高。这里必须按时间顺序切分先用最早 70% 的数据训练再用接下来 15% 做验证最后 15% 做样本外测试。在切分时还有一个细节build_sequences会吃掉前window个样本所以原始数据要多留出窗口长度。如果总数据量只有 300 根日线切出 70% 后去掉窗口训练样本可能就只剩 180 条这时候模型很难收敛。我的习惯是至少积累 500 根日线再开始训练也就是约两年的数据。val_size int(len(train_close) * 0.8) train_part train_scaled[:val_size] val_part train_scaled[val_size - window:] # 保留窗口重叠部分 X_tt, y_tt build_sequences(train_part, window) X_val, y_val build_sequences(val_part, window)这里的val_part用了一点技巧切验证集时要从val_size - window开始保证验证集里的每个样本都拥有完整的历史窗口。如果不这样做验证集前 window 行会因为缺少前置数据而被丢弃损失少量样本。这个细节很少有人讲但在数据量有限时很值得注意。3. 用 PyTorch 搭一个能跑的 LSTM 预测模型PyTorch 的 LSTM 模块封装得很完整但新手经常在batch_first、hidden_size、num_layers这些参数上纠结半天。这一章直接给出一个能运行的基线模型并解释每个参数为什么这么设。3.1 LSTM 网络结构输入、隐藏层、输出怎么定预测股票价格是一个“回归任务”输入是一段连续的时间序列输出是一个具体的价格数值。我用两层 LSTM 加一个全连接输出层激活函数在输出层不设因为回归任务不需要把结果限制在 [0,1] 区间。import torch import torch.nn as nn class LSTMPredictor(nn.Module): def __init__(self, input_size1, hidden_size64, num_layers2, output_size1, dropout0.2): super(LSTMPredictor, self).__init__() self.lstm nn.LSTM(input_size, hidden_size, num_layers, batch_firstTrue, dropoutdropout) self.fc nn.Linear(hidden_size, output_size) def forward(self, x): # x 形状: (batch, seq_len, input_size) out, _ self.lstm(x) # 取最后一个时间步的输出作为全连接层的输入 out out[:, -1, :] out self.fc(out) return outbatch_firstTrue是最容易忽略的参数。PyTorch 默认输入形状是(seq_len, batch, input_size)但我们在build_sequences里生成的形状是(样本数, 窗口, 特征数)如果不设置batch_first形状就对不上。hidden_size64是一个体现“够用就好”的参数隐藏单元太多会加大过拟合太少拟合不了序列规律。num_layers2也是经验值日线数据一层 LSTM 往往欠拟合三层以上训练速度明显变慢且收益有限两层是最平衡的起步配置。3.2 损失函数与优化器回归任务别选错损失函数选 MSELoss这是预测连续价格的标准选择。有些教程对分类问题喜欢用 CrossEntropy但我们要的不是“涨或跌”而是具体的价格。使用 MSE 会让模型更关注价格接近真实值而不是只判方向。优化器我用 Adam学习率 0.001 是 PyTorch 默认值但配合股票数据我通常再设一个weight_decay1e-5做 L2 正则。model LSTMPredictor(input_size1, hidden_size64, num_layers2, output_size1) criterion nn.MSELoss() optimizer torch.optim.Adam(model.parameters(), lr0.001, weight_decay1e-5)这里weight_decay的作用是惩罚过大的权重参数防止模型在某些极端行情上记住噪声。股票数据的信噪比极低没有正则的 LSTM 在训练集上表现很好但验证集上 Loss 会逐步上升。3.3 训练循环与验证Epoch、早停、检查点训练循环不需要写太花哨关键是每一步都要把数据转成 PyTorch 的 Tensor并且关闭梯度记录在验证阶段。训练过程中要监控验证集 Loss不要只盯着训练集看。from torch.utils.data import TensorDataset, DataLoader X_tt_tensor torch.tensor(X_tt, dtypetorch.float32) y_tt_tensor torch.tensor(y_tt, dtypetorch.float32).view(-1, 1) X_val_tensor torch.tensor(X_val, dtypetorch.float32) y_val_tensor torch.tensor(y_val, dtypetorch.float32).view(-1, 1) train_data TensorDataset(X_tt_tensor, y_tt_tensor) train_loader DataLoader(train_data, batch_size32, shuffleTrue) epochs 50 best_val_loss float(inf) for epoch in range(epochs): model.train() total_loss 0.0 for b_x, b_y in train_loader: optimizer.zero_grad() pred model(b_x) loss criterion(pred, b_y) loss.backward() optimizer.step() total_loss loss.item() model.eval() with torch.no_grad(): val_pred model(X_val_tensor) val_loss criterion(val_pred, y_val_tensor).item() if val_loss best_val_loss: best_val_loss val_loss torch.save(model.state_dict(), best_lstm.pth) if (epoch 1) % 10 0: print(fEpoch {epoch1}, train_loss{total_loss/len(train_loader):.5f}, val_loss{val_loss:.5f})batch_size32是一般默认值。股票数据没有图片数据那么大batch 设 16 或 64 都可以但不要超过样本量的一半。shuffleTrue在训练时可以打乱样本顺序这个不影响时序关系因为每个样本自己已经包含历史窗口。验证时不开梯度可减少内存占用并避免误更新模型参数。关于早停我在上面的代码里用best_val_loss保存了最优参数后续再用这个参数做测试集预测。真正跑实验时我会设置patience10如果验证集 Loss 连续 10 个 epoch 没有下降就停止训练。4. 把模型接进 Backtrader策略类与信号生成模型训练完成后真正的工作才刚开始。Backtrader 是一个事件驱动的回测框架它本身并不知道 PyTorch 模型是什么。需要把预测结果转换成策略能读取的信号。4.1 Backtrader 的数据源适配如何把 DataFrame 喂给 CerebroBacktrader 支持通过pandas直接加载数据。首先把原始行情 DataFrame 转成 Backtrader 要求的格式必须包含datetime, open, high, low, close, volume并且索引是日期。这一步很简单但很多人忽视如果 DataFrame 里有 NaNBacktrader 会直接报错。import backtrader as bt df_bt df.copy() df_bt[datetime] pd.to_datetime(df_bt[date]) df_bt df_bt.set_index(datetime) df_bt.drop(columns[date], inplaceTrue) data_feed bt.feeds.PandasData( datanamedf_bt, datetimeNone, # 使用索引作为时间 open-1, high-1, low-1, close-1, volume-1, openinterest-1 )open-1等参数的意思是让 Backtrader 自动识别列名。如果列名不是open而是Open就要显式传openOpen。这里最容易翻车的点是指数列如果 DataFrame 的索引不是唯一的Backtrader 的按时间对齐会失效所以前面必须先drop_duplicates。4.2 用预测结果生成买卖信号阈值是核心LSTM 预测的是“下一个交易日的收盘价”。要把它变成买卖信号不能简单地和当前收盘价比因为预测总有偏差。我经常用“预测涨跌幅”来生成信号如果预测下一日收盘价比当前收盘价高出超过阈值则买入低于阈值则卖出。class LSTMStrategy(bt.Strategy): params dict( buy_threshold0.005, # 预测涨幅超过 0.5% 才买入 sell_threshold-0.005, # 预测跌幅低于 -0.5% 才卖出 window30 ) def __init__(self): self.pred_signal self.datas[0].pred_signal # 预先生成的预测涨跌幅 self.order None def next(self): if self.order or len(self.data) self.params.window: return if self.pred_signal[0] self.params.buy_threshold and not self.position: self.buy() elif self.pred_signal[0] self.params.sell_threshold and self.position: self.close()上面代码里的pred_signal需要提前准备好。常见做法是在进入回测前用训练好的 LSTM 对每一根 K 线做滚动预测把“下一日预测涨跌幅”作为一列添加到 DataFrame。这个过程必须在时间上严格回溯预测第 t 天的信号时只能用第 t 天之前的数据作为模型输入不能把第 t 天的收盘价算进去否则就构成了未来函数。buy_threshold0.005是经验值在日线级别如果模型预测的涨跌幅连 0.5% 都不到通常不值得出手因为手续费和滑点会吃掉利润。阈值调得越高交易次数越少但每笔交易的预期质量更高。这是回测调参里最先要试的参数。4.3 回测运行与绩效提取这里能拿到哪些指标Cerebro 是 Backtrader 的核心引擎。设置好初始资金和手续费后把策略加入引擎直接跑。回测结束后能从stats里拿到年化收益率、最大回撤、夏普比率等指标。cerebro bt.Cerebro() cerebro.adddata(data_feed) cerebro.addstrategy(LSTMStrategy) cerebro.broker.setcash(100000.0) cerebro.broker.setcommission(commission0.0005) # 万五的手续费 results cerebro.run() final_value cerebro.broker.getvalue() print(f最终权益: {final_value:.2f}) # 输出常用回测指标 strat results[0] print(f总收益率: {strat._equity_curve[0][drawdown] if hasattr(strat, _equity_curve) else N/A})这里有一个坑Backtrader 自带的回测分析器默认不记录每个 bar 的收益曲线。如果要做详细的绩效归因需要额外添加bt.analyzers.Returns和bt.analyzers.DrawDown。不添加的话只能看到最终的 cash看不到最大回撤和月度分布这在验证模型时是远远不够的。5. Backtrader 回测参数设置与高频避坑清单这个章节把我在实际跑 LSTM Backtrader 时遇到的高频问题汇总一下每一条都对应一个“现象 - 原因 - 解决”的完整链路。5.1 手续费、滑点与现金不设这些的测试等于自欺手续费和滑点是最容易被忽略的。很多人一开始只设置setcash(100000)然后发现回测收益非常漂亮放到实盘却亏损。原因是规则策略的交易频率加上 LSTM 的信号变动手续费可能会吃掉 20% 以上的收益。cerebro.broker.setcash(100000.0) cerebro.broker.setcommission(commission0.0007) # 万七 cerebro.add_slippage_perc(perc0.001, slip_openTrue, slip_limitFalse)add_slippage_perc(perc0.001)代表每次成交增加 0.1% 的滑点。对于日线级别的低频模型0.1% 已经是比较保守的估计。如果你回测的是分钟级别这个值要提到 0.2% 甚至更高。现金设置也很关键如果初始资金太少而 LSTM 信号在一段时间内连续出现买入仓位管理会让后续的买入无法执行。5.2 多股回测的数据对齐时间戳与停牌坑热词里常能看到“backtrader 多股回测”但多股回测最大的坑不是框架不会用而是数据没对齐。不同股票的交易日可能不完全一致停牌日会缺失一行。直接把两个 DataFrame 喂给同一个 Cerebro 时Backtrader 会按时间戳对齐但缺失位置会变成 NaNLSTM 预测时就会出错。解决办法是构建一个统一的交易日历。先用所有股票中日期最全的那个作为基准然后对每只股票的 DataFrame 进行reindex缺失的价格用最近一个有效值前向填充。停牌期间不产生交易这是最接近真实市场的做法。def align_stock_data(df, trade_dates): df df.set_index(datetime) df df.reindex(trade_dates) df df.fillna(methodffill) df df.dropna() return df.reset_index()前向填充会让停牌日的收盘价维持原值模型对这样的数据不会预测出大的涨跌符合常理。但如果停牌时间过长前向填充会失真。遇到超过 20 个交易日停牌的股票我一般会直接丢弃该股票而不是强行填充。5.3 常见问题LSTM 回测的 5 个高频翻车点现象一训练集 Loss 很低但回测却亏钱。原因是模型过拟合了历史价格模式把噪声当成了规律。解决减少hidden_size增加dropout或者提高开盘阈值只交易预测涨幅最大的那几天。现象二回测胜率很高但最终资金曲线却几乎不动。原因是模型预测的涨跌幅很接近 0信号阈值设得太低导致买入和卖出频繁切换手续费把利润吃光。解决把buy_threshold从 0.3% 提升到 0.8%看一下交易次数是否明显下降。现象三换到另一只股票后模型完全失效。原因是从头训练时没有做滚动训练模型只适用于某一段特定行情。解决对验证集做滚动窗口训练每 60 个交易日用最近一年的数据重新训练模型。现象四回测结果会随着随机种子改变。原因是训练 LSTM 时权重初始化有随机性同时DataLoader(shuffleTrue)的顺序也影响结果。解决在训练前设置torch.manual_seed(42)并固定 numpy 随机种子保证每次回测可复现。现象五模型预测的下一日涨跌幅永远都是正的。原因是股票整体长期上涨模型学到了“均值回归”以外的上升趋势。解决不要在原始价格上训练而是对“涨跌幅序列”训练模型或者对价格做一阶差分后再归一化。6. 从单股到组合验证 LSTM 预测系统是否值得上实盘单只股票的 LSTM 回测曲线再好看也不能直接作为实盘依据。我习惯在框架完整跑通后做三件事验证模型是否真的有效样本外测试、滚动训练、多股组合回测。样本外测试不能只做一次。把最近半年的数据留出来完全不参与训练等模型调参完成后再用这半年数据跑一遍回测。如果样本外表现和训练期差距过大说明模型选择本身有问题。滚动训练也值得做每个季度用过去两年的数据重新训练然后回测下一个季度。这能在一定程度上模拟实盘的环境变化。多股组合回测是最后一道防线。单只股票的低回撤很可能是偶然组合回测可以分散这个噪声。把模型分别应用到 5 到 10 只不同行业的股票上每只股票均分资金。观察整体资金曲线是否仍然稳定。如果组合回测的夏普比率能稳定在 1 以上这个系统才具备进一步上实盘的潜力。for ticker in [600519, 000001, 000858]: df pd.read_csv(fdata/{ticker}.csv) df preprocess(df) predicted run_lstm_prediction(df, model_pathbest_lstm.pth) df[pred_signal] predicted cerebro.adddata(bt.feeds.PandasData(datanamedf))最终值得不值得实盘取决于你的风险偏好和验证深度。我的经验是至少完成一次完整的“过去一年每日滚动预测 下一日交易”模拟把所有手续费、滑点、停牌都算进去如果净值曲线能跑赢基准指数且最大回撤低于 20%这个框架才算合格。希望这篇笔记能帮你把 PyTorch、Backtrader 和 LSTM 这条链路真正跑通少踩几个我已经踩过的坑。本文还有配套的精品资源点击获取