ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

深度学习股票量化全流程:数据、模型、回测与实战避坑

深度学习股票量化全流程:数据、模型、回测与实战避坑 简介这是一份基于深度学习的股票量化交易完整工程包面向高校学生完成毕业设计、课程设计或期末大作业也可供金融科技方向初学者系统参考。项目采用模块化结构围绕数据加载、模型构建、策略回测与运行监控组织代码涵盖DLinear、NLinear、LightGBM、CatBoost、TFT、TSMixer等多种模型并提供jupyter notebook示例与模型部署相关脚本有助于理解从原始行情数据到交易信号的全流程。压缩包共49个文件以42个Python脚本为主辅以2个notebook、配置文件及启动监控脚本整体大小约2.93MB体积轻量且结构清晰。目前已有87人学习下载。借助完整目录与测试用例使用者可快速上手改写模型参数或接入自有数据既能巩固深度学习与量化交易知识也能提升工程实现与调试能力。1. 预测股价只是入场券这份 zip 在解决什么、适合谁把“基于深度学习的股票量化交易”打包成 zip 交付听起来像是一份能直接算出买卖点的圣杯脚本但实际解开后你会发现真正值钱的不是那个预测模型而是从行情数据到可执行订单之间那条完整、不出错的链路。我见过太多人在这一步翻车模型在训练集上表现惊人一上实盘就连续打脸。问题几乎从来不在深度学习本身而在数据对齐、标签生成、信号映射和成本损耗这些看起来不起眼的环节。这份方案适合三类人刚入门的量化研究者想用深度学习替换传统均线策略做毕设的学生需要一条从数据处理到回测的完整路线已经在用传统因子模型、想引入神经网络做非线性增强的实战者。它的本质不是“预测得准”而是“预测—执行—回测—验证”这条链路里每一步都可复现、可追溯、可质疑。2. 先把数据做扎实目标标签、特征窗口与前视偏差2.1 先说清楚预测目标分类涨跌比回归收益率更稳很多人拿到行情数据就急着写模型第一步就错了。深度学习在股票日线数据上的信号非常弱如果你用回归去预测未来几天的具体收益率模型很容易把所有预测值收敛到均值附近因为涨跌幅分布近似正态、尖峰厚尾极端样本占比很低。这样训练出来的模型“永远看平”策略完全没法用。我一般会用分类目标。最常见的做法是预测 t1 日的涨跌方向但这里有个容易忽略的细节直接以“是否上涨”做标签正负样本接近各半模型学到的其实是一个近似随机的分类面。更稳妥的做法是设一个阈值比如未来 5 日收益率是否大于 0.5%或者 t1 日收益是否超过当日振幅的中位数。抬高门槛后正样本变成“显著上涨”模型才真正有东西可学。标签类型定义优点常见坑二分类涨/跌t1 收盘 t 收盘实现简单样本均衡模型趋向随机预测二分类阈值t1 收益 0.5%信号更集中阈值需按品种波动率调整三分类涨 / 平 / 跌过滤震荡期类别边界模糊评估复杂回归未来 n 日收益率可排序预测值趋均值直接下单很难做分类有一个额外好处评估指标更直观。准确率、F1、AUC 都能直接量化模型能力回测时的信号生成也更方便。如果你坚持做回归我建议不要直接预测收益率而是预测收益率的排名或者标准化后的分位数这样能减少极端样本的影响。2.2 数据加载与标签生成一个可复用的最小代码数据加载这一步核心是处理三个问题复权、停牌、时间索引。我习惯用前复权价格因为后复权价格在历史回看时数值会越来越大滚动特征会被价格水平主导不复权价格则会在除权日出现断崖式跳空模型会把除权误判成暴跌。停牌数据必须过滤否则连续的 0 成交会给模型注入“这个股票不会动”的伪规律。import pandas as pd import numpy as np # 读取本地行情csv注意gbk编码很多行情软件导出的都是gbk df pd.read_csv(stock_daily.csv, encodinggbk) df[trade_date] pd.to_datetime(df[trade_date]) df df.sort_values(trade_date).reset_index(dropTrue) # 过滤停牌和涨跌停异常量能为0或价格缺失的直接剔除 df df[df[volume] 0].copy() df df.dropna(subset[open, high, low, close, volume]) # 用前复权因子调整收盘价这里假设数据里自带adj_factor df[close_adj] df[close] * df[adj_factor] # 生成t1收益作为标签阈值取0.5% df[ret_next] df[close_adj].shift(-1) / df[close_adj] - 1 df[label] (df[ret_next] 0.005).astype(int) # 删除最后一行因为它没有未来收益 df df.iloc[:-1].reset_index(dropTrue) print(df[[trade_date, close_adj, ret_next, label]].tail())代码逻辑先按日期升序排序这是时序任务的红线任何乱序都会让模型偷看到未来。shift(-1)把下一日的收益率放到今天的行上标签label标识 t1 收益是否超过 0.5%。最后删掉最后一行因为它无法计算未来收益。参数说明0.5% 这个阈值不是万能的。对银行股日涨幅超过 0.5% 已经是罕见事件对题材股0.5% 几乎家常便饭。我一般会在训练前统计样本正负比把阈值调到正样本占比 25%40% 之间。阈值太低模型学不到东西太高则正样本太少、训练不稳定。2.3 特征窗口与滚动标准化前视偏差最容易藏在这里股票特征我习惯分成三类价格与收益类收盘价、收益率、振幅、量能类成交量变化率、换手率、技术指标类MA、RSI、ATR 偏离度。技术指标的计算要格外小心很多内置指标库默认用全量数据计算相当于用了未来的统计量。我一般会自己实现用 rolling 窗口手动构造。# 用rolling手工构造特征避免引入未来函数 df[ret_1d] df[close_adj].pct_change(1) df[ret_5d] df[close_adj].pct_change(5) df[vol_ratio] df[volume] / df[volume].rolling(20).mean() df[ma_20] df[close_adj].rolling(20).mean() df[ma_bias] df[close_adj] / df[ma_20] - 1 # 滚动标准化用过去60天的均值和标准差而不是全样本 df[close_zscore] ( df[close_adj] - df[close_adj].rolling(60).mean() ) / df[close_adj].rolling(60).std() # 构造特征矩阵 feature_cols [ret_1d, ret_5d, vol_ratio, ma_bias, close_zscore] df[seq_id] range(len(df)) # 删除早期没有足够窗口的行 df df.dropna(subsetfeature_cols).reset_index(dropTrue) print(df[feature_cols].describe())这段代码里最容易忽略的是rolling(60).mean()的时序含义它只使用当前时刻之前 60 天的数据不掺杂任何未来信息。很多新手用df[close_adj].mean()或者 sklearn 的StandardScaler对整个序列做标准化这等于把未来价格的平均值放进了训练特征回测时模型等于开卷考试。参数说明滚动窗口 60 天是我在日线数据上的默认值。窗口太短比如 10 天特征波动剧烈模型学到的全是短周期噪声窗口太长比如 250 天又会让特征对大趋势过度敏感对小级别拐点失去反应。对分钟级数据窗口设置逻辑完全不同需要按数据频率重新折算。3. 模型选型与参数设置LSTM 还是 Transformer以及我怎么调3.1 LSTM 最小可跑模型结构、参数与训练配置LSTM 做股票时序预测是深度学习量化里最常见的方案之一。对日线数据我一般不用超过两层的 LSTM因为股票数据的信噪比极低网络越深、记住的训练噪声越多。输入特征维度通常在 510 个之间序列长度给 2040 个交易日比较合理太长反而会引入久远的市场状态干扰对当前趋势的判断。import torch.nn as nn class LSTMModel(nn.Module): def __init__(self, input_size5, 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.classifier nn.Sequential( nn.Linear(hidden_size, 32), nn.ReLU(), nn.Dropout(0.3), nn.Linear(32, 1), ) def forward(self, x): # x shape: (batch, seq_len, input_size) out, _ self.lstm(x) # 取最后一个时间步的隐状态 last out[:, -1, :] logit self.classifier(last) return logit.squeeze(-1)这里batch_firstTrue是高频踩坑点。PyTorch 的nn.LSTM默认输入维度是(seq_len, batch, input_size)如果你习惯用(batch, seq_len, feature_dim)组装数据忘记batch_firstTrue会在训练时疯狂报Expected input batch_size之类的错。out[:, -1, :]表示取最后一个时间步的输出这在预测 t1 收益时是标准做法因为你希望模型根据整个窗口的最终状态来做判断。训练配置我一般用BCEWithLogitsLoss配合Sigmoid输出而不是在模型里直接加 Sigmoid 再配BCELoss。前者在数值上更稳定梯度表现更好。优化器用 AdamW 或 Adam学习率 1e-3配合 CosineAnnealing 或 ReduceLROnPlateau。早期停止的 patience 设 810 个 epoch避免模型在 50 个 epoch 之后陷入过拟合。3.2 Transformer 做分类浅层配置与过拟合控制Transformer 在长序列上表现确实好但股票日线数据通常只有 2040 个时间步序列长度不足以发挥它的优势反而更容易过拟合。如果你还是要用 Transformer我建议把它当作“带注意力的 LSTM”来理解而不是当作大模型来堆层。我的做法是d_model64、nhead4、num_layers2、dropout0.2。import torch import torch.nn as nn import math class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len100): super().__init__() pe torch.zeros(max_len, d_model) position torch.arange(0, max_len, dtypetorch.float).unsqueeze(1) div_term torch.exp(torch.arange(0, d_model, 2).float() * -(math.log(10000.0) / d_model)) pe[:, 0::2] torch.sin(position * div_term) pe[:, 1::2] torch.cos(position * div_term) self.register_buffer(pe, pe) def forward(self, x): return x self.pe[: x.size(1)] class TransformerClassifier(nn.Module): def __init__(self, input_size5, d_model64, nhead4, num_layers2, dropout0.2): super().__init__() self.input_proj nn.Linear(input_size, d_model) self.pos_enc PositionalEncoding(d_model) encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, dim_feedforward128, dropoutdropout, batch_firstTrue ) self.encoder nn.TransformerEncoder(encoder_layer, num_layersnum_layers) self.classifier nn.Sequential(nn.Linear(d_model, 32), nn.ReLU(), nn.Linear(32, 1)) def forward(self, x): x self.input_proj(x) x self.pos_enc(x) x self.encoder(x) logit self.classifier(x[:, -1, :]) return logit.squeeze(-1)Transformer 做股票分类有一个典型症状训练 loss 下降很快验证集准确率却纹丝不动。原因是注意力机制能够在训练集上记住大量噪声模式。解决手段是强制正则dropout提到 0.3 以上、dim_feedforward不要设太大、减少 encoder 层数。我从不建议在此类任务上使用超过 4 层的 TransformerEncoder。参数说明nhead4是给d_model64用的每个 head 分到 16 维能保留足够的表示能力。如果你把nhead提到 8而d_model仍然是 64每个头只有 8 维注意力分布会变得很碎反而学不到有意义的依赖关系。3.3 训练参数速查表学习率、批次、早停与随机种子下面这组参数是我在日线级别的股票分类任务上比较稳定的起点可以直接照抄但不要盲目当成最优。验证集 loss 连续 8 个 epoch 不降时我会把学习率降一半再继续训练如果仍然不降就视为训练结束。参数推荐值调整方向序列长度 seq_len30数据波动大就减小趋势性强就加大隐藏维度 hidden_size64特征维度高时适当加大到 128LSTM 层数23 层以上在日线数据几乎必过拟合Dropout0.3验证集与训练集差距大就加学习率1e-3训练震荡就降到 3e-4Batch Size64样本少就减到 32Epoch50配合 Early Stopping不必跑满Early Stopping Patience8~10信号越弱越要耐心每次训练前固定随机种子这个动作容易被忽略但非常重要。PyTorch 在 GPU 上的卷积和 LSTM 计算存在非确定性不固定种子两次训练结果可能会有明显差异你无法判断一个改动到底是真的有效还是只是随机噪声。我一般在代码开头固定torch.manual_seed(42)、np.random.seed(42)并在加载数据时把DataLoader的shuffleFalse确保时间顺序不被搅乱。提示量化模型的训练集 loss 不应该被压到接近 0。如果你的训练准确率超过了 95%几乎可以断定模型在背数据而不是在学习模式样本外表现大概率崩盘。4. 把模型输出变成订单信号映射、回测与交易成本4.1 模型概率如何变成交易信号分位数阈值法模型训练完之后你拿到的是每个交易日一个 01 之间的概率。很多新手直接以 0.5 作为阈值大于 0.5 就买入小于 0.5 就卖出。但在弱信号任务里模型输出的概率通常集中在 0.40.6 之间直接卡 0.5 会产生大量噪声信号交易频率高得离谱手续费就能把手续费吃穿。我一般用验证集上的预测概率分布来确定阈值而不是拍脑袋定 0.5。最稳定的做法是把验证集的预测概率排序取前 25% 或前 10% 分位作为做多信号的触发条件。import numpy as np # val_prob是验证集上模型输出的概率val_label是真实标签 val_prob np.array([0.48, 0.52, 0.55, 0.61, 0.47, 0.58, 0.63, 0.44, 0.51, 0.59]) val_label np.array([0, 1, 1, 1, 0, 1, 1, 0, 0, 1]) # 取概率最高的30%作为正信号阈值 threshold np.quantile(val_prob, 0.7) print(f信号阈值: {threshold:.4f}) # 生成信号高于阈值为1反之为0 signal (val_prob threshold).astype(int) print(信号序列:, signal) # 评估该阈值下的准确率和信号占比 acc np.mean(signal val_label) signal_ratio signal.mean() print(f准确率: {acc:.2f}, 信号占比: {signal_ratio:.2f})这段代码的核心思路是放弃绝对概率改用相对排名。股票预测模型输出的概率绝对值受市场环境影响很大牛市里 0.6 可能只是普普通通熊市里 0.55 可能是极强信号。分位数阈值让策略永远只做最自信的那部分预测把交易频率降下来把单笔信号的质量提上去。参数说明选择前 30% 还是前 10%取决于你对交易频率的容忍度。日线级别我建议信号占比控制在 20%35% 之间太低了样本太少、回测不稳定太高了换手率过高、成本太贵。另外记得这个阈值要在验证集上确定然后固定下来去测试集上验证不能在测试集上重新调优否则又是变相过拟合。4.2 最小向量化回测验证策略逻辑别先急着上事件引擎回测分两种。一种是向量化回测直接对信号序列做批量计算速度极快适合验证策略逻辑本身是否正确另一种是事件驱动回测逐笔撮合、模拟订单和滑点更接近真实但实现复杂。我建议先做向量化回测把策略逻辑跑通再根据需求引入事件引擎。# signal已经按交易日排序1表示持仓/买入 # close_adj是前复权收盘价 signal pd.Series(signal, indexdf.index) close df[close_adj] # 核心对齐信号的执行必须后移一天避免用当日信号当日成交 position signal.shift(1).fillna(0) # 日收益率 持仓状态 * 当日涨跌幅 daily_ret position * close.pct_change().fillna(0) # 累计收益曲线 cum_ret (1 daily_ret).cumprod() # 计算年化、最大回撤、夏普 years len(df) / 252 annual_ret cum_ret.iloc[-1] ** (1 / years) - 1 drawdown cum_ret / cum_ret.cummax() - 1 max_dd drawdown.min() sharpe daily_ret.mean() / daily_ret.std() * np.sqrt(252) print(f年化收益: {annual_ret:.2%}, 最大回撤: {max_dd:.2%}, 夏普: {sharpe:.2f})这段回测里有三个关键细节。第一signal.shift(1)是量化回测的命根子你在 t 日收盘后产生的信号最快只能在 t1 日开盘成交如果不 shift相当于用当天收盘价买入、当天收盘价卖出赚的是自己骗自己的钱。第二position直接乘pct_change()得到当日收益这里隐含假设是“持仓期间每日收益全部捕获”没有考虑买入当天的滑点和卖出当天的冲击这需要后续用成本模型修正。第三夏普比率用了sqrt(252)这是 A 股日线数据的年化惯例如果你用的是周线或分钟线年化系数要相应调整。注意向量化回测只是“逻辑正确性验证”不是“收益真实性验证”。我在实际工作中用向量化回测筛掉不靠谱的策略 Idea然后再用事件驱动回测做精排。4.3 手续费、滑点与涨跌停三层损耗过滤不可少很多深度学习量化方案死在了手续费和滑点上。A 股最常见的费率结构是佣金单边万 2.5万 3卖出时加印花税千 12023 年后减半征收再叠加滑点。滑点我习惯按保守的千 1 计算因为深度学习模型往往在波动放大的日子发信号这些日子滑点比平时更大。成本项单边费率双边合计佣金万2.50.000250.0005印花税卖出千0.50.00050.0005滑点保守千10.0010.002合计0.001750.003双边总成本大约 0.3%。也就是说如果你做日线级别策略平均持仓不到 3 天就换仓一年 60 次换仓光成本就吃掉 18%。只有当模型的单次平均收益超过 0.3% 并且胜率足够高时这个策略才有实际价值。我评估策略的第一道工序不是看夏普而是看“换手率”年换手率超过 20 倍的策略不管模型多聪明大概率在实盘赚不到钱。涨跌停过滤是另一个容易忽略的坑。模型在训练时见过大量涨停、跌停样本但在实盘中你无法在涨停板上买入、也无法在跌停板上卖出。回测时如果不检查信号当日的涨跌停状态会出现“信号生成于涨停当日、次日继续涨停”的完美幻觉。我一般在信号生成后加一道过滤当日涨停的不允许买入当日跌停的不允许卖出。代码上就是在回测前把涨停日的position强制置为前值或 0。5. 避坑过拟合、数据穿越与 zip 包里的幺蛾子5.1 回测年化漂亮实盘却跑不动现象训练集准确率 90% 以上回测年化收益率超过 50%最大回撤不到 5%看起来是个完美策略。实盘小资金跑了两个月收益不但没跟上回测还连续亏损。原因这类情况八成是过拟合 特征穿越。特征里用了全样本标准化、或者标签生成时混入了未来数据。还有一个隐蔽原因是幸存者偏差测试集里剔除了退市股票实盘持仓则会遇到退市风险。解决把所有特征计算严格限定为滚动窗口训练集、验证集、测试集按时间顺序切分测试集绝不参与任何调参。另外检查回测收益最大的几天是不是都集中在某一次极端行情里如果是这个策略的收益结构非常脆弱实盘很难复制。5.2 收盘信号次日开盘成交收益却对不上账现象回测每天产生信号信号当天看起来利润丰厚把信号和成交价逐笔核对发现回测收益明显高于按真实开盘价计算的结果。原因信号序列没有做shift(1)。你在 t 日收盘后算出概率并生成信号但 t 日的收盘价已经被模型看到了如果回测用 t 日收盘价成交模型等于用自己的输出喂自己凭空创造收益。解决严格执行信号后移。用代码检查一下你的position序列和收益序列是否对齐# 检查对齐信号在第t天产生持仓应从第t1天开始 misaligned (position.diff() ! 0) (signal ! position) print(f对齐异常数量: {misaligned.sum()}) # 修正方式position signal.shift(1) position signal.shift(1).fillna(0)这个检查能帮你快速定位“收益是真实可获得的还是自欺欺人的”。我在每次回测跑完后都会强制打印这个异常数量只要不为 0就说明代码有 bug回测结果一律作废。5.3 特征在样本内外表现完全不同现象训练数据里ma_bias和close_zscore的分布正常测试集上这些特征出现大量极端值模型预测概率抖动剧烈信号频繁开仓、频繁止损。原因滚动窗口太短导致特征不稳定。比如用 10 天滚动窗口计算 Z-score遇到一段急涨行情Z-score 会冲到 3 以上但训练集中可能从未出现过连续 10 天急涨模型对这样的极端输入没有经验只能随机预测。另一个原因是除权除息如果价格没有复权每次分红送股都会把特征打出异常跳点。解决将滚动窗口下限提高到至少 60 天并统一使用前复权数据。在实盘部署前对特征的实时值做监控当 z-score 超过训练集最大值的 2 倍时暂停策略或强制清仓等分布回归正常再恢复。5.4 解压 zip 后 csv 读出来是一堆乱码现象从网上下载的股票数据或项目 zip解压后 csv 文件用 pandas 打开中文列名全是乱码数字列倒是正常。有时候用zipfile读取还会直接报RuntimeError: File xxx is encrypted但你根本没设置过密码。原因Windows 环境下很多行情软件导出的 csv 默认是 GBK 编码pandasread_csv默认 UTF-8自然乱码。而 zip 文件报“加密”的提示多数情况是 zip 伪加密——文件确实没有密码但压缩时设置了加密标志位Python 的zipfile模块看到标志位就误以为文件被加密直接拒绝读取。解决读 csv 时显式指定编码遇到 zip 伪加密先检查文件的通用标志位再用 7-Zip 重新压缩一次去掉伪加密标志import zipfile with zipfile.ZipFile(data.zip, r) as zf: # 如果抛异常说明zip伪加密或真加密 for info in zf.infolist(): flag info.flag_bits 0x1 # bit 0为加密标志位 if flag and info.CRC is not None: print(f{info.filename}: 疑似伪加密flag0x{info.flag_bits:04x}) # 实际读取时优先尝试gbk编码 df pd.read_csv(stock_daily.csv, encodinggbk)如果你确实下载了加密 zip 又忘记了密码没有任何“后悔药”可吃。真加密的 zip 几乎无法暴力破解除非密码极短。更实际的做法是向数据源重新获取文件或者用 7-Zip 查看压缩方式是否为 ZipCryptoZipCrypto 在某些情况下可以通过已知明文攻击恢复内容但工程上成本太高不如直接找原始数据。5.5 信号太频繁手续费把收益吃穿现象策略回测日志显示平均持仓只有 1.5 天每月交易 20 次虽然胜率有 55%但账户净值曲线是缓慢上升后突然跳水整体收益接近零。原因深度学习模型输出的概率每天都在波动分位数阈值切得太低比如前 10%导致模型频繁产生新信号换手率高企。单笔交易平均盈利只有 0.2%扣除双边成本 0.3% 之后已经是负期望。解决在信号层增加最小持仓周期比如信号给出后至少持有 3 天再允许换仓。另一个做法是对模型输出做平滑用过去 3 天预测概率的移动平均值作为信号依据降低单日极端预测带来的抖动。这个处理不算复杂但能把换手率降下来一半以上。6. 从回测到下场Walk-Forward 验证与实盘前的最后一关6.1 Walk-Forward 验证比单次回测可信得多单次回测即使做得再规范也只是对一段历史的一次性验证策略可能刚好碰上了适合它的行情。Walk-Forward 的意思是把数据按时间分成多段每次用前一段训练、后一段验证然后窗口整体向前滚动反复多次。这样得到的样本外表现比单次回测可靠得多也更接近实盘“不断用新数据重新训练”的真实流程。def walk_forward(df, train_days1000, val_days250, step250): results [] start 0 while start train_days val_days len(df): train_end start train_days val_end train_end val_days train df.iloc[start:train_end] val df.iloc[train_end:val_end] # 这里替换成你的模型训练与预测流程 model train_model(train) prob model.predict(val) results.append(evaluate(prob, val[label])) start step return results每次滚动验证之间要留下一小段间隔避免训练集末尾的样本与验证集开头太近这在学术上叫 embargo。日线数据我一般留 510 天。如果连续几段窗口的验证结果差异很大说明策略很不稳定需要回到特征工程去看是不是遗漏了市场状态切换变量。6.2 实盘小仓位测试的三个习惯Walk-Forward 过了之后我还会做一件事用模拟盘和小仓位实盘并行跑两周每天记录模型预测概率、实际成交价格和回测假设价格之间的差距。这个差距比任何技术指标都重要它真实反映了滑点、延迟和流动性对策略的冲击。两周后如果资金曲线和回测对不上我会优先怀疑数据对齐其次是成本模型偏乐观。我自己踩过最大的坑是模型在样本内无所不能、样本外连买点都对不上。后来养成一个笨习惯每次跑完回测先问自己一句“这个收益是靠哪几天赚的”把收益最大的前 5 个交易日去掉再看策略是否还成立。大多数深度学习策略经不起这层剥离那些靠单日极端行情撑起来的年化实盘里你根本不敢重仓去接。希望这些工程上的细节能帮你在量化这个方向上少走一段弯路。本文还有配套的精品资源点击获取
返回列表