最早接触神经网络做交易时,我以为把价格序列丢进 LSTM 就能自动找到规律。结果模型在训练集上精准得吓人,一到样本外就崩成随机猜测。后来我才意识到,深度学习在算法交易里的最大门槛不是模型本身,而是数据切分、标签设计和特征构造这套看不见的工程细节。这篇文章是我从零开始用 Python 深度学习构建算法交易策略的完整复盘,适合已经会用 pandas 处理行情数据、熟悉 Python 基础,但对“把神经网络接到交易场景里”还没有系统思路的读者。我会把目标设计、数据边界、特征工程、模型训练和回测验证从头到尾捋一遍,也会把那些让我亏过时间的坑单独拎出来说清楚。
1. 传统因子模型为什么不够用:从线性视角到非线性视角
1.1 当线性回归遇上价格数据
做量化交易的人对这套流程应该不陌生:拿收益率当因变量,拿估值、动量、波动率这类因子当自变量,塞进线性回归或者简单决策树里,得到一个方向打分,然后排序选标的。这个思路本身没问题,但它有一个隐含假设:因子和未来收益之间的关系是线性的,或者至少是可加性的。
可是行情数据的真实结构往往不是这样。举个例子,一个动量因子在趋势行情里是正贡献,在震荡行情里可能变成反向指标;波动率因子在低波环境下的预测力很强,高波环境下又会失效。同一个因子,在不同市场状态下对收益的影响方向甚至可能翻转。这种“条件式”的关系,线性模型表达不出来,因为线性模型只能给每个因子配一个固定权重。
我在做传统因子模型时最常遇到的情况是:单因子测试 IC 均值不高,但换一种市场状态分组后,信号效果差异巨大。这说明信息其实就在数据里,只是没有被模型结构捕捉到。这也是我开始转向神经网络的原因——我需要一个能自动挖掘交互效应和非线性结构的工具,而不是把所有关系强行压成线性权重。
1.2 神经网络的“万能近似”在交易里意味着什么
神经网络本质上是一个带大量可学习参数的复合函数。只要层数和神经元数量足够,它可以逼近任意复杂的连续函数。这个性质叫万能近似定理。放在交易场景里理解,就是它有机会自动发现“低波动+强动量+量能配合”这类组合条件对收益的影响,而不需要我把这些交互项手工构造出来。
但这里有一个关键认知:万能近似是数学上的能力,不是实践中的承诺。样本量有限、噪声极大、信噪比极低的行情数据,会让模型很容易学到无关的随机模式,也就是过拟合。我在跑第一批实验时,训练集 AUROC 能到 0.65 以上,看起来很不错,但验证集只有 0.50 上下,标准的随机水平。后来我才明白,神经网络在交易里的价值不在于“无中生有创造 Alpha”,而在于“用够复杂的结构去逼近那些用传统规则无法准确描述的关系”。
1.3 深度学习适合解决的交易问题边界
不是所有交易问题都该用神经网络。如果要做的是类似“均线金叉后上涨概率”这种强规则场景,简单统计就能解决,引入深度学习反而容易因为噪声过拟合。根据我的经验,神经网络更适合下面这几类问题:
- 特征之间关系复杂、存在明显交互效应,但无法手工指定规则;
- 样本量足够大,至少几十万条以上的训练样本;
- 预测目标本身有弱可预测性,而不是完全随机;
- 你愿意投入工程时间处理数据切分和验证设计,而不只是调模型。
反过来,如果数据量很小、行情结构非常单一、或者你只是想快速验证某个规则有效性,直接用经典因子模型或者滚动统计方法会更稳。神经网络不是银弹,它是一个需要完整工程配套的复杂工具。
2. 模型还没写,先定这三件事:目标、数据边界和时间切分
2.1 标签设计:预测收益方向还是预测收益大小
写第一版模型时,很多人第一反应是“预测明天的收盘价”。这个目标我强烈不建议直接用回归去做。价格序列本身是非平稳的,今天的绝对价格和十年前的绝对价格没有可比性,模型要同时学会“市场涨跌”和“绝对价格尺度”,学习负担极大,而且很容易被价格水平带偏。
我更推荐的做法是构造相对标签。最常见的一种,是把未来 N 期收益率作为连续标签做回归;另一种,是把未来 N 期收益率的正负变成二分类标签。选哪种取决于你的策略逻辑:如果你后面要做仓位大小差异化,用回归输出更自然;如果你只打算生成“开多/开空/空仓”的方向信号,二分类更直接。
我给实际项目做标签时会考虑两个细节:第一,预测期限 N 要和你的调仓频率匹配,做日内频繁调仓就别用未来 5 天收益做标签,信号早衰减没了;第二,样本类别不均衡要提前看清楚,市场大部分时候涨幅区间很小,如果强行切成“涨/跌”二分类,两类样本可能比例接近,但收益幅度差异很大,模型会忽略那些幅度大但方向相同的情况。
2.2 时间序列不能随机乱切:分组与滚动窗口
这是我最想强调的一点。很多刚接触深度学习的人默认把数据打成随机样本,训练集、验证集、测试集三切分,这在 CV 任务里没问题,但在金融时间序列里是灾难。
原因很简单:相邻交易日的样本高度相关。比如用 20 天窗口做特征,第 100 天和第 101 天的样本里有 19 天特征重叠。如果随机切分,训练集里可能混进“未来”数据,验证集里也可能出现和训练集几乎重复的样本。模型在验证集上的成绩会虚高,实盘时立刻现形。
我现在的做法是严格按时间顺序切分,并且给样本分组。具体来说:
- 训练集用 2018 年至 2021 年数据;
- 验证集用 2022 年数据;
- 测试集用 2023 年上半年数据;
- 训练集内部再做一次滚动前推验证,而不是单次切分。
这样每一个样本的时间位置都是严格确定的,验证集里的任意一个样本都不可能在训练集里找到“未来的自己”。这个环节看起来简单,但数据泄漏问题有七成以上出在切分逻辑上。
2.3 数据泄漏的高发场景:归一化、未来函数、重采样
除了切分,数据泄漏还会潜伏在三个操作里,我逐个踩过。
第一个是归一化参数。常见的 z-score 归一化需要计算均值和标准差,如果你用全样本的均值和标准差去缩放训练集和测试集,测试集的分布信息就已经被模型看到了。正确做法是只用训练集统计量去归一化验证集和测试集。
第二个是未来函数。构造特征时,如果某个指标用了未来一段时间的价格计算,比如“当前价格相对未来 5 日均值的变化率”,这个特征在回测里会非常“准”,但实盘永远拿不到当天的未来数据。我查代码时发现,很多第三方指标库默认实现里就可能引入未来窗口,尤其是在计算“当日回看 N 期”和“N 期后”这类需求时容易混淆。每写一个特征,都要问自己一句:“当前这个时刻,我真的能拿到这个数吗?”
第三个是重采样。把分钟数据重采样成日线时会有个隐性问题:当天最后一根分钟线和收盘价之间的信息差。如果重采样逻辑把当天完整价格路径都用尽了,某些特征其实隐含了收盘后的信息,实盘策略很难在收盘前完成交易。这个问题在做高频或日内策略时尤其严重,做日线级别虽然影响小一些,但也要心里有数。
3. 技术栈选择:我用什么库、为什么这样选
3.1 Python 环境与核心依赖
我的开发环境是 Python 3.10,配合以下核心库:
| 模块 | 用途 |
|---|---|
| pandas / numpy | 数据处理、滚动特征计算 |
| PyTorch | 神经网络建模与训练 |
| scikit-learn | 分类/回归评估指标、简单预处理 |
| joblib | 模型持久化 |
| 某开源行情接口 | 获取日线、分钟线行情数据 |
很多人会纠结要不要安装 TA-Lib,我的建议是:如果只是做最基础的 MA、RSI、布林带这类指标,用 pandas 的 rolling 方法自己实现就够,还能保证不引入未来函数。等指标复杂到需要大量 C 扩展计算时,再考虑引入专门的指标库。
3.2 选 PyTorch 而不是其他框架的理由
这个选择更多是基于日常维护的考虑。PyTorch 的动态图机制让我在调试序列模型时能随时打印中间变量,对理解 LSTM 每一时刻的状态很有帮助。同时它的 Dataset 和 DataLoader 机制对时间序列样本的采样控制比较方便,我可以自定义按时间顺序取样本,不用担心框架默认打乱。
训练预测模型用的是分类交叉熵,输出层是 2 个神经元,对应未来 N 期收益正负两个方向。这种设计在代码上最直观,也方便和其他模型做对比实验。
layer 1 的 dropout=0.4 是后来调参调出来的。一开始我设 0.2,训练集和验证集差距还是大,提高到 0.4 之后明显缓解。同时我给 LSTM 输出接了一个 BatchNorm 层,它能让梯度在深层网络中更容易传播,收敛速度比不接快不少。hidden_size=32 在四维特征下足够用,不需要一味加大网络复杂度。
4.4 训练过程中的过拟合信号与对策
我第一个 LSTM 模型训练到 15 个 epoch 时,训练集损失下降到 0.28,验证集损失却停在 0.69 附近不再下降。这个 0.41 的 gap 就是典型的过拟合信号。当时我做了三件事:
- 加大 dropout,从 0.2 提到 0.4;
- 把 LSTM 层数从 2 层减回 1 层,减少序列层级堆叠;
- 加入早停,验证集损失连续 5 个 epoch 不改善就停止训练。
改完之后验证集损失下降到 0.58 左右,虽然预测准确率依然不高,但至少训练集和验证集之间的差距缩小了。这个阶段我的判断标准不是“涨了多少”,而是“验证集评估是否稳定复现”。如果验证集结果一会儿好一会儿差,说明样本量不够或者特征噪声太大,需要回到特征工程环节重新思考。
5. 从零写一个方向预测模型:MLP 版本到 LSTM 版本的工程实现
5.1 设计思路:为什么最终选了 LSTM
在序列建模之前,我试过把 20 天的特征摊平成 80 维向量直接喂给 MLP。结果训练起来很慢,而且效果一般。原因很好理解:MLP 把每一天的特征当作完全独立的输入,没有建模时间顺序关系的能力。而行情数据里真正有价值的是“趋势的延续”“动能的衰减”这类顺序性信息。
LSTM(长短期记忆网络)通过门控结构维护一个细胞状态,可以记住几十步之前的有效信息,同时丢掉无关噪声。在预测未来 N 期收益方向这个任务里,它天然适合捕捉“过去一段时间价格如何演变”这类问题。这也是我选择 LSTM 作为系列第一代模型的原因。
5.2 模型定义代码
下面这个是我实际在用的模型结构,输入是 shape 为(batch_size, 20, 40)的张量,20 是时间步长,40 是特征维度。输出是 2 分类 logits。
import torch import torch.nn as nn class DirectionLSTM(nn.Module): def __init__(self, input_size=40, hidden_size=32, num_layers=1, num_classes=2): super(DirectionLSTM, self).__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=0, ) self.bn = nn.BatchNorm1d(hidden_size) self.dropout = nn.Dropout(0.4) self.fc = nn.Linear(hidden_size, num_classes) def forward(self, x): out, (h_n, c_n) = self.lstm(x) # 取最后一个时间步的输出 last_out = out[:, -1, :] last_out = self.bn(last_out) last_out = self.dropout(last_out) logits = self.fc(last_out) return logits这里有一个我反复确认过的细节:batch_first=True让输入维度更直观;取out[:, -1, :]表示把最后一个时间步的隐藏状态取出,代表模型对整段序列的最终解读。如果任务换成预测未来多期收益,我会把这里改成h_n的组合输出,但方向预测用最后一个隐状态已经足够。
5.3 训练循环与早停
训练循环本身不复杂,但有几个点需要处理好:学习率调度、类别权重、早停。
import torch.optim as optim from torch.utils.data import DataLoader, TensorDataset def train_model(model, train_dataset, val_dataset, epochs=30, lr=3e-4): train_loader = DataLoader(train_dataset, batch_size=256, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=1024, shuffle=False) optimizer = optim.Adam(model.parameters(), lr=lr) scheduler = optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode="min", factor=0.5, patience=3 ) class_weight = torch.tensor([1.0, 1.0]) # 如果样本不平衡再调整 criterion = nn.CrossEntropyLoss(weight=class_weight) best_val_loss = float("inf") patience_counter = 0 for epoch in range(epochs): model.train() train_loss = 0.0 for x_batch, y_batch in train_loader: optimizer.zero_grad() logits = model(x_batch) loss = criterion(logits, y_batch) loss.backward()n optimizer.step() train_loss += loss.item() * x_batch.size(0) model.eval() val_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for x_batch, y_batch in val_loader: logits = model(x_batch) loss = criterion(logits, y_batch) val_loss += loss.item() * x_batch.size(0) preds = logits.argmax(dim=1) correct += (preds == y_batch).sum().item() total += y_batch.size(0) val_loss /= total train_loss /= len(train_dataset) val_acc = correct / total print(f"Epoch {epoch+1}: train_loss={train_loss:.4f}, val_loss={val_loss:.4f}, val_acc={val_acc:.4f}") scheduler.step(val_loss) if val_loss < best_val_loss: best_val_loss = val_loss patience_counter = 0 torch.save(model.state_dict(), "best_model.pth") else: patience_counter += 1 if patience_counter >= 5: print("early stopping") break这里shuffle=True只用在训练集,验证集必须保持原始时间顺序。训练时打乱样本能让每个 batch 分布更接近整体分布,加速收敛;验证时如果打乱了,评估结果也不受影响,但从时间一致性角度我习惯保持不变。
5.4 我踩过的训练坑:类别不平衡与学习率过高
第一次训练时我没注意类别权重,结果模型几乎把所有样本都预测成“上涨”。检查后发现,虽然总体涨跌样本比例接近 50:50,但收益幅度大的上涨样本占比更高,模型学到的决策边界偏向更容易降低整体损失的类别。后来我在训练时增加了一个类别权重校准步骤:先按训练集统计两个类别的比例,再把class_weight设为反比,让模型更关注少数类。
学习率方面,Adam 用 3e-4 是我测试下来比较稳的起点。有段时间我图快用了 1e-3,结果 LSTM 的梯度在序列反传时不太稳定,训练损失反复横跳。后来加了 ReduceLROnPlateau,让它每 3 个 epoch 没改善就降低一半学习率,收敛稳定很多。
6. 回测结果好看不等于能赚钱:验证环节最容易踩的坑
6.1 准确率不是交易指标
模型在验证集上准确率 53%,看起来比随机高一点,但能赚钱吗?不一定。交易里最怕的是“预测对的时候赚小钱、预测错的时候亏大钱”。准确率只统计方向预测的对错次数,完全不考虑盈亏幅度。
我见过不少模型,方向准确率有 55%,但错的样本往往是大幅下跌或者大幅上涨的反向,错的幅度大过对的幅度,最终收益曲线还是往下走。所以我在评估模型时还会同时看三组指标:预测正确的平均收益、预测错误的平均收益、以及按预测概率分层的收益单调性。如果高概率预测组的未来收益明显高于低概率组,模型才算真的有信息量。
6.2 前视偏差与幸存者偏差
回测阶段最容易让人产生“我已经稳了”错觉的就是前视偏差。举个例子:如果你用某一天的完整日线数据去生成特征,而模型信号是在收盘后计算的,那你的回测下单时间必须设在收盘价之后,或者第二天开盘。很多人直接把当日信号和当日收盘价对齐,实际上是在用当天的结果“预测”当天,回测收益自然高得离谱。
幸存者偏差则是标的池选择的问题。如果回测时只选了现在还存在的标的,那些已经退市或者长期停牌的标的完全没有进入样本,策略的普适性就被高估了。做全市场选股类策略时,处理退市数据是个非常麻烦但不得不做的事,否则业绩曲线的真实性要打折扣。
6.3 滚动前推回测:我常用的验证方法
我现在的标准流程是滚动前推验证。拿日线数据举例,第一次用 2018-2021 年训练,2022 年验证;第二次把训练集扩到 2022 年,验证 2023 年上半年;之后再扩到 2023 年上半年,验证 2023 年下半年。每滚动一次,就相当于经历一次“未来”。
这样做的好处很明显:模型每换一段新的验证期,都会面对不同波动环境,能看出它对市场状态变化的适应能力。如果模型只在某一段牛市行情里有效,滚动验证会立刻暴露问题。我曾经的模型在 2022 年验证集上还行,但滚到 2023 年却大幅衰减,说明它学习到的规律不稳定。遇到这种情况,我会回到标签设计和特征构造去重新思考,而不是硬调超参。
6.4 成本模型:手续费和滑点会吃掉利润
很多第一次写回测的人都忽略交易成本。觉得买卖一次只有千分之几,但高频换仓下,成本是复利级别的。日线策略一次完整的开平仓,手续费加滑点我一般按双边 0.1% 到 0.2% 估算。如果模型信号每天变化一次,一年 200 多个交易日,成本会非常惊人。
我第一次跑完整回测时,把成本设成 0,收益曲线 45 度向上,人差点飘起来。后来加上 0.15% 的成本后,实际净收益直接腰斩再腰斩。从那以后我养成了习惯:任何回测结果都必须标注“含成本”和“不含成本”两条收益曲线,并且用含成本曲线作为决策依据。
7. 从“能跑”到“能持续跑”的几点个人心得
7.1 模型更新频率与信号衰减
我训练完模型后的第一感受是“它怎么这么快就失效了”。一个模型在验证期结束后,实际部署阶段的效果会随着时间推移逐渐衰退。原因不复杂:市场参与者在变,交易规则在变,宏观环境在变,所有历史规律都会老化。
我的应对方案是固定更新节奏:每周五收盘后用最新数据重新训练一次,同时保留过去几个版本的模型做集成投票,避免单一版本在某一周突然崩溃。序列数据本来就不是平稳的,模型更新频率本身就是策略的一部分,不能当作事后补救。
7.2 组合与仓位管理才是真正的护城河
走到这一步,我最深刻的体会是:单标的的预测模型再准,也不如组合层面的设计重要。神经网络的预测信号只是生成一个“倾向”,真正决定收益曲线形态的是怎么分配仓位、怎么控制回撤、怎么在不同策略之间分散风险。
我现在会把单个模型当作一个信号源,然后同时跑多个不相关信号源,再用风险平价或者波动率目标去分配权重。这样单个模型的失效对整体组合的冲击是有限的。深度学习模型给出的概率,本质上只是一个权重输入,不是决策的全部。
7.3 给同样走这条路的你几个建议
回望整个第一代策略的开发过程,我最想总结的是下面几条:
- 把 70% 的时间花在数据理解、标签设计和验证流程上,模型只占 20%,剩下的 10% 才是调参;
- 任何验证集上的“惊喜”都要先怀疑数据泄漏,而不是庆祝发现了 Alpha;
- 回测结果必须完整覆盖成本、滑点、停牌、退市这些现实因素,否则没有参考价值;
- 模型越复杂,样本量要求越高,先跑通简单模型建立基线,再逐步增加复杂度;
- 靠单一模型很难持续赚钱,构建多信号组合才是更稳健的方向。
这个系列的第一篇到这里,核心是把“用深度学习做交易”这条路的完整工程框架盘清楚。后面我会继续展开特征工程的更多坑、模型集成的方法,以及实盘部署中遇到的那些教科书里不会写的细节。每一步我都踩过泥,写出来希望你能走得快一点。