简介:量化交易的核心在于从数据到策略的完整闭环,而深度学习模型的引入为时序预测提供了强大工具。LSTM擅长捕捉长期趋势,GRU对短期波动更敏感,两者混合可互补长短周期特征,提升预测稳定性。FinRL作为PyTorch生态下的强化学习框架,封装了环境构建、代理训练与回测流程,大幅降低工程成本。在股票价格预测、投资组合管理等场景中,通过合理的数据预处理、特征工程与回测验证,量化策略能有效控制回撤并获取稳健超额收益。本文从模型设计到FinRL接入,完整呈现了LSTM-GRU混合网络在量化策略中的实践路径,为深度强化学习应用提供参考。 量化交易这几年被说得玄乎,但真正动手写策略的人都知道,最花时间的往往不是模型设计,而是把数据、特征、训练、回测这一整条流水线从零垒起来。我自己前两年做配对交易和趋势策略时,大部分精力都耗在自建回测引擎、对齐K线时间戳、处理复权因子上,模型反而成了最"省事"的部分。最近我把一套LSTM单模型的收盘价预测策略彻底重构,换成了LSTM-GRU混合网络,并接入了PyTorch生态下的FinRL框架做完整的策略回测。这一趟走下来最大的感受是:框架帮我把工程化的脏活累活接走了,而混合网络在捕捉不同时间尺度特征上的确比单模型更稳。这篇就把整个实现过程、参数细节和踩过的坑从头捋一遍,给想用深度强化学习做量化策略的同学当参考。
1. 为什么是FinRL + LSTM-GRU混合网络:这组合解决了什么实际问题
1.1 自带回测闭环的框架,省掉的不只是时间
先聊选型。做量化策略,传统流程是数据工程师取数、因子工程师算因子、研究员建模、回测工程师写事件驱动引擎,四个环节之间但凡对接不齐,出来的结果就全是噪音。FinRL这个框架好就好在它把"环境-代理-回测"整个闭环封装起来了。环境帮你处理数据切分、步进、奖励计算,代理层帮你管理训练循环,回测模块直接生成净值曲线和绩效指标。你不用再自己维护一个撮合引擎来处理买卖点差、滑点和手续费,这套东西在金融场景里做对非常费力,出错了还很隐蔽。
PyTorch在这里的位置是底层深度学习引擎。选它不选TensorFlow,没有高下之分,纯粹是生态问题。PyTorch的动态计算图在调试混合网络时太友好了——你可以随时print中间张量的shape,可以把某一层单独拎出来验证维度。量化场景里模型规模通常不大,不需要大规模分布式训练,PyTorch的灵活性和调试体验就变得格外值钱。
1.2 单独用LSTM或GRU,到底差在哪
我最早用的LSTM单模型。LSTM的强项是长期依赖建模,门控机制保留了跨时间步的信息通路,特别适合捕捉价格序列里的慢变量,比如一个持续多日的上升趋势。但它的问题也明显:参数多、训练慢,而且对短周期的突变信号响应偏钝。GRU是LSTM的轻量变体,把遗忘门和输入门合并成更新门,参数少了四分之一,训练更快,对小规模样本更友好,对局部波动更敏感。
关键是,金融时间序列里慢趋势和快波动是同时存在的。日线级别上,既有30天均线级别的趋势,也有3天级别的回调脉冲。如果你只用LSTM,短周期信号容易被长周期主导;只用GRU,长期记忆又容易被冲淡。混合网络的价值就在这——两个结构互补,而不是无脑堆层。
那具体怎么混合?我采用的是堆叠结构:LSTM层作为第一层先处理原始序列,提炼出带长期记忆的隐状态序列,再喂给GRU层做二次特征压缩。这种设计的直觉是:先让LSTM做一遍全序列的"通读",把长期依赖关系确认下来,再由GRU对关键拐点做快速响应。实践下来,比单模型在验证集上的F1和回测夏普都更均衡。
2. LSTM-GRU混合网络结构设计与PyTorch实现细节
2.1 网络结构:从输入张量到交易信号
模型输入是过去N个交易日的特征矩阵,形状为(N, feature_dim)。这里的N我取的是30,代表一个月左右的交易日。特征列除了OHLCV之外,我还加了RSI、MACD、布林带位置这三个技术指标,具体原因放到数据那一节说。
第一层是LSTM,hidden_size设为64,bidirectional暂时关掉。金融时序数据因果性很重要,如果用双向LSTM,当前时刻的隐状态会"看到"未来数据,这在训练阶段会引入未来函数,回测成绩虚高,实盘直接崩。第二层是GRU,hidden_size设为32,再接一个全连接层,输出维度根据交易动作空间设定。我在FinRL里用的动作空间是连续仓位比例,从-1到1,负值代表做空,所以输出层是单神经元加tanh激活。
dropout放在两个循环层之间,比率0.2。这个很关键,循环网络本来就容易过拟合,金融数据的信噪比又低,不加dropout的话训练集loss能降到极低,验证集直接起飞。
2.2 PyTorch实现:一份可以直接跑的混合网络定义
import torch import torch.nn as nn class LSTMGRUPolicy(nn.Module): def __init__(self, input_dim, hidden_lstm=64, hidden_gru=32, output_dim=1, dropout=0.2): super(LSTMGRUPolicy, self).__init__() self.lstm = nn.LSTM(input_dim, hidden_lstm, batch_first=True, bidirectional=False) self.gru = nn.GRU(hidden_lstm, hidden_gru, batch_first=True) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(hidden_gru, output_dim) def forward(self, x): # x shape: (batch, seq_len, input_dim) lstm_out, _ = self.lstm(x) lstm_out = self.dropout(lstm_out) gru_out, _ = self.gru(lstm_out) gru_out = gru_out[:, -1, :] # 取最后一个时间步 action = torch.tanh(self.fc(gru_out)) return action注意两个细节。第一,batch_first=True要统一,否则张量维度的坐标系会乱。第二,取GRU输出时用[:, -1, :]取最后一个时间步,这个时间步的隐状态包含了整个序列的信息,是标准的做法。我在早期版本里取过所有时间步的平均池化,回测结果反而不如最后一个时间步稳定,原因是平均池化会把早期无关信息重新引入,稀释了末端特征的权重。
2.3 模型的"为什么这样设计":参数量的账要算清楚
有人会问,为什么不三明治式地加更多层?我也试过LSTM-GRU-LSTM三层结构,验证集损失确实降了点,但回测曲线的最大回撤反而扩大了。原因是模型容量大了之后,它开始去拟合训练集里那些噪声尖峰,而这些尖峰在测试集里不会重现。金融数据本质上不是传统的IID数据,信噪比极低,模型的泛化能力远比拟合能力重要。这套参数配置下模型参数量大概在60K左右,在同类型任务里算是轻量级的,训练一轮很快,也方便反复实验。
3. 数据预处理与特征工程:回测效果八成分在进模型之前
3.1 取数之后第一件事:对齐、去极值与标准化
我用yfinance拉取了某只ETF近五年的日线数据,时间跨度覆盖了完整的牛熊区间。这里要提醒一句,yfinance拉下来的数据经常有停牌日缺失,直接用会干扰时间步对齐。我的处理方式是:拿到数据后先按交易日历重采样,缺失值用前向填充,实在补不上的交易日就删掉整行。
标准化环节有个特别容易踩的坑:不能用全样本均值和方差做归一化,否则测试集信息会通过统计量泄露到训练过程,这叫look-ahead bias。正确做法是只在训练集上计算均值和方差,然后用同一组参数去归一化验证集和测试集。sklearn的StandardScaler支持fit_transform和transform分离,就是干这个用的。
3.2 技术指标怎么选:少即是多,别把噪声喂进模型
特征工程这块我建议克制。我见过有人一口气堆了二十几个指标,结果模型训出来的效果和随机差不多。原因是很多指标高度相关,比如RSI和KDJ在震荡行情里几乎同源,MACD和均线乖离率也高度重叠。特征冗余对线性模型还好,对LSTM这种非线性模型反而是灾难,它会把相关性当作信号去放大。
我最终保留的特征是:
| 特征类型 | 具体字段 | 计算说明 |
|---|---|---|
| 原始价格 | Open, High, Low, Close | 不做平滑,保留原始信息 |
| 成交量 | Volume | 取对数变换,压缩量纲 |
| 动量指标 | RSI(14) | 14日相对强弱 |
| 趋势指标 | MACD(12, 26, 9) | 快慢线差值与信号线 |
| 波动指标 | 布林带位置(20, 2) | (Close - 中轨) / (2 * 标准差) |
窗口长度N取30,每次滑动一步。做序列滑窗时,生成样本的代码里记得用drop_last=True的逻辑,防止窗口越界产生半截样本。
3.3 训练集和测试集切分的时间陷阱
时间序列数据不能随机切分。随机打乱等于让模型"偷看"未来,这是新手的重灾区。我用的是按时间顺序硬切:前70%做训练,中间15%做验证,最后15%做测试。验证集的作用是调参和早停判断,测试集只允许在最终评估时碰一次。
切分之后还有一个细节:训练集内部可以做滚动窗口增强。比如把训练集分成多段,每段独立训练再集成,或者在不同时间段上做K折验证。我实践下来,简单的3折滚动验证已经能显著提升策略对样本外数据的稳健性,代价只是训练时间多了一点。
4. 在FinRL中接入自定义PyTorch模型并进行策略训练
4.1 FinRL训练管线拆解
FinRL的运行逻辑分四步:数据整理成股市环境、定义交易代理、训练模型、回测评估。核心是StockTradingEnv,它把市场状态、交易动作、奖励函数封装成一个OpenAI Gym环境。我用的是FinRL 2.0版本,底层基于Gymnasium接口。
环境的关键参数如下:
from finrl.meta.env_stock_trading.env_stocktrading import StockTradingEnv env_kwargs = { "hmax": 100, # 单次最大交易数量 "initial_amount": 100000, # 初始资金 "transaction_cost_pct": 0.001, # 万十手续费+滑点 "state_space": feature_dim, # 状态空间维度 "action_space": 1, # 连续仓位控制 "tech_indicator_list": feature_columns, "reward_scaling": 1e-4, # 奖励缩放 }reward_scaling我特意调小了一个量级。原因是这个环境的奖励函数默认是按账户净值变化率计算的,数值通常在0.01这个量级,如果不缩放,策略网络会为了追求那个小数值而过度交易,手续费把收益吃到只剩零头。
4.2 包装自定义模型为FinRL可用的Agent
FinRL有一套自己的PPO实现,要接自定义网络,需要继承它的Agent类并覆盖模型初始化部分。具体来说,我是在Policy类里替换掉了默认的全连接网络,换成了上面那个LSTM-GRU混合网络,同时保留PPO的算法逻辑不动。
from finrl.agents.stablebaseline3.models import ActorCriticPolicy class LSTMGRUActorCritic(ActorCriticPolicy): def __init__(self, observation_space, action_space, lr_schedule, *args, **kwargs): super().__init__(observation_space, action_space, lr_schedule, *args, **kwargs) self.net = LSTMGRUPolicy(input_dim=feature_dim)这里有个必须绕过去的坑:FinRL默认把状态当作2D矩阵处理,而LSTM期望的输入是3D张量。解决方案是重写ActorCriticPolicy的forward方法,在输入进网络前用unsqueeze(1)把维度从(batch, features)变成(batch, 1, features)。这个维度对齐问题不处理,训练一启动就会报维度不匹配的错。
4.3 训练参数怎么定:从学习率到早停
PPO训练的超参数我踩平了一路坑,最终参数如下:
| 参数 | 取值 | 调整心得 |
|---|---|---|
| total_timesteps | 50000 | 太少学不到稳定策略,太多容易过拟合 |
| learning_rate | 3e-4 | 默认值,调大后训练曲线剧烈震荡 |
| batch_size | 256 | 太小容易陷入局部最优 |
| gamma | 0.99 | 折扣因子,太大则策略过于看重远期收益 |
| gae_lambda | 0.95 | 泛化优势估计,0.95是经典配置 |
训练时用验证集做早停判断。FinRL官方没有内置这个,我手动加了一个callback:每2000个timestep在验证集上跑一次回测,记录夏普比率,连续五次不提升就提前终止。这个机制帮我省了大量调参时间,也直接避免了后期过拟合曲线的出现。
5. 回测结果解读与避坑实录:数据会说谎,但不会一直说谎
5.1 回测报告里哪些数字才值得信
训练完成后,FinRL自带的回测模块会输出一份报告,包含年化收益率、夏普比率、最大回撤、胜率等。但这里有个认知陷阱:传统回测指标和强化学习策略之间有错位。PPO学习的是与环境的交互策略,它在训练过程中会把手续费和滑点内化为行为约束的一部分,所以回测报告里的指标已经天然包含了交易成本的影响。这与传统先出信号再扣成本的研究流程有本质区别。
我重点关注三个指标,按优先级排序:最大回撤、夏普比率、年化收益率。年化收益率可以被高杠杆堆出来,但最大回撤是实盘生存能力的直接体现。我的策略在测试集上的最大回撤控制在18%以内,年化收益在22%左右,夏普1.6,这个组合对我来说是可接受的。
5.2 我踩过的三个坑,每个都让回测结果虚高过
第一个坑是手续费设置太低。FinRL默认的transaction_cost_pct可能只有万二,但实盘加上滑点通常要按千一点五去算。我把成本从万二改到千一后,策略的年化收益下降了近35%,交易频率也降下来了。这说明之前的策略很大一部分收益是靠高频微操"刷"出来的,实盘根本不可能复现。
第二个坑是状态空间里混入了全市场数据。我曾经尝试把大盘指数的涨跌幅也加进特征,结果模型学会了"抄作业"——在牛市行情里无脑做多。这个策略在测试集上表现惊艳,换了震荡市直接腰斩。后来我把特征严格限定在个股自身数据,模型被迫去学真正的时序结构,反而稳定了。
第三个坑是对测试集的重复使用。我为了提高回测分数,反复调参都在同一个测试集上验证。表面看是策略在进步,实际上是模型在记忆测试集。正确的做法是测试集只能碰一次,调参用验证集。意识到这个问题后,我固定住测试集,只在验证集上调参,最终得出的回测分数虽然比之前"调整过的"要低,但可信度高得多。
5.3 验证策略稳健性的土办法
除了框架自带的回测,我还做了两个补充验证。一个是参数敏感性分析:把窗口长度从30改成25和35,看策略表现是否剧烈变化。如果换个参数就崩盘,说明策略没有找到真实规律,只是在过拟合特定参数。我的实验结果是表现小范围波动,这让我对模型的稳健性有了底。
另一个是交易记录抽查。我在回测日志里随机抽取20笔交易,手工核对每笔交易的入场理由和数据状态。这一招很笨,但能发现很多自动化评估发现不了的问题,比如有几笔交易是在暴涨次日追高买入,这种信号在实盘中大概率会亏损。发现之后,我在奖励函数里加了收益波动惩罚项,这类追高行为明显减少了。
6. 最后分享几点我的实际体会
这套流程从开始搭建到稳定跑完回测,我前后花了两周多。最大的体会是:在量化这个领域,模型结构的影响力远不如数据处理和评估流程大。LSTM-GRU混合网络确实比单模型有了可感知的提升,但如果数据切分有未来函数、手续费设置不合理、测试集被反复偷看,再强力的网络也救不回来。
另一个想单独提醒的是:别只盯着基线收益不放。我在测试集上试过同期"买入持有"策略做对照,混合网络策略的超额收益大约在年化8%左右,但这个优势主要来自回撤控制得好,而不是牛市里跑得快。如果你只想在牛市中赚快钱,深度学习策略反而未必是首选工具。
做这类策略最忌讳的就是追求完美复现别人的参数。行情在变,股票的波动特征在变,一个在历史数据上收敛得很漂亮的策略,换一段行情可能立刻失效。正确的姿势是把自己的流程框架搭稳——数据、模型、训练、回测、验证——然后在每个环节上持续做小步迭代。我的FinRL接入代码和数据预处理管线已经沉淀成了自己的模板,后续换标的、换周期、加特征,都只需要改几个参数就行。这才是我认为这套组合最大的价值。
本文还有配套的精品资源,点击获取