简介这是一份聚焦量化交易策略开发的PDF文档主要面向量化交易爱好者、金融数据分析人员及深度学习入门者系统讲解如何基于PyTorch构建LSTM模型进行股票价格预测与回测验证。资源包为单个PDF文件共53页大小2.33MB支持目录章节跳转与阅读器大纲快速定位便于按需查阅。目前已有196人学习下载。文档内容覆盖量化交易与LSTM基础、数据获取与预处理、特征工程、基于PyTorch的LSTM模型结构设计与训练优化、股票价格预测流程、回测框架搭建以及策略评估与改进等完整模块并详细展开数据清洗、归一化、损失函数选择、学习率调度、正则化、早停策略等关键技术环节通过阅读可掌握从原始数据到策略回测的完整实现思路是入门深度学习量化方向的实用参考资料。1. 同样是LSTM股价预测为什么有人回测翻倍、实盘亏光过去三年我在量化社区看到最多的一类帖子就是用PyTorch搭了个LSTM预测股价回测曲线漂亮得让人想辞职结果一上模拟盘就开始连续打脸。这不是模型玄学而是大多数人在拿到行情数据之后直接df.shift(-1)造标签、直接train_test_split切数据集、直接拿未来数据算收益率——这三步做完回测再好也是自欺欺人。这篇笔记要解决的就是把这套「基于PyTorch的LSTM股票价格预测与回测框架」从数据清洗、特征构造、模型训练到回测引擎完整过一遍重点是哪些参数决定生死、哪些坑会让你的回测曲线变成废纸。适合已经会写Python、想认真做量化策略验证的从业者不面向只想跑通一个demo的初学者。2. 把PyTorch环境装到能用WSL、CUDA版本和第一段验证代码2.1 为什么我推荐在WSL里跑而不是Windows原生环境做时间序列预测跑一次完整的LSTM网格搜索可能要十几个小时。Windows原生环境的PyTorch虽然能装但动态图调试、显存监控、numpy底层库的线程调度都不如Linux顺手。常见做法是在WSLWindows Subsystem for Linux里装Ubuntu然后conda建独立环境——这个方案对国内开发者的额外好处是换机器之后环境迁移方便也不用担心Windows下CUDA和显卡驱动版本互相打架的问题。装WSL本身不复杂Windows Terminal里执行wsl --install装完重启进Ubuntu。之后是conda环境隔离。我踩过一次GPU版PyTorch装好之后torch.cuda.is_available()返回False折腾了大半天发现是英伟达驱动和WSL的CUDA适配层版本对不上。所以这里先给出一段装完环境之后必跑的验证脚本它能一次暴露驱动、CUDA toolkit、PyTorch三者的版本匹配问题import torch import platform print(Python 版本:, platform.python_version()) print(PyTorch 版本:, torch.__version__) print(CUDA 是否可用:, torch.cuda.is_available()) if torch.cuda.is_available(): print(显卡名称:, torch.cuda.get_device_name(0)) print(显存总量(GB):, torch.cuda.get_device_properties(0).total_memory / 1024**3) # 实际跑一个矩阵乘法验证GPU不是只报了名字、一算就崩 a torch.randn(1024, 1024, devicecuda) b torch.randn(1024, 1024, devicecuda) c torch.matmul(a, b) torch.cuda.synchronize() print(GPU 矩阵乘法验证通过) else: print(当前运行在 CPU 模式)这段代码的逻辑很直白先确认PyTorch装的是哪个版本再确认CUDA可用性最后用一个1024乘1024的矩阵乘法强制GPU干活。注意synchronize()这行不能省——PyTorch的CUDA操作默认是异步的不加同步等待你看到的结果可能是任务还没执行完就打印出来了。参数说明torch.cuda.is_available()返回False时优先查三件事——nvidia-smi是否能看到显卡、python -c import torch; print(torch.version.cuda)输出的CUDA编译版本、以及你装PyTorch时用的CUDA对应版本cu117/cu118/cu121。三者的匹配关系是驱动版本 运行时要求 PyTorch编译版本。如果驱动太老确实存在装不上GPU版PyTorch的情况那就先升驱动再装环境。2.2 数据访问和存储的加密方案你的行情数据也是资产很多人把精力全放在模型上却忽略了行情数据本身的安全。你在量化这条路上积累的清洗脚本、特征代码、标注数据就是你的核心竞争力丢了或者被截了比模型被抄更难受。常见做法是给数据文件做加密存储本地落盘用SQLCipher或加密压缩云端同步走加密通道。下面这段代码给出一个轻量级的本地数据加密方案from cryptography.fernet import Fernet import pandas as pd import io # 生成一次性密钥妥善保管这个key丢了数据就永久无法解密 key Fernet.generate_key() cipher Fernet(key) def save_df_encrypted(df: pd.DataFrame, path: str, key: bytes): 把DataFrame加密后写入文件落盘的不是明文 data df.to_csv(indexFalse).encode(utf-8) encrypted cipher.encrypt(data) with open(path, wb) as f: f.write(encrypted) def load_df_encrypted(path: str, key: bytes) - pd.DataFrame: 读取时解密并还原DataFrame with open(path, rb) as f: encrypted f.read() decrypted cipher.decrypt(encrypted).decode(utf-8) return pd.read_csv(io.StringIO(decrypted)) # 用法示例把日线数据加密落盘读取后可以正常打印验证 df pd.DataFrame({close: [1.0, 2.0, 3.0]}) save_df_encrypted(df, daily_bar.enc, key) restored load_df_encrypted(daily_bar.enc, key) print(restored.head())这段代码的逻辑是Fernet是对称加密加密解密用同一个key密钥生成一次就要备份好——它是你的后悔药丢了等于数据全部作废。实际项目中我一般会把key存在环境变量或单独的密钥文件里而不是写死在代码中。加密之后的数据文件即便被拷走对方看到的也是密文配合数据访问日志就能做到可控。参数说明Fernet的加密强度对行情数据的日常保护已经够用它比直接zip加密更安全且Python生态有官方维护库。如果你的数据量到了几十GB级别就不要用这个方案逐行读了改成chunk分批加密或直接上层文件加密系统。2.3 取数到特征工程的完整流水线环境通了、加密方案有了接下来就是数据流水线。一句话特征工程先于模型架构决定你的LSTM能不能收敛。这里给出一段可复用的数据处理代码从tushare取日线数据构造LSTM需要的序列样本import numpy as np import pandas as pd def fetch_and_prepare(symbol: str, start: str, end: str): 取日线数据并构造LSTM的监督学习样本 import tushare as ts df ts.pro_bar(ts_codesymbol, adjqfq, start_datestart, end_dateend) df df.sort_values(trade_date).reset_index(dropTrue) # 只保留收盘价做基础特征实际策略可以加入量、额、最高最低 df[ret] df[close].pct_change() # 关键点删除NaN行第一条记录没有收益率 df df.dropna().reset_index(dropTrue) # 归一化LSTM对输入尺度敏感一定要fit在训练集上 from sklearn.preprocessing import MinMaxScaler scaler MinMaxScaler(feature_range(0, 1)) df[close_scaled] scaler.fit_transform(df[[close]]) return df, scaler def make_sequences(data: np.ndarray, seq_len: int 60): 把一维价格序列切成滑窗样本X是过去60天y是下一天 X, y [], [] for i in range(len(data) - seq_len): X.append(data[i:iseq_len]) y.append(data[iseq_len]) return np.array(X), np.array(y)这段代码的关键有两处。第一处是dropna()——pct_change()之后首行必然是NaN不删掉后面构造序列时索引会错位训练出来的模型第一样本就是脏的。第二处是scaler.fit_transform放在整个数据上严格来说这有数据泄露的风险我这里只是展示最小可用版本在后面避坑章节会给出严格版本。参数seq_len60表示用过去60个交易日预测下一天这个数字对应约一个季度的交易数据是很多论文和实战项目的常见起点但不是最优参数后面网格搜索会说明。接下来是把全量序列切分成训练集和测试集的注意点。注意时间序列不能随机打乱切分必须按时间顺序切。常见错误是直接用train_test_split默认的shuffleTrue这会把未来的数据混进训练集导致回测指标虚高。# 按时间顺序切分前80%训练、后20%测试 train_size int(len(X) * 0.8) X_train, X_test X[:train_size], X[train_size:] y_train, y_test y[:train_size], y[train_size:] print(f训练样本: {X_train.shape}, 测试样本: {X_test.shape}) # 期望输出(假设总样本1000)训练样本: (800, 60, 1), 测试样本: (200, 60, 1)这里的(样本数, 时间步, 特征维度)三维张量形状直接决定了后面PyTorch模型的输入维度。特征维度目前是1就是收盘价后面想加入成交量、流通市值只要在构造make_sequences之前把多列堆叠成二维数组即可。维度变了之后模型第一层的input_size要跟着改这是新手最容易翻车的地方。3. LSTM预测模型从零构建PyTorch代码、参数坑与收敛判断3.1 最小可用LSTM模型结构、初始化与训练闭环LSTM做股价预测的基础逻辑是用过去N天的序列数据学习一个映射关系输出未来一天的价格。它比线性回归强的点在于能捕捉序列中的短期依赖和门控记忆但代价是需要更多调参技巧。下面给出一个实战向的PyTorch LSTM实现import torch import torch.nn as nn class LSTMPredictor(nn.Module): def __init__(self, input_size1, hidden_size64, num_layers2, dropout0.2): super().__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropoutdropout ) self.regressor nn.Sequential( nn.Linear(hidden_size, 16), nn.ReLU(), nn.Linear(16, 1) ) def forward(self, x): # x形状: (batch, seq_len, input_size) lstm_out, _ self.lstm(x) # 取最后一个时间步的隐状态做预测 last_hidden lstm_out[:, -1, :] return self.regressor(last_hidden)模型结构不复杂nn.LSTM是核心batch_firstTrue让输入维度直接是(batch, seq_len, input_size)这对新手更直观。dropout0.2只在层数大于1时生效它的作用是防止过拟合——当训练集loss持续下降而验证集loss开始上升时优先调大它。回归头是一个两层MLP把LSTM最后一步的64维隐状态压缩成1维价格预测。参数说明hidden_size64表示隐状态维度这个值决定了模型的记忆容量太小欠拟合、太大过拟合且耗显存。num_layers2是LSTM堆叠层数2层是一个常见折中点——1层表达力不足3层以上训练难度骤增且收益极小。下面给出训练闭环的代码def train_model(model, X_train, y_train, X_val, y_val, epochs60, lr0.001): device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) X_train_t torch.FloatTensor(X_train).to(device) y_train_t torch.FloatTensor(y_train).to(device) X_val_t torch.FloatTensor(X_val).to(device) y_val_t torch.FloatTensor(y_val).to(device) optimizer torch.optim.Adam(model.parameters(), lrlr) criterion nn.MSELoss() for epoch in range(epochs): model.train() optimizer.zero_grad() pred model(X_train_t) loss criterion(pred.squeeze(), y_train_t) loss.backward() optimizer.step() if (epoch1) % 10 0: model.eval() with torch.no_grad(): val_pred model(X_val_t) val_loss criterion(val_pred.squeeze(), y_val_t) print(fEpoch {epoch1}: train_loss{loss.item():.6f}, val_loss{val_loss.item():.6f})训练代码的关键点是zero_grad()必须在每轮调用否则梯度会累加导致loss震荡不收敛。MSELoss是回归任务的标准选择。每10轮打印一次训练和验证loss这是判断模型状态最直接的依据——训练loss降、验证loss也降说明模型在正常学习训练loss降、验证loss不降或回升说明过拟合了。3.2 预测效果判定损失值降到多少才算能用很多新手看到loss0.001就觉得模型很准实际上MSE均方误差的量纲依赖数据的归一化范围。如果价格被缩放到0到1之间loss0.001意味着平均误差大约0.03换算回真实价格假设原始价格100元就是3元这个误差在日线级别并不算好。一个更直观的判定方式是看预测方向和真实方向的一致性。代码验证如下import numpy as np def direction_accuracy(y_true, y_pred): 计算涨跌方向预测准确率 # 相邻真实值比较得到实际涨跌方向 true_dir np.sign(np.diff(y_true)) pred_dir np.sign(np.diff(y_pred)) # 忽略方向为0的情况 mask true_dir ! 0 return np.mean(true_dir[mask] pred_dir[mask]) # 用法传入反归一化后的真实价格和预测价格序列 # acc direction_accuracy(y_true_price, y_pred_price)这只是一种常用量化方式方向准确率超过55%就已经比随机预测强很多值得继续优化低于50%说明模型基本没学到规律这时候不要急着调参先去检查数据流水线——是否包含未来数据、是否归一化泄漏。3.3 LSTM参数调试的四个杠杆实战中最常调的四个参数按性价比排序是序列长度seq_len、隐藏层维度hidden_size、学习率lr、dropout。我用过一套网格搜索的思路用itertools.product生成参数组合每组在验证集上跑方向准确率选择最高组合。这里给出参数范围参考——这不是固定标准而是我用下来相对合理的起点范围参数起点值调试范围调试方向说明seq_len6020~120太短只学到噪声太长把远端无关信息也塞进来hidden_size6432~128超过128对日线数据基本提升很小num_layers21~33层以上训练不稳定dropout0.20.1~0.5验证集loss回弹时加大lr0.0010.0005~0.01lr太大会loss震荡太小收敛慢网格搜索最大的坑是要用验证集而不是测试集去选参数——在测试集上反复调参等于把测试集变成了训练集最后的回测结果就没有说服力了。4. 回测框架选型与自研轻量回测从backtrader到自写引擎4.1 三套回测方案对比与选型LSTM模型训练好之后下一步是回测验证。回测框架的选择决定了策略验证的效率和可信度。常见的三套方案各有取舍方案优点缺点适合场景自研回测脚本逻辑完全可控无黑匣子撮合逻辑可能过于理想化验证单标的、日线级别策略backtrader功能成熟支持自定义学习曲线陡接口老旧多标的、多策略对比vectorbt向量化运算极快部分逻辑封得太死大规模参数扫描我个人的习惯是先写一个轻量自研回测脚本来验证LSTM策略的原始逻辑确认有研究价值后再迁移到backtrader做更严格的回测。理由很简单——自研脚本的每一步我都知道它算了什么出了问题能快速定位backtrader的抽象层级较高出了问题排查链路长。提示从backtrader扩展到期现或模拟交易是可行的但需要把回测中假设的以收盘价成交、无滑点等条件全部替换为真实撮合逻辑这一步的工程量往往比想象中大三倍。4.2 自研最简回测引擎交易信号、仓位与资金曲线这里给出一段极简但逻辑完整的自研回测代码。核心是模型预测明天的涨跌预测涨幅超过阈值就持有或买入低于阈值就空仓。仅做日线级别单标的验证def run_backtest(prices: np.ndarray, preds: np.ndarray, threshold: float 0.0): 最简回测预测明天涨幅超过threshold则持仓否则空仓 假设策略在今天收盘时根据信号决定明天的持仓状态 positions np.zeros(len(prices)) for i in range(1, len(preds)-1): # 第i天的预测值对应第i1天的实际价格 if preds[i] threshold: positions[i1] 1.0 # 第i1天持仓 else: positions[i1] 0.0 # 策略日收益率 持仓状态 * 当日涨跌幅并扣除交易成本 daily_ret np.diff(prices) / prices[:-1] cost 0.0005 # 单边万五手续费 滑点成本 trades np.abs(np.diff(positions)) trade_cost trades * cost strategy_ret positions[1:] * daily_ret - trade_cost equity_curve np.cumprod(1 strategy_ret) # 计算核心绩效指标 total_return equity_curve[-1] / equity_curve[0] - 1 sharpe np.mean(strategy_ret) / np.std(strategy_ret) * np.sqrt(252) max_drawdown np.max(1 - equity_curve / np.cummax(equity_curve)) print(f累计收益: {total_return:.2%}) print(f夏普比率: {sharpe:.2f}) print(f最大回撤: {max_drawdown:.2%}) return equity_curve这段代码的逻辑是positions数组记录了每个交易日的持仓状态——回测中策略在第i天收盘时看到模型对第i1天的预测决定第i1天是否持仓。trade_cost只在实际发生仓位变化的那天扣费这是模拟真实交易成本的简化模型。equity_curve用cumprod连乘得到这是资金曲线的标准计算方式。参数说明threshold是决策阈值0表示只看涨跌方向。调高它可以过滤掉预测涨幅较小的信号减少交易频率并降低手续费拖累。cost0.0005是单边成本假设——如果是高频交易这个值需要显著提高如果做日线级别中低频万分之五是一个比较现实的估计。4.3 回测结果的可信度检验至少要看这三项指标一个LSTM策略回测报告不能只输出总收益率。我最少会看三项夏普比率、最大回撤、交易次数。夏普比率低于1的策略不值得上实盘最大回撤超过30%的策略要有极强的心理承受力才行交易次数太少比如一个标的一年只买卖了5次则统计显著性不足任何绩效指标都没有意义。判断标准如下比绝对值更有参考价值的是相对参照——同一套LSTM模型跑在不同股票上的回测结果应当有接近的绩效水平。如果某只票回测年化50%另一只票直接亏损大概率是模型在该标的上过拟合了而不是真的发现了什么规律。5. 避坑指南LSTM量化里最常见的五个翻车现场5.1 数据泄露你用了未来数据回测当然漂亮现象回测曲线完美上涨训练集loss和验证集loss都低得离谱但实盘一塌糊涂。原因在特征构造阶段用了整个数据集的均值和方差做归一化。前80%的训练数据已经看到了后20%测试数据的分布信息这就是数据泄露。另一个常见来源是shift(-1)造标签时多移了一位让模型提前一天看到了答案。解决严格先分训练集/测试集再在训练集上fit归一化器然后分别transform训练集和测试集。标签构造用shift(-1)之后要检查第一行——如果第一行有值就说明你移位方向错了。# 正确的做法先切分再归一化 train_df df.iloc[:train_size] test_df df.iloc[train_size:] scaler MinMaxScaler() train_df[close_scaled] scaler.fit_transform(train_df[[close]]) # 用训练集的scaler的transform测试集而不是重新fit test_df[close_scaled] scaler.transform(test_df[[close]])5.2 归一化对象错误对收益率归一化和对价格归一化截然不同现象模型训练时loss下降正常但回测时发现预测价格总是滞后一天——就是经典的「昨天的价格预测今天的价格」。原因LSTM对绝对值价格做回归时模型发现最简单的策略就是把上一日的价格复制过来因为相邻两天价格变化很小这样MSE就已经很低了。这不是模型学会了规律而是模型学会了偷懒。解决改成对收益率做LSTM回归而不是对绝对价格做回归。预测收益率之后再累积还原成价格曲线。收益率的统计特性更平稳LSTM能学到的东西才有意义。5.3 验证集loss持续下降低于训练集检查是不是数据排序出了问题现象验证集loss比训练集loss还低看着不合理。原因切分数据集时如果用了默认的train_test_split随机切分验证集里混入了时间上靠后的数据而训练集里也有时间上靠后的数据验证集随机抽到的样本反而更容易预测。解决时间序列数据必须按时间顺序切分——前80%训练后20%验证。不要用随机切分。这是老生常谈但每次检查代码总会发现有人犯这个错。5.4 LSTM预测值全部收敛到一个常数现象模型输出的预测值几乎不变或者预测曲线是一条很平的线。原因回归任务中如果输入数据的方差很小、目标值分布又集中模型发现输出均值是最小化MSE的选择。另一个常见原因是学习率太低导致模型基本没怎么更新。解决检查归一化后的数据分布是否有足够的波动确认学习率不是低到1e-6这种水平考虑在损失函数中换成HuberLoss它对离群点更鲁棒、对收敛到均值的情况有一定缓解。5.5 回测结果受极端单日影响过大现象某一天收益突然暴涨20%比如涨停策略总收益被这一天的收益撑起来了。一看持仓记录确实在那一天持仓了严格来说不算错但这对策略的评价产生了极大的误导。原因LSTM策略可能碰巧抓到了某个极端行情但这种事件不可重复。解决在回测报告中额外输出「剔除当日最大收益后策略收益」和「剔除当日最大亏损后策略收益」。如果剔除某一天后总收益从30%变成-5%说明策略的本质不是稳定盈利而是赌对了一次极端行情实盘不具有参考价值。6. 用一个「预测偏移量验证法」检查你的LSTM有没有学到真规律到这里你已经从环境搭建走到了回测验证。但还有一个测试是我不论做任何时间序列模型都会跑的——预测偏移量验证法。做法很简单把模型的预测结果和真实结果在时间轴上平移0到5天分别计算相关系数。如果平移1天后的相关系数最高说明模型学到的基本就是「昨天复制到明天」的假规律如果平移0天最高而且显著高于平移1天说明预测包含了增量信息。import numpy as np def lead_lag_corr(y_true: np.ndarray, y_pred: np.ndarray, max_lag: int 5): 计算预测序列的真实值在不同滞后下的相关系数 for lag in range(max_lag 1): if lag 0: c np.corrcoef(y_true[:-1], y_pred[:-1])[0, 1] else: c np.corrcoef(y_true[:-lag], y_pred[lag:])[0, 1] print(f滞后{lag}天: 相关系数{c:.4f})以我的经验滞后0天的相关系数要高于滞后1天0.03以上才有信心认为模型学到了行情规律。低于这个值就说明模型没有真正抓住预测信号。这个方法特别适合在把策略接入更完整的回测框架之前做一次快速筛选——通不过的模型不值得花钱花时间去搭回测。用量化交易这行的一句话收尾模型是生产力但数据才是根源。希望你早日跑出一条自己敢上实盘的资金曲线。本文还有配套的精品资源点击获取