简介:这份资源是一套基于深度学习神经网络实现的通用股票预测模型Python源码,面向计算机、人工智能、金融商贸等方向的学生与开发者,可用于课程设计、毕业设计、项目立项演示或自学进阶。项目以LSTM、RNN、Transformer及BERT等模型为核心,配套数据下载、预处理、训练、预测与评估的完整流程,并附详细项目说明文档。压缩包共33个文件,约5MB,包含13个py源码文件、13张png结果图、3个md说明文档及txt、gz等辅助文件,源码与图表分别对应模型实现和训练损失、预测对比、误差指标等可视化结果。已有104人学习下载。读者可据此掌握从数据获取到模型评估的完整链路,理解不同网络结构在股价预测中的表现差异,并可在现有代码基础上修改以扩展功能,适合作为金融时序预测的实践参考。
1. 通用股票预测模型:为什么“一套代码打天下”是个伪命题
很多人第一次接触量化,都是被“通用股票预测模型”这几个字吸引的。深度学习、神经网络、Python 源码、详细说明,看起来像是一把万能钥匙,插进任何一只股票的历史数据里,就能吐出明天的收盘价。我当年也是这么想的,直到我把同一套 LSTM 代码分别跑在贵州茅台、某银行股和一只小盘题材股上,才发现所谓的“通用”,在真实市场里几乎是个玄学。
这个标题真正要解决的问题,不是“预测涨跌”这么简单,而是:如何用一套可复用的深度学习工程框架,把不同股票的数据清洗、特征构造、模型训练、回测评估串成流水线,让换一只标的时只需要改配置,而不是重写代码。它适合有 Python 基础、想入门量化或深度学习落地的工程师,也适合已经会调包、但一直没搞明白“为什么我的模型回测很美、实盘就翻车”的从业者。核心不是模型多深,而是流程多稳。
2. 从原始行情到模型输入:数据管道与特征工程怎么搭
2.1 为什么股票预测的第一步不是选模型,而是对齐时间轴
绝大多数翻车案例,根源都在数据对齐。日线数据里,不同股票停牌、除权除息、涨跌停的日期都不一样。如果你直接把两只股票的数据拼成一个大矩阵喂给神经网络,时间轴错位会让模型学到根本不存在的规律。常见做法是:先确定一个统一的交易日历,把所有标的按这个日历重采样,停牌日用前值填充并打上停牌标记,除权除息用后复权价格计算收益率。
我一般会先拉取一段足够长的历史数据,比如 2015 年至今,然后做三件事:一是用后复权价算对数收益率,避免除权跳空被模型当成真实波动;二是把成交量做对数变换,压缩量级差异;三是对每个特征做滚动窗口标准化,而不是全样本标准化,防止未来信息泄露。这三点听起来基础,但少做任何一个,回测曲线都会漂亮得让你怀疑人生。
import pandas as pd import numpy as np def build_feature_frame(df, window=20): """ df: 单只股票日线数据,包含 open/high/low/close/volume,索引为交易日 window: 滚动标准化窗口 """ out = pd.DataFrame(index=df.index) # 对数收益率,避免除权跳空 out['ret'] = np.log(df['close'] / df['close'].shift(1)) # 成交量对数变换 out['log_vol'] = np.log1p(df['volume']) # 高低价差相对幅度 out['hl_range'] = (df['high'] - df['low']) / df['close'].shift(1) # 滚动标准化,只用过去 window 天 for col in ['ret', 'log_vol', 'hl_range']: roll_mean = out[col].rolling(window).mean() roll_std = out[col].rolling(window).std() out[col + '_z'] = (out[col] - roll_mean) / (roll_std + 1e-8) # 预测目标:下一日收益率方向,1 为涨,0 为跌 out['target'] = (out['ret'].shift(-1) > 0).astype(int) return out.dropna()这段代码的关键参数是window,我一般设 20 个交易日,对应一个月。设太小,标准化结果噪声大;设太大,对近期波动不敏感。1e-8是防止除零。target用方向而不是具体价格,是因为价格预测的均方误差在金融数据上几乎没有意义,方向分类更贴近实盘决策。注意shift(-1)表示用明天涨跌来标注今天,训练时必须确保最后一行的 target 不参与训练,否则就是未来函数。
2.2 用 PyTorch 搭一个可替换的预测网络
标题里写的是“深度学习神经网络”,但具体用 LSTM、GRU 还是 Transformer,其实可以做成可插拔的。我一般会先实现一个基类,把前向传播和输入维度固定下来,然后分别写 LSTM 和 GRU 两个子类。这样换模型只需要改一行配置,不用动数据管道。
import torch import torch.nn as nn class BasePredictor(nn.Module): def __init__(self, input_dim, hidden_dim=64, num_layers=2, dropout=0.3): super().__init__() self.input_dim = input_dim self.hidden_dim = hidden_dim self.num_layers = num_layers self.dropout = dropout def forward(self, x): raise NotImplementedError class LSTMPredictor(BasePredictor): def __init__(self, input_dim, hidden_dim=64, num_layers=2, dropout=0.3): super().__init__(input_dim, hidden_dim, num_layers, dropout) self.lstm = nn.LSTM( input_size=input_dim, hidden_size=hidden_dim, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0 ) self.fc = nn.Sequential( nn.Linear(hidden_dim, 32), nn.ReLU(), nn.Dropout(dropout), nn.Linear(32, 1), nn.Sigmoid() ) def forward(self, x): # x: (batch, seq_len, input_dim) out, _ = self.lstm(x) # 取最后一个时间步 last = out[:, -1, :] return self.fc(last).squeeze(-1)hidden_dim=64是我在日线数据上试出来的一个平衡点,再大容易过拟合,再小欠拟合。num_layers=2配合dropout=0.3是常见组合,单层 LSTM 表达能力有限,三层以上在小数据集上几乎必然过拟合。batch_first=True让输入维度是(batch, seq_len, feature),和后面 DataLoader 对齐。最后用 Sigmoid 输出 0 到 1 的概率,配合 BCE 损失。如果你换成 GRU,只需要把nn.LSTM换成nn.GRU,其余不变。
2.3 训练循环里必须加的早停与验证集切分
股票数据时间序列不能随机切分,必须按时间顺序切。我一般用前 70% 做训练,中间 15% 做验证,最后 15% 做测试。验证集用来早停,测试集只在最后跑一次。训练循环里每个 epoch 结束后在验证集上算一次损失,如果连续 5 个 epoch 没下降就停,并恢复验证损失最低的模型参数。
def train_model(model, train_loader, val_loader, epochs=50, lr=1e-3, patience=5): device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model.to(device) optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=1e-4) criterion = nn.BCELoss() best_val_loss = float('inf') best_state = None wait = 0 for epoch in range(epochs): model.train() 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() # 梯度裁剪,防止 LSTM 梯度爆炸 nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() 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) val_loss += criterion(pred, yb).item() * len(yb) val_loss /= len(val_loader.dataset) if val_loss < best_val_loss: best_val_loss = val_loss best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()} wait = 0 else: wait += 1 if wait >= patience: break model.load_state_dict(best_state) return modelweight_decay=1e-4是 L2 正则,配合 dropout 一起压制过拟合。clip_grad_norm_的max_norm=1.0是 LSTM 训练的血泪经验,不加这个,损失曲线经常突然变成 NaN。patience=5是早停耐心值,太小容易停在局部最优,太大浪费算力。注意验证损失是按样本数加权的,不是简单平均,否则 batch 大小不一致时会失真。
3. 回测与评估:为什么准确率 55% 也可能亏钱
3.1 用方向准确率、夏普和最大回撤三个指标一起看
模型训练完,第一件事不是看准确率,而是看它在测试集上的交易信号如果真金白银执行,会是什么结果。我一般会算三个指标:方向准确率、年化夏普比率、最大回撤。方向准确率超过 55% 才有统计意义,但光有这个不够,因为连续错几次可能就把本金打没了。夏普比率衡量单位风险的收益,日线策略年化夏普能到 1 以上就算不错。最大回撤告诉你最坏情况下要承受多少浮亏。
def backtest(preds, returns, threshold=0.5): """ preds: 模型输出的上涨概率,shape (n,) returns: 对应交易日的下一日收益率,shape (n,) threshold: 概率大于该值才做多 """ signals = (preds > threshold).astype(int) strategy_ret = signals * returns cum = (1 + strategy_ret).cumprod() total_ret = cum.iloc[-1] - 1 annual_ret = (1 + total_ret) ** (252 / len(returns)) - 1 annual_vol = strategy_ret.std() * np.sqrt(252) sharpe = annual_ret / (annual_vol + 1e-8) max_dd = (cum / cum.cummax() - 1).min() acc = ((preds > threshold) == (returns > 0)).mean() return { 'total_return': total_ret, 'annual_return': annual_ret, 'sharpe': sharpe, 'max_drawdown': max_dd, 'direction_acc': acc }threshold=0.5是最朴素的切分,实际中可以根据验证集上的精确率-召回率曲线调,比如调到 0.55 只做高置信度信号,交易次数少了但胜率会高一些。252是 A 股一年大致交易日数。注意strategy_ret没有扣手续费和滑点,真实回测里至少再减掉单边万分之三的成本,否则结果会虚高。
3.2 滚动前向验证:比单次测试集更接近实盘
单次切分测试集有个问题:你恰好切到一段牛市,模型看起来很好;切到熊市,又很差。更稳的做法是滚动前向验证:用前 3 年训练,第 4 年测试,然后窗口往后滑一年,重复。这样能看到模型在不同市场环境下的表现稳定性。
def walk_forward(features, model_cls, train_years=3, test_years=1): results = [] dates = features.index start = dates[0] while True: train_end = start + pd.DateOffset(years=train_years) test_end = train_end + pd.DateOffset(years=test_years) if test_end > dates[-1]: break train_df = features.loc[start:train_end] test_df = features.loc[train_end:test_end] # 这里省略 DataLoader 构建和训练,实际调用 train_model # model = train_model(model_cls(...), ...) # preds = model(test_df) # results.append(backtest(preds, test_df['ret'])) start = train_end return resultstrain_years=3是我在日线数据上的经验值,太短模型学不到完整周期,太长又容易把过时规律带进来。test_years=1保证每年都有一次样本外评估。这个循环里每次都要重新初始化模型,不能接着上一个窗口继续训练,否则信息会跨窗口泄露。
4. 避坑与排查:通用股票预测模型最常见的 5 个翻车现场
4.1 现象:训练损失一直降,验证损失从第二个 epoch 就开始涨
原因:模型容量相对数据量太大,或者特征里混入了未来信息。股票日线数据一年才 250 条左右,如果你用 3 年数据训练,样本不到 800 条,而 LSTM 参数量轻松上万,过拟合是必然的。另一个隐蔽原因是滚动标准化时用了全样本的均值和方差,等于把未来信息泄露给了训练集。
解决:先把hidden_dim降到 32,num_layers降到 1,观察验证损失是否还涨。如果还涨,检查标准化是不是滚动窗口。另外可以加weight_decay到 1e-3,并增大 dropout 到 0.5。如果这些都不管用,说明特征本身信噪比太低,需要重新构造。
4.2 现象:回测夏普很高,但一上模拟盘就连续亏损
原因:回测里用了收盘价成交,但实盘你不可能在收盘那一刻知道收盘价并下单。更常见的是,回测没有扣交易成本,而模型信号频繁切换,手续费把利润全吃掉了。
解决:把成交价改成次日开盘价,信号在当天收盘后生成,次日开盘执行。同时把单边成本设成万分之三到万分之五,滑点设成万分之一。如果扣完成本后夏普掉到 0.5 以下,这个策略就不值得上实盘。
4.3 现象:换一只股票,模型输出全是 0 或全是 1
原因:不同股票的价格量级和波动率差异很大,如果你用全样本标准化,小盘股的收益率分布和大盘股完全不同,模型输出会饱和。另外,如果新股票的涨跌停比例很高,方向标签会严重不平衡。
解决:对每只股票单独做滚动标准化,不要跨股票共享均值和方差。如果标签不平衡,用pos_weight给 BCE 损失加权,或者改用 Focal Loss。我一般会先统计训练集里上涨天数占比,如果低于 45% 或高于 55%,就调整阈值而不是硬切 0.5。
4.4 现象:训练时 GPU 利用率很低,一个 epoch 要跑好几分钟
原因:DataLoader 的num_workers设成了 0,或者 batch_size 太小。股票数据本身不大,但如果序列长度设成 60,batch_size 只有 16,GPU 大部分时间在等数据。
解决:把num_workers设成 4 或 8,pin_memory=True,batch_size 提到 64 或 128。如果显存不够,用梯度累积,比如每 4 个 batch 才更新一次参数。另外,序列长度不要盲目设大,日线数据 20 到 30 个时间步通常就够了,再长只会增加计算量而不提升效果。
4.5 现象:模型在测试集上方向准确率 60%,但实盘信号和回测对不上
原因:测试集里的 target 是用shift(-1)生成的,最后一行没有未来数据,但你在实盘推理时,输入的是最新一天的特征,模型输出的概率对应的是“明天涨跌”。如果你在当天收盘前就用这个概率下单,等于用了当天还没结束的数据。
解决:实盘推理必须等到收盘后,用完整的当日数据生成特征,再让模型预测,次日开盘执行。另外,确保推理时的特征计算逻辑和训练时完全一致,包括滚动窗口的长度和标准化参数。我一般会把特征计算和模型推理封装成同一个函数,训练和实盘共用,避免两套代码不一致。
5. 让通用模型真正通用:配置化与多标的批量推理技巧
写到这儿,你应该已经发现,所谓“通用股票预测模型”,通用的不是权重,而是流程。真正能落地的做法,是把数据管道、模型结构、训练参数、回测逻辑全部抽成配置文件,然后用一个主脚本遍历股票池。我一般会用一个 YAML 文件描述每只股票的特征窗口、模型类型、学习率和早停耐心值,主脚本读配置后依次跑训练和回测,最后把结果汇总成一张表。
import yaml def load_config(path): with open(path, 'r', encoding='utf-8') as f: return yaml.safe_load(f) def run_pipeline(config): results = {} for symbol, cfg in config['symbols'].items(): df = load_price_data(symbol, cfg['start'], cfg['end']) features = build_feature_frame(df, window=cfg['window']) # 按时间切分,构建 DataLoader # model = LSTMPredictor(input_dim=features.shape[1] - 1, **cfg['model']) # model = train_model(model, train_loader, val_loader, **cfg['train']) # preds = predict(model, test_loader) # results[symbol] = backtest(preds, test_df['ret']) return pd.DataFrame(results).T配置文件里,window可以按股票波动率调,波动大的设 10,波动小的设 30。model里可以指定type: lstm或type: gru,主脚本根据类型实例化对应类。train里放lr、epochs、patience。这样换一只股票,只需要在 YAML 里加一行,不用改任何 Python 代码。
还有一个技巧:多标的批量推理时,不要把每只股票的数据拼成一个超大 batch,因为不同股票的特征分布不同,BatchNorm 会互相干扰。我一般会按股票分组,每组单独做一次前向传播,最后把预测结果按日期对齐再汇总。如果非要共享模型,至少把 BatchNorm 换成 LayerNorm,或者干脆不用归一化层,靠输入端的滚动标准化来统一量纲。
最后说一个我自己的习惯:每次跑完一批实验,我会把配置文件、随机种子、验证集夏普和最大回撤记到一个 CSV 里,而不是只存模型权重。因为股票预测模型的可复现性极差,同样的代码换个随机种子结果可能差很多。没有这份记录,两周后你根本想不起来哪个配置对应哪个结果。这个习惯帮我省了太多后悔药。希望帮到你。
本文还有配套的精品资源,点击获取