深度Q学习网络在股票交易中的实战:从MDP建模到回测避坑指南
简介基于深度Q学习网络的股票交易策略设计源码是一套面向量化交易与强化学习研究者的完整实现针对股票市场高维动态特征借助深度Q网络的动作价值函数优化交易决策。压缩包共含24个文件大小约12.21MB主要文件类型包括Python脚本、CSV行情数据、TensorFlow模型检查点及说明文档覆盖数据预处理、模拟交易环境、DQN算法训练与回测验证等环节。包内数据包含分钟级和日线级行情样本检查点文件可用于断点续训脚本与说明文档搭配便于快速搭建实验项目。目前已有330人浏览学习这套源码适合具备Python基础并希望将强化学习落地到金融策略的开发者参考。通过该源码可理解DQN网络构建、经验回放、环境交互、模型保存与评估等关键步骤减少从零搭建的重复工作更专注策略设计与调优。1. 深度Q学习网络做交易行情不是标注集是带延迟回报的决策过程先泼一盆冷水如果你带着“预测明天涨跌”的心态来用深度Q学习网络大概率会做出一个回测漂亮、实盘拉胯的策略。因为DQN压根不干预测这件事——它学的是“在当前这个市场状态下买入、卖出、还是拿着不动哪个动作在未来能拿到最多的累积回报”。这两者最本质的区别在于行情在DQN里不是带标签的样本集而是一条有延迟回报的马尔可夫决策链。所以这篇笔记的目标读者是那些已经有Python基础、接触过一点强化学习概念但始终没把DQN真正落到K线数据上的人。你会发现整个方案的骨架并不难搭状态怎么构造、奖励怎么设、训练循环怎么写、回测怎么防自欺欺人每一环都有明确的参数和踩坑点。跟着走下来你得到的不是一个“万能选股器”而是一套能自己调、能自己验证、出了问题能排查的交易决策实验框架。2. 把股票交易建模成马尔可夫决策过程状态、动作、奖励怎么定很多人在DQN上翻车不是网络结构写错了而是马尔可夫决策过程MDP的四个要素压根没想清楚就开跑。股票交易天然适合用MDP描述智能体在每个交易日看到一个状态盘面数据当前持仓做出一个动作买、卖、持市场给出一个奖励账户权益的变化然后状态推进到下一个交易日。这套闭环听起来简单落到代码里全是细节。2.1 状态空间设计20根K线还是60根特征选哪些状态空间的第一个问题是“用多长的历史窗口”。窗口太短模型看不到趋势窗口太长输入维度膨胀全连接网络很容易过拟合到噪声上。我一般先用2030个交易日做窗口起步这个长度对日线级别的中期趋势已经够用后续再根据验证结果往回缩或者往上加。第二个问题是“用哪些特征”。不建议一上来就堆几十个指标DQN的特征工程原则和传统机器学习一样先少而精再逐步扩展。我常用的基础特征组是这五类对数收益率序列能直接反映价格变化幅度也天然做了尺度压缩收盘价相对过去N日均线的偏离度相当于一个简单趋势强度信号成交量相对过去N日均量的比值放量缩量的量化表达RSI(14)超买超卖区间的刻画当前持仓状态0表示空仓1表示持有若干股对应地状态张量的形状大概是[batch, 窗口长度, 特征数]。如果你用全连接网络而不是LSTM就需要把窗口内的特征摊平成[batch, 窗口长度 × 特征数]。下面这段是状态构造的常见写法import numpy as np import pandas as pd def build_state(df, idx, window20, featuresNone, holding0): 构造t时刻的状态张量 df: 按日期升序的DataFrame至少包含close/volume idx: 当前索引位置必须大于等于window holding: 当前是否持仓0/1 if idx window: return None # 历史数据不足跳过该样本 # 窗口切片注意这里取的是idx-window到idx不包含未来数据 window_data df.iloc[idx - window:idx] # 对数收益率 log_ret np.log(window_data[close] / window_data[close].shift(1)).fillna(0).values # 收盘价相对20日均线的偏离度 ma20 window_data[close].rolling(20).mean().iloc[-1] price_dev (window_data[close].iloc[-1] - ma20) / ma20 # 成交量相对20日均量的比值 vol_ratio window_data[volume].iloc[-1] / window_data[volume].rolling(20).mean().iloc[-1] # RSI(14)的简化计算 delta window_data[close].diff() gain delta.clip(lower0).rolling(14).mean().iloc[-1] loss (-delta.clip(upper0)).rolling(14).mean().iloc[-1] rsi 100 - (100 / (1 gain / (loss 1e-9))) state np.concatenate([log_ret, [price_dev, vol_ratio, rsi, holding]]) return state.astype(np.float32)这个实现里有几个容易出错的细节。首先是切片边界df.iloc[idx - window:idx]左闭右开正好不包含当前时刻的收盘价否则就构成了未来函数。其次是RSI计算loss 1e-9是防除零很多人在单边上涨行情里因为loss为0直接算出inf。最后是holding这个维度必须放进状态里否则Q网络无法区分“我手里有没有货”同一个盘面形态在持仓和空仓下的最优动作是完全不同的。2.2 动作空间与持仓约束为什么离散动作比连续仓位更稳动作空间的设计直接决定了训练难度。最常见的选择是3个离散动作0表示清仓/不买1表示买入/加仓2表示卖出/减仓。不要一上来就做连续仓位控制输出一个0到1的仓位百分比DQN在这类问题上的收敛极不稳定——Q值回归目标本身就带噪声再叠加连续动作的探索空间训练过程会非常漫长。用离散动作还有一个额外的好处你可以对动作空间做合法性约束。比如当前状态是空仓那动作2卖出就是非法的当前状态已满仓动作1买入也是非法的。做法是给非法动作的Q值设一个极大的负值def mask_illegal_actions(q_values, holding): q_values: 网络输出的3个动作的Q值shape [batch, 3] holding: 当前持仓状态0空仓 1持仓 masked q_values.clone() # 空仓时禁止卖出持仓时禁止买入 masked[holding 0, 2] -1e8 masked[holding 1, 1] -1e8 return masked这个mask必须同时用在训练和推理阶段否则会出现“空仓状态下Q网络贪心选择了卖出”的荒谬动作。另外要注意不要用“买入后立即在下一时刻卖出”这类高频动作来刷奖励DQN很容易学会这种套利式路径但实盘里手续费和滑点会把这部分收益全部吃掉。2.3 奖励函数从收益率到考虑了手续费的对数收益奖励函数是整个MDP里最主观的一环也是测试标准不统一时最容易被“调参调出来的虚假收益”骗到的地方。基础做法是用当前步的账户权益变化作为即时奖励$$R_t \frac{equity_t - equity_{t-1}}{equity_{t-1}}$$但直接使用简单收益率有个问题它对上涨和下跌的处理是对称的而交易者实际感受到的亏损痛苦远大于盈利快乐。更平滑的做法是使用对数收益率它在数学上具有时间可加性累乘收益可以直接通过对数累加得到。下面的表对比了三种常见的奖励设计奖励形式计算方式优点缺点简单日收益率(equity_t - equity_{t-1}) / equity_{t-1}直观、易实现对极端值敏感训练不稳定对数收益率log(equity_t / equity_{t-1})平滑、时间可加对小收益率不敏感带成本的对数收益率log(equity_t / equity_{t-1}) - cost * trade_flag贴近真实交易需要额外维护交易标志我最终选用的是“带成本的对数收益率”手续费率按单边万二到千一之间设置。之所以要把成本写进奖励是因为DQN如果不承担任何交易成本它会学出“日内反复横跳”的最优策略——每个看起来能赚0.01%的波动都想抓训练出来的策略交易次数动辄上千实盘光手续费就拖垮账户。还有一个进阶技巧如果训练时发现模型一直不愿意交易奖励长期为0可以考虑在奖励里加一个小的“交易行为惩罚项”比如买入动作本身扣0.0001强制让智能体去权衡该不该出手。但这属于调参手段不要默认开启。3. 深度Q网络源码结构网络选型、经验回放与三个必调参数MDP定完之后接下来就是DQN的核心训练框架。这部分代码量不大但每个组件都必须存在少一个都会导致训练要么发散、要么永远不收敛。我会按网络结构、经验回放、训练主循环三个层次拆开讲顺手把最容易踩的参数设置说清楚。3.1 网络结构全连接够不够用何时换LSTM对日线级别的DQN交易策略一个三层的全连接网络通常就够用。输入是摊平后的状态向量中间两层128维和64维激活函数用ReLU输出是3个动作的Q值。很多人在这个阶段会焦虑“是不是该用LSTM或Transformer”我的建议是先把全连接跑通再看收益曲线决定。原因很简单日线数据本身信息密度低全连接学不到时序依赖不代表它学不好——它可以用收益率特征本身就携带的滞后信息来做决策。import torch import torch.nn as nn class DQN(nn.Module): def __init__(self, state_dim, action_dim3): super().__init__() self.net nn.Sequential( nn.Linear(state_dim, 128), nn.ReLU(), nn.Linear(128, 64), nn.ReLU(), nn.Linear(64, action_dim) ) def forward(self, x): # 输入x: [batch, state_dim]输出每个动作的Q值 return self.net(x)如果状态里包含了较长窗口的原始行情序列且你发现全连接网络拟合出来的策略总是慢半拍——比如该离场时拖了两天才反应——那可以考虑把第一层换成LSTM输入形状改成[batch, 窗口长度, 特征数]。但记住LSTM的收敛速度会比全连接慢不少训练时间可能是三到五倍先做好心理准备。3.2 经验回放与目标网络稳定训练的两个支柱DQN训练稳定性差的两个根源一是相邻时间步的样本高度相关二是因为Q值的目标本身在训练中不断移动。经验回放解决前者目标网络解决后者。经验回放的做法是维护一个固定容量的缓冲区把每条(state, action, reward, next_state, done)存进去训练时从中随机抽一批样本打断时间上的相关性。目标网络是另一条防线。它的参数不参与梯度更新而是每隔固定步数直接从主网络复制一次。这样在计算TD误差时目标值是“一个较旧版本网络给出的估计”而不是刚更新完的当前网络大幅减少了训练震荡。这两个组件的参数通常是这样的参数取值区间我的常用起点作用回放缓冲区容量5000 ~ 5000020000越大越稳定但过大会学到太久远的行为目标网络更新步数200 ~ 2000500越小越接近主网络震荡加剧越大越稳定批大小32 ~ 12864影响梯度估计的稳定性折扣因子γ0.9 ~ 0.990.95越接近1越看重远期收益越接近0越只看眼前3.3 训练主循环PyTorch源码与参数说明训练循环是DQN项目里最需要逐行细究的部分。下面这段代码是完整的单步训练流程使用了Double DQN来缓解Q值过估计问题这也是我建议从第一天就加进去的改进改动成本极小收益却很明显。import random from collections import deque memory deque(maxlen20000) def train_step(q_net, target_net, optimizer, gamma0.95, batch_size64): if len(memory) batch_size: return 0.0 # 样本不够先不训练 batch random.sample(memory, batch_size) # 拆包纵向拼接 states torch.FloatTensor([t[0] for t in batch]) actions torch.LongTensor([t[1] for t in batch]).unsqueeze(1) rewards torch.FloatTensor([t[2] for t in batch]).unsqueeze(1) next_states torch.FloatTensor([t[3] for t in batch]) dones torch.FloatTensor([t[4] for t in batch]).unsqueeze(1) # 当前状态-动作的Q值 q_values q_net(states).gather(1, actions) # Double DQN: 先用主网络选动作再用目标网络计算这个动作的Q值 with torch.no_grad(): next_actions q_net(next_states).argmax(dim1, keepdimTrue) next_q target_net(next_states).gather(1, next_actions) target rewards gamma * next_q * (1 - dones) loss nn.MSELoss()(q_values, target) optimizer.zero_grad() loss.backward() # 梯度裁剪防止Q值爆炸的最后一层保险 nn.utils.clip_grad_norm_(q_net.parameters(), max_norm10.0) optimizer.step() return loss.item()这段代码里最关键的是target rewards gamma * next_q * (1 - dones)这一行。(1 - dones)表示如果当前步已经是序列末尾就不该再累加未来收益这是很多人漏掉的终止条件处理。另外注意到next_actions是由q_net选出的而不是target_net这就是Double DQN的精髓它会显著压低Q值过估计尤其在交易这种奖励稀疏且噪声大的场景里过估计会让策略误以为“买什么都赚钱”最后训练出一个盲目满仓的动作。梯度裁剪也是这里建议保留的。交易序列的奖励偶尔会出现极端值比如一根大阴线让账户权益跳变如果不裁剪梯度这一批样本的loss会直接把网络参数推到一个坏区域训练要花很久才能恢复。4. 基于DQN的股票交易策略落地流程数据预处理、训练到回测网络结构和训练循环都有了接下来是怎么把一个真实的股票数据集跑通。这一章按“数据切分→环境交互→回测评估→结果判读”的顺序走每一步都有对应的代码片或参数细节。这个环节决定了你的实验是“可复现的研究”还是“换个随机种子就完全变样的玄学项目”。4.1 数据切分与归一化训练段验证段不共享统计量拿到的历史行情数据必须先做两件事切分和归一化。常见的切法是把时间序列按比例分成三段比如训练段2016-2022、验证段2023-2024、测试段2025至今。注意训练段必须严格在验证段之前不能用随机划分——金融时间序列一旦乱序相当于把未来信息偷进了训练集。归一化这一步是最容易被忽视的坑。很多教程直接在全数据集上算均值和方差再用它去标准化所有数据。这在交叉验证里是致命的泄漏训练阶段已经“看”了验证段的分布信息验证结果就会虚高。正确做法是只用训练段数据计算归一化参数验证段和测试段都复用这套参数。# 只从训练段学习归一化参数 train_df df.loc[:2022-12-31] mean train_df[[close, volume]].mean() std train_df[[close, volume]].std() # 全序列用同一套参数转换 df[[close, volume]] (df[[close, volume]] - mean) / std如果你用的状态里包含多只股票那归一化参数应该按股票分别计算或者统一用全市场均值不要混在一起算。还有个细节滚动类特征比如RSI、偏离度在序列开头会产生NaN通常直接用dropna()丢掉即可但要注意丢掉后索引有没有对齐。4.2 训练后的交易决策逻辑与回测评估指标模型训练完评估的第一步是把策略在一个完整序列上跑一遍记录每个时间步的持仓和动作算出期末权益。这一步要建立一个最小化的回测循环def run_episode(df, q_net, holding0, cash100000, fee_rate0.0002): 模型推理阶段的回测循环 for i in range(window, len(df)): state build_state(df, i, holdingholding) state_tensor torch.FloatTensor(state).unsqueeze(0) q_values q_net(state_tensor) # 非法动作mask if holding 0: q_values[0, 2] -1e8 # 空仓不能卖 else: q_values[0, 1] -1e8 # 持仓不能买 action q_values.argmax().item() price df[close].iloc[i] if action 1 and holding 0: # 买入 shares cash * 0.95 // price / 100 * 100 # 留5%备用按手买 cost shares * price * fee_rate cash - shares * price cost holding shares elif action 2 and holding 0: # 全部卖出 cash holding * price * (1 - fee_rate) holding 0 final_equity cash holding * df[close].iloc[-1] return final_equity这个循环里的两个参数容易有歧义。fee_rate0.0002是单边万二手续费买入时本金减少、卖出时收益打折都体现在了代码里。现金利用率0.95是为了留出应对滑点的余量很多新手满仓进出回测结果比实盘高出一大截。另外注意按手交易把零股舍掉的做法A股一手是100股这个舍入对最终收益的影响在小资金回测里往往不明显但在大资金策略里误差会被放大。回测结果出来后不要只看总收益率至少要看这三个指标夏普比率收益与波动之比、最大回撤最惨时刻的账户回撤幅度、交易次数。DQN策略如果交易次数少于10次说明它基本躺平了只是靠行情Beta在赚钱模型本身没有学到任何有效买卖信号如果交易次数超过300次大概率是在反复割肉赚手续费。4.3 回测参数明细与结果判读一次完整的DQN训练实验通常会涉及十几项参数建议把它们全部记录在配置文件里否则过了三天就不记得昨天的结果是怎么跑出来的。高频参数参考参数建议值实际修改时怎么调训练轮数(episode)500 ~ 2000每轮对应一条完整历史序列过少学不完过多过拟合ε衰减终值0.05 ~ 0.2越高探索越多适合策略还在不稳定的早期每轮最大步数与序列长度一致序列太短时可通过滑动窗口扩增训练样本学习率1e-4 ~ 3e-4超过5e-4容易出现Q值发散每轮结束后打印累积回报与平均Q值如果累积回报在500轮后仍未形成上升趋势先别急着加网络层数大概率是前面MDP设计出了问题——奖励太稀疏、状态特征没包含有效信息、或者动作mask写错了。5. 常见问题与避坑五条真实踩坑记录这一章写的是我在跑通DQN交易策略过程中遇到过的五类问题每条都按“现象→原因→解决”整理覆盖了从训练不稳定到回测失真的常见坑。这些都是能让血泪经验变成“后悔药”的地方建议直接对照自己的实验结果查。5.1 训练不收敛与Q值爆炸现象训练到200轮左右loss不但没下降反而从0.02飙升到几百Q网络输出值达到数十万量级后续训练完全失效。原因这是DQN在金融数据上最常见的稳定问题。触发原因通常是某一批样本奖励异常大比如极端行情收出一根10%的巨阳TD误差反向传播时把参数推崩。学习率设置太高也会加剧这个现象。解决先把学习率降到1e-4以下然后在训练循环里加上梯度裁剪max_norm10.0是保守起步值。最后检查奖励序列是否有极端离群值如果有将奖励除以标准差做缩放把范围控制在-1到1之间。5.2 状态泄漏与数据顺序污染现象验证段回测年化收益高达40%但同一模型在最新行情上预测连续失误实际收益严重跑输回测。原因状态构造或数据预处理过程中混入了未来信息。最常见的两种是归一化时用了全序列均值和方差窗口切片时把当前时刻的收盘价纳入了特征计算导致模型推断的“当前状态”实际包含了当天收盘结果。解决把所有归一化参数锁定在训练段上并单独写一段断言代码打印状态张量里最后一个时间步的特征值人工核对它只由此前行情计算得出。这个坑排查起来很费时建议在build_state函数里加一行时间戳调试输出验证序列边界。5.3 过拟合历史行情的“后视镜”陷阱现象训练段累计回报曲线一路向上验证段却两次腰斩。换不同日期作为起始点重新训练收益差异极大。原因DQN的epsilon-greedy探索会撞上历史行情中的特殊结构比如某一轮训练正好在暴涨段频繁买入策略记住了这条路径。金融序列没有重复样本过拟合的表现比CV图像识别更隐蔽——loss下降不代表学到了通用规律。解决做多起点训练。把训练序列切成三段互不重叠的子段分别初始化独立模型训练500轮然后看三个模型在统一验证段上的收益标准差。标准差过大说明策略对起始位置敏感没有学到稳定规律需要回去调整状态空间或奖励设计。5.4 交易成本建模缺失导致回测失真现象回测总收益率12%但把手续费调成实实在在的万五后收益率直接变成-3%。更讽刺的是去掉成本后策略的交易频率明显偏高。原因奖励函数里没扣交易成本智能体学到的是“频繁交易总能抓住小波动”。在无摩擦假设下DQN天然偏好高换手实盘中的滑点和手续费会把这部分收益全部抹掉。解决奖励函数中按动作切换次数扣除成本买入和卖出各算一次。另外在回测时做成本敏感性分析分别用0、万二、千一三档手续费跑一遍看收益曲线斜率变化。如果从正转负的临界点很低说明策略的真实毛利空间不足以覆盖交易成本这个方向就需要重新审视。5.5 收敛判断陷阱只看loss是最大的误判现象训练loss从0.8稳步降到0.2你觉得模型学得很好了回测却发现它全程躺平不交易最终收益大约等于买入持有策略。原因DQN的loss只是“预测Q值与目标Q值之间的距离”当模型学到“无论什么动作收益都差不多”的模式时loss依然可以降到很低但这是死水一潭的收敛。整条序列完全没有交易行为。解决训练过程中除了记录loss还要周期性地统计每轮episode的买入次数、卖出次数、持仓时间占比。如果卖出次数少于买入次数的一半甚至为0说明策略可能困在“空仓最优”的局部解里。常规处理是把ε衰减拉长让模型有更多探索机会同时检查奖励里是否对“开仓”这一行为有正向引导。6. 从能跑到能信DQN策略的验证方法与继续进阶的方向模型跑通、回测也出了正收益之后别急着宣布成功。我自己的习惯是再做三个快速验证权当“上阵前最后的检查”——这三步大概多花半小时但能把一个运气型策略和一个真正有规律的策略区分开。第一个验证是随机基线对比。把你训练好的策略和一个纯随机的买卖动作序列放在同样的回测框架里跑10次比较平均收益与最大回撤。如果随机动作的收益和你的DQN差不多甚至更好那说明模型学到的根本不是有效信号只是赶上了一段普涨行情。第二个验证是成本敏感性分析前面避坑章提过这里再强调一遍从0费率到千一费率把策略收益画成一条曲线看它在哪个点出现拐点。第三个验证是序列擦除——把状态窗口内最后一根K线人为替换成随机价格如果模型的输出Q值几乎不变说明它依赖的是历史统计特征而非最近K线这种策略对噪声的鲁棒性通常更好。验证做完之后可以再往两个方向推进。一是算法层面优先尝试Dueling DQN或优先经验回放PER这两个改动分别是各500行级别的代码量对收敛速度的提升最明显二是时间粒度如果你已经跑通日线可以尝试切到15分钟线但注意动作频率也要对应提高且分钟线噪声大建议把手续费在奖励中的权重再提高一倍。如果发现自己对状态设计、奖励函数和网络调参都越来越有感觉可以再考虑引入多股票联合训练的扩展——让同一个DBN同时观察多只股票的状态输出动作时再增加一维“选择标的”的维度这才是真正接近实盘组合管理的形态。说到底深度Q学习网络做股票交易这个方向到底值不值得投入核心在于你能不能建立一套“自己做决策自己验证自己的决策”的闭环。把上面这些流程跑通之后你手里留下的不只是几个模型文件而是一套能持续迭代的实验环境——这也是我认为这个方向里最值得拿走的资产。希望这篇笔记里的踩坑经验和参数基准能帮你少走几段弯路。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →