基于LSTM的股票预测实战:特征工程、数据防泄露与回测全解析 简介基于深度学习的股票分析预测系统源自高分期末项目面向计算机专业高年级本科生与人工智能入门者适合课程设计、毕业设计或进阶自学。系统将多层感知机与长短期记忆网络相结合通过技术指标提取和特征工程构建时序预测模型并输出股价走势可视化结果。资源包共二十五份文件容量六点三四兆字节涵盖核心源码、历史行情数据、预测结果图像与说明文档脚本内集成数据下载、指标计算、预测推理、策略回测等模块并搭配股票代码、训练集、原始数据集等多层级目录便于还原完整建模流程。全部代码采用模块化设计附有中文注释和接口说明兼顾数据预处理、模型训练策略、超参数优化与性能评估等关键环节可替换个股数据直接运行也可在现有结构和接口基础上扩展新模型。目前已有九十六人学习下载适合需要完整可运行参考实现、课程答辩示例与二次开发起点的读者。1. 用深度学习预测股价为什么你的模型一上实盘就翻车很多人把“深度学习股票分析预测”做成期末项目跑通LSTM后看到训练集loss降到0.001回测曲线贴着真实收盘价心里已经给自己打了高分。可一旦把时间窗口拉到最近一个月预测值和实际走势完全脱节甚至方向都是反的。这不是因为股价“不可预测”而是数据划分、特征构造和评估方式在最开始就埋了雷。这篇实战笔记我会用Python从零搭一套可复现的框架tushare取数、pandas做特征、PyTorch训练LSTM、自写回测器验证收益。适合要交期末项目的学生也适合想入门量化但不想只会调现成库的从业者。读完你会知道哪些步骤是“做给人看”的哪些真正决定泛化能力。2. 数据源与特征工程先把“喂给模型”的原料做好再谈模型股票分析预测系统里模型的好坏经常被归因于算法但实际项目中真正决定上限的是数据。噪音多、有前视偏差、标签定义不合理的样本扔给LSTM或者Transformer都白搭。这一章从数据获取讲到特征构造每一步都有代码并且会刻意标出容易把未来信息混进去的位置。2.1 数据源选择tushare、akshare与yfinance怎么选我见过不少期末项目直接在网上下载历史行情文件或者用现成的csv好处是省事坏处是你不知道这份数据是否做过复权是否包含停牌导致的缺失行。做深度学习预测我们更希望数据源稳定、能复现且可以随时回滚。常见的选择有三个tushare提供Pro接口A股覆盖面广支持前复权/后复权返回的是pandas DataFrame能直接进特征工程。注册后初始积分能取日线但有些接口需要积累积分。akshare完全免费接口很杂但胜在能取到各种另类数据板块、资金流。缺点是接口变动频繁经常需要升级版本才能继续跑。yfinance用起来顺手但更适合美股和全球市场A股字段和交易规则涨跌停、T1对不上容易让时间序列不连续。我做这个项目时一般默认tushare。不是说它最好而是它把复权、停牌、日期排序这些脏活做得最省心非常契合“先跑通再深入”的期末项目节奏。数据源不是越全越好。比如分钟级数据看起来很酷但会引入大量噪音训练时间翻倍且回测时成交逻辑更难写。日线数据已经足够完成一个“高分期末项目”。2.2 用Python获取行情最小代码与复权问题下面是一段能直接跑的最小代码。注意ts.set_token里的token不要硬编码在提交的源码里建议放到环境变量或者至少注释说明。import pandas as pd import numpy as np import tushare as ts # 在终端设置环境变量 TUSHARE_TOKEN避免源码泄露密钥 ts.set_token(你的token) pro ts.pro_api() # 获取平安银行日线前复权时间范围可以自己改 df ts.pro_bar(ts_code000001.SZ, adjqfq, start_date20150101, end_date20231231, freqD) # pro_bar返回的顺序不保证递增强制按trade_date排一次 df df.sort_values(trade_date).reset_index(dropTrue) print(df.head()) print(df.tail())代码逻辑说明ts.pro_bar是tushare Pro封装好的取数函数ts_code传入股票代码交易所代码用后缀区分。adjqfq表示前复权它会把历史价格按照每次除权除息重新计算让价格曲线连续如果是不复权除权日会跳空模型看到这种跳空会误判为巨大涨跌。排序必须做因为接口返回的日期不保证按时间升序。reset_index是为了之后构建滑动窗口时索引干净。参数说明freqD是日线改成60min可以拿分钟线但日线足够本期项目。start_date和end_date直接决定样本量5到8年日线大约1200到2000根恰好够训练一个两层LSTM。获取原始数据后先做一遍“体检”df.columns查看字段df.isna().sum()看缺失df[trade_date].diff().value_counts()看停牌导致的日期断层。这些检查看起来啰嗦但能提前暴露数据源问题。2.3 特征工程技术指标、滑动窗口与标签构造特征工程有三个原则只用当前时刻及之前的信息、所有特征在训练和测试时采用同一套算法、标签不能和特征在同一时刻取到。先加上最基础的技术指标# 基础字段open high low close vol等 df[ma5] df[close].rolling(5).mean() df[ma20] df[close].rolling(20).mean() df[ret1] df[close].pct_change() df[volatility] df[ret1].rolling(10).std() # 标签未来5日收益率回归任务 df[label] df[close].shift(-5) / df[close] - 1.0 # 跌停涨停日处理后删除无效行 df df.dropna().reset_index(dropTrue)这段代码里最容易被忽略的是shift(-5)。它把当前行的close与五天后的close做了比值所以label是未来信息用来当监督信号不能作为模型输入特征。模型输入特征只能保留当行及之前已确定的数据例如ma5和volatility。如果错把label也塞进特征矩阵就是典型的未来函数后面的预测会“完美到不真实”。接下来构造模型真正吃到的样本feature_cols [open, high, low, close, vol, ma5, ma20, ret1, volatility] def build_dataset(data, lookback60): X, y [], [] for i in range(len(data) - lookback): X.append(data.iloc[i:ilookback][feature_cols].values) y.append(data.iloc[ilookback][label]) return np.array(X), np.array(y) X, y build_dataset(df, lookback60) print(X.shape, y.shape)逻辑说明build_dataset对每一行取前lookback天的全部特征作为输入标签是第ilookback天对应的未来收益率。i的循环范围是len(data) - lookback意味着最多生成到倒数第61天避免越界。参数说明lookback60代表用过去三个月交易日的行情预测未来5天的收益方向。窗口太小模型看不到趋势窗口太大特征维度高、训练慢。常见范围在20到120之间60是相对平衡的开始值。这里还有一个容易翻车的细节标准化。若在构建数据集时先对所有特征做全局StandardScaler再划分训练/测试等于让模型在训练时提前“看到”测试集的均值方差。正确做法是只用训练集的scaler去transform训练集、验证集和测试集。我通常会在后面模型训练前单独封装一个函数避免在流水线上随手fit整份数据。提示时间序列标准化必须只fit训练集否则数据泄露会在你不注意的地方发生。最后建议把按时间顺序切分的索引单独保存train_idx, val_idx, test_idx 0, int(len(X)*0.7), int(len(X)*0.85)注意时间序列不能随机切分更不能用train_test_split默认的shuffle这一点在后面的避坑章节会重点展开。如果你想让特征更有“期末项目味”可以再加两个常见指标# RSI 14 delta df[close].diff() gain delta.clip(lower0).rolling(14).mean() loss -delta.clip(upper0).rolling(14).mean() df[rsi] 100 - 100 / (1 gain / (loss 1e-9)) # MACD 的差离值 ema12 df[close].ewm(span12).mean() ema26 df[close].ewm(span26).mean() df[macd] ema12 - ema26加指标的原则是和已有特征相关性不要太高。比如ma5和ma20相关性极高但一个反映短期、一个反映中期LSTM能自动学习相对位置所以保留没问题。vol这个成交量字段经常被直接丢进去实际上不同股票的成交量绝对值差异巨大最好做log1p或者归一化。T1和涨跌停规则在特征里没有体现严格来说应该加入“是否涨停”的布尔列但期末项目可以略过。3. 模型选型与训练用PyTorch把LSTM训练成可复现的管线数据准备好之后模型环节的核心不是“选个最先进的网络”而是保证实验可复现、可比较。很多期末项目在同一个数据集上跑几种网络结果差异巨大最后都不知道是模型好还是随机种子好。这一章的代码会专门处理随机种子、梯度裁剪和早停。3.1 为什么选LSTM而不是CNN或Transformer股价预测本质上是时间序列预测输入是一个长度为lookback的窗口里面每根K线之间是强顺序依赖的。LSTM内部有输入门、遗忘门、输出门可以让梯度沿着时间步流动更远适合捕捉趋势和反转这种缓慢变化的状态。相比原生RNN它不容易梯度消失但也不是完全免疫所以后面会加梯度裁剪。CNN在量化领域一般用于图像化特征比如把K线窗口画出二维图再用卷积分类。但期末项目的时间序列很短卷积窗口一滑反而丢了全局顺序。Transformer擅长学习长程依赖但需要大量数据用1500根日线喂Transformer比LSTM更容易在验证集上表现不稳。GRU是LSTM的简化版本参数少、训练快效果往往不相上下但LSTM“名声更大”答辩时讲起来更顺。做一个快速对比模型顺序建模能力数据需求量训练稳定性期末项目推荐度RNN弱小差易梯度消失不推荐LSTM强中稳定推荐首选GRU强中稳定可作为对比实验CNN较弱较大稳定不推荐Transformer极强很大依赖调参不适合小样本这个表是我个人经验不是定理。期末项目如果想体现“工作量”可以加一个GRU和LSTM的对比实验而不是折腾Transformer。3.2 模型结构设计两层LSTM加全连接我会把预测任务定义为回归输入过去60天特征输出一个连续的未来5日收益率。之所以不用“涨/跌”分类是因为分类会丢掉幅度信息而且类别不平衡严重上涨和下跌天数接近但大涨大跌是少数回归之后再按预测值的正负和大小做决策灵活得多。import torch import torch.nn as nn class StockLSTM(nn.Module): def __init__(self, input_size, hidden_size64, num_layers2, dropout0.3): super().__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropoutdropout ) self.head nn.Sequential( nn.Linear(hidden_size, 32), nn.ReLU(), nn.Linear(32, 1) ) def forward(self, x): # x: (batch, seq_len, features) out, _ self.lstm(x) # 取最后一个时间步的隐状态 last out[:, -1, :] return self.head(last).squeeze(-1)参数说明input_size对应特征数在前一章的feature_cols里是9个字段所以传入9。hidden_size64控制隐单元数量太小欠拟合太大容易过拟合。num_layers2让模型能分层提取短期和中期特征但不要超过3层日线样本量撑不起太深的结构。dropout0.3只对两层LSTM之间的输出生效防止全连接部分对隐状态过拟合。这里有个常见的认识误区out[:, -1, :]取的是最后一个时间步的隐状态而不是最后“一天”的输入。在LSTM中每个时间步都会更新隐状态最后一步的隐状态已经包含了前面所有时间步的压缩信息。如果你想用注意力聚合所有时间步的信息那属于进阶改造后面第六章会讲。3.3 训练循环、早停与随机种子训练循环看起来和普通PyTorch项目没什么两样但有两个时间序列特有的坑数据集不能shuffle梯度爆炸要用clip。前者保证每个batch里的样本都是连续时间窗口后者防止股票数据的极端脉冲把LSTM权重冲飞。import numpy as np from torch.utils.data import TensorDataset, DataLoader def set_seed(seed42): torch.manual_seed(seed) np.random.seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) set_seed(42) # 假设X/y已经按时间顺序构建好 X torch.tensor(X, dtypetorch.float32) y torch.tensor(y, dtypetorch.float32) # 按时间顺序划分 split1 int(len(X) * 0.7) split2 int(len(X) * 0.85) train_ds TensorDataset(X[:split1], y[:split1]) val_ds TensorDataset(X[split1:split2], y[split1:split2]) test_ds TensorDataset(X[split2:], y[split2:]) train_loader DataLoader(train_ds, batch_size64, shuffleFalse) val_loader DataLoader(val_ds, batch_size64) test_loader DataLoader(test_ds, batch_size64)注意shuffleFalse是关键。时间序列样本之间高度重叠如果打乱顺序训练集里会混入未来样本验证集和测试集不再是“未知未来”。有人觉得shuffle能加速收敛但对时序模型来说这是典型的偷懒翻车点。训练部分device torch.device(cuda if torch.cuda.is_available() else cpu) model StockLSTM(input_size9).to(device) optimizer torch.optim.Adam(model.parameters(), lr1e-3) criterion nn.MSELoss()如果不用GPUinput_size9会训练得比较慢但日线级别数据量小CPU也能在几分钟内搞定。lr1e-3是LSTM常见的起点如果loss震荡就降到3e-4。早停循环best_val_loss float(inf) patience 10 counter 0 epochs 200 for epoch in range(epochs): model.train() train_loss 0.0 for xb, yb in train_loader: xb, yb xb.to(device), yb.to(device) optimizer.zero_grad() pred model(xb) loss criterion(pred, yb) loss.backward() # 梯度裁剪防梯度爆炸 nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() train_loss loss.item() * len(xb) train_loss / len(train_ds) model.eval() val_loss 0.0 with torch.no_grad(): for xb, yb in val_loader: xb, yb xb.to(device), yb.to(device) pred model(xb) loss criterion(pred, yb) val_loss loss.item() * len(xb) val_loss / len(val_ds) if val_loss best_val_loss: best_val_loss val_loss torch.save(model.state_dict(), best_model.pt) counter 0 else: counter 1 if counter patience: print(f早停于 epoch {epoch}) break代码逻辑说明每个epoch内先训练后验证。clip_grad_norm_的max_norm1.0表示把整个参数梯度的二范数限制在1以内超过则等比缩放。patience10表示连续10个epoch验证loss不降就停止早停是防过拟合的“后悔药”。参数说明batch_size64在序列模型上不要贪大因为每个样本是60x9的二维片段64个已经包含很多信息如果显存不够就降到32。epochs200上限实际上大多数模型在50-100个epoch内就会被早停。训练结束后测试集只加载best_model.pt。4. 评估与回测方向准确率、资金曲线和样本外测试模型训练完大部分新手会直接看loss但loss低不等于能赚钱也不等于项目分数高。评估要从回归指标、方向准确率、资金曲线三个维度来做。4.1 回归与分类的评估指标MAE、RMSE、方向准确率与IC回归任务不能只看loss要算几个更容易向老师和评委解释的指标import numpy as np from sklearn.metrics import mean_absolute_error, mean_squared_error # 假设 y_true / y_pred 是测试集上的真实和预测未来收益率 mae mean_absolute_error(y_true, y_pred) rmse np.sqrt(mean_squared_error(y_true, y_pred)) # 方向准确率模型预测涨跌方向与真实涨跌方向一致的比例 direction_acc np.mean(np.sign(y_true) np.sign(y_pred)) # IC预测值和真实值的相关系数衡量预测的线性解释力 ic np.corrcoef(y_true, y_pred)[0, 1] print(fMAE: {mae:.4f}) print(fRMSE: {rmse:.4f}) print(f方向准确率: {direction_acc:.2%}) print(fIC: {ic:.4f})这些指标各有侧重MAE是平均误差RMSE因为平方放大了大偏差方向准确率直接对应“涨跌判断”的能力IC则反映预测值和真实值之间的单调相关。期末报告里IC能超过0.05已经算有可用信号如果IC是负的说明模型和市场反着来反而可以作为反向指标。不要追求方向准确率80%以上。在日线级别的股票预测里长期方向准确率能稳定在55%就是很优秀的模型。如果方向准确率太高第一反应应该是检查有没有未来函数。4.2 写一个简单的回测引擎次日开盘买入、佣金和滑点回测有个铁律不能用当天的预测信号当天执行因为信号是基于当天收盘数据产生的实际买入只能发生在次日。用次日开盘价执行会引入一个“未来函数”的修正也算是一种防坑。def backtest(df, pred_signal, initial_cash100000, fee_rate0.0003, slippage0.0002): cash initial_cash position 0 equity [] signal pred_signal.copy() for i in range(1, len(df)): # 用前一天的信号决定今天的开盘调仓 trade_price df[open].iloc[i] * (1 slippage) if signal[i-1] 0 and position 0: position cash / trade_price cash - position * trade_price elif signal[i-1] 0 and position 0: cash position * trade_price * (1 - fee_rate) position 0 equity.append(cash position * df[close].iloc[i]) final_value cash position * df[close].iloc[-1] return final_value, np.array(equity)逻辑说明signal是模型对每一天的预测值。第i天开盘时先把i-1天的预测转成信号因为i-1天的预测用到的是i-1天收盘信息i天开盘价是第一个可成交的时点。佣金和滑点简单加在成交价上买入时价格抬高卖出时价格降低比“完全按收盘价成交”诚实得多。equity用来画资金曲线。参数说明fee_rate0.0003是A股佣金加印花税的一个粗略估计实际佣金万2.5卖出印花税千1但用单边0.0003能体现成本。slippage0.0002表示滑点预计0.02%。这两个参数在期末项目里可以放在报告里解释。注意回测结果和预测方向准确率是两回事。方向准确率55%可能因为手续费过高依然亏损方向准确率50%也可能因为每次赚得多亏得少而盈利。所以要做资金曲线和最大回撤。4.3 过拟合自检滚动训练与样本外测试一个模型在固定测试集上表现好不代表它是真正学会了。常见做法是把测试集再切成前段和后段观察两段的表现是否一致。如果前段好、后段差说明模型只是记住了训练集尾部的模式并没有泛化到新的市场状态。代码框架def walk_forward_validate(df, X, y, train_len800, step200): scores [] for start in range(0, len(X) - train_len, step): end start train_len train_x, train_y X[start:end], y[start:end] test_x, test_y X[end:endstep], y[end:endstep] # 在这里重新实例化模型、训练、预测 # 记录方向准确率和IC pass说明walk_forward模拟真实投资中的滚动训练每过200天就用最近800天重新训练一次。这能暴露模型在样本外的退化速度。期末项目不要求跑完整套但一定要在报告里展示测试集后段和前段的对比这才是高分的差异点。另外回测里要处理涨跌停涨停时你买不进跌停时你卖不出。严格的做法是在回测中跳过这些交易日或者在交易条件里加入if df[pct_chg].iloc[i] 9.8: skip之类的判断。期末项目如果不做这一步可以在报告中主动说明“忽略涨跌停约束”但要写明这是简化假设。5. 股票预测项目必踩的五个坑数据泄露、未来函数与训练不稳这一章是整篇笔记的血泪经验。下面每个问题都来自我实际跑项目时翻车后反推的记录。5.1 全量数据归一化模型悄悄看到了测试集现象测试集loss低到离谱方向准确率超过70%但实盘曲线完全对不上。原因在划分训练集之前对X做了StandardScaler.fitscaler的均值/方差来自全部样本测试集的信息已经渗入训练过程的输入分布。解决只用训练集的统计量然后transform验证集和测试集。from sklearn.preprocessing import StandardScaler # 错误做法 # scaler StandardScaler() # X_scaled scaler.fit_transform(X) # 正确做法 scaler StandardScaler() train_shape X_train.shape X_train scaler.fit_transform(X_train.reshape(-1, train_shape[-1])) X_train X_train.reshape(train_shape) val_shape X_val.shape X_val scaler.transform(X_val.reshape(-1, val_shape[-1])).reshape(val_shape)逻辑说明LSTM输入是三维所以先把前两维合并成“样本数×特征数”的二维矩阵做fit再还原成三维。同一个scaler只拟合训练集特征验证集和测试集直接transform就能保证没有未来均值和方差渗入。5.2 未来函数预测用到了当天收盘后的信息现象测试集上预测曲线和真实曲线几乎重合方向准确率90%以上。原因构建特征时把label或者未来窗口的统计量混进了feature_cols比如用close.shift(-5)的移动平均作为特征。解决对每一列特征做一次时间对齐检查。打印特征矩阵最后一行的时间戳和标签所在时间的对应关系确认特征只包含 t的信息而标签是t1之后的信息。更直接的办法是用df.columns列出特征列表凡是出现shift(-n)、rolling(n).mean().shift(-n)的字段全部排除。5.3 DataLoader开shuffle把未来样本混入了训练batch现象训练loss快速下降但验证loss震荡。原因相邻时间窗口样本高度重叠比如第100个样本和第101个样本共享59天历史。shuffle后一个batch里可能同时出现第1000个样本和第200个样本模型的时间上下文被破坏验证阶段表现自然不稳。解决DataLoader(train_ds, shuffleFalse)并按时间顺序分批。如果实在想增加随机性可以随机丢弃部分样本但绝不要打乱顺序。5.4 停牌和除权导致的数据断档与标签漂移现象df[trade_date].diff()出现大量缺失rolling指标为NaN样本数量不稳定。原因股票停牌期间交易日没有行情自然日数和交易日数对不上直接rolling会跨过停牌缺口。前复权每次除权都会改动历史价格导致历史标签和未来收益失真。解决做两条硬性检查。第一用df[trade_date].diff().value_counts()确认日期间隔基本都是1第二不要使用未复权价格训练训练和测试统一采用adjqfq并在报告中说明复权方式。如果某只股票停牌超过10个交易日直接换股票或者剔除该段。5.5 梯度爆炸与NaNLSTM训练不收敛现象训练几轮后loss变为NaN验证集全为NaN。原因股价原始值可能在10元到1000元之间输入幅度差异大乘以权重后梯度很大或者特征里有NaN没有清洗干净。解决在进模型之前做一次断言并在训练循环里加梯度裁剪。assert not np.isnan(X).any(), 输入存在NaN回到数据清洗 assert not np.isnan(y).any(), 标签存在NaN检查shift逻辑然后把optimizer的学习率从1e-3降到3e-4同时保留clip_grad_norm_。如果输入特征包括成交量和价格这种量纲差异大的字段建议把价格改成对数收益率或者统一用训练集scaler归一化。6. 进阶给LSTM加注意力层并在答辩时展示什么这一章把项目从“能跑”拉到“值得被讨论”。如果你还有时间可以做两件事可视化预测曲线给模型加一个简单的注意力机制。6.1 画出预测曲线和真实曲线import matplotlib.pyplot as plt plt.figure(figsize(12, 5)) plt.plot(y_true, label真实未来5日收益, linewidth0.8) plt.plot(y_pred, label预测收益, linewidth0.8) plt.axhline(0, colorblack, linestyle--, linewidth0.5) plt.legend() plt.title(测试集预测 vs 真实) plt.xlabel(样本序号) plt.ylabel(收益率) plt.show()这个图是答辩第一页的素材。不要只展示loss曲线loss曲线不能说明赚钱能力。把方向准确率和资金曲线放在同一张图里老师一眼能看出你的模型不是黑匣子。6.2 给LSTM加一个简单注意力层注意力机制让模型可以学习“更关注最近哪几天的隐状态”比单纯取最后一个时间步更能解释预测依据。import torch.nn.functional as F class AttentionLSTM(nn.Module): def __init__(self, input_size, hidden_size64, num_layers2, dropout0.3): super().__init__() self.lstm nn.LSTM(input_size, hidden_size, num_layers, batch_firstTrue, dropoutdropout) self.attn_fc nn.Linear(hidden_size, 1) self.head nn.Sequential( nn.Linear(hidden_size, 32), nn.ReLU(), nn.Linear(32, 1) ) def forward(self, x): out, _ self.lstm(x) # out: (B, L, H) attn_weights torch.softmax(self.attn_fc(out).squeeze(-1), dim1) context torch.bmm(attn_weights.unsqueeze(1), out).squeeze(1) return self.head(context).squeeze(-1)这段代码在两层LSTM之后把每个时间步的隐状态做加权平均attn_fc先等权重地打分softmax在序列维度上归一化bmm把权重应用到每个时间步的隐状态上得到加权后的上下文向量。训练时attn_fc会自己学到哪些天的影响更大。6.3 答辩时展示什么才能拿高分不要只报方向准确率要有对比实验随机预测、线性回归、LSTM、LSTM注意力的方向准确率和资金曲线。哪怕LSTM注意力没有明显提升它也能证明你理解了模型工作原理。另外主动讲一个你踩过的坑比如归一化数据泄露或未来函数。评委老师最怕看到“这个项目是怎么跑出来的自己都说不清”。你能讲清楚哪里容易翻车反而说明项目是你自己调出来的。我当年在答辩时把数据泄露这个坑讲出来老师追问我细节场上立刻进入讨论状态。希望帮到你。本文还有配套的精品资源点击获取