深度Q学习网络(DQN)股票交易实战:从环境建模到回测避坑全解析
2026/9/23 23:58:26 网站建设 项目流程

简介:一份基于深度Q学习网络的股票交易策略设计源码,面向量化交易入门者、金融AI研究者和Python算法开发人员,解决如何利用强化学习对历史行情建模并自动生成交易决策的问题。压缩包共24个文件,约12.21MB,包含Python核心脚本、CSV行情数据、TensorFlow模型检查点与说明文档,覆盖数据预处理、训练、回测等完整链路。项目提供分钟级与日级深证个股数据,DQN_trade.py实现智能体决策逻辑,stock_env.py构建模拟交易环境,test.py支持策略回测,saved_network中保存了多个训练好的网络权重,可基于已有模型继续训练或直接验证效果。已有330人学习下载,适合希望快速上手深度强化学习在股票交易中应用的开发者,源码结构清晰,稍作修改即可迁移到其他市场或策略场景。

1. 深度Q学习网络做股票交易:从“决策会反悔”开始的设计闭环

把股票交易交给深度Q学习网络(DQN)来做,经常被当成玄学,其实它的核心逻辑一句话能说清:让模型学会回答“在当前市场状态下,做哪个动作最划算,并且把动作之后的未来收益也考虑进去”。这套基于深度Q学习网络的股票交易策略设计源码,落地的价值不在“预测涨跌”,而在于把交易决策变成可训练、可回测、可排错的序贯决策问题:环境模块模拟市场,Q网络估计动作价值,经验回放和目标网络负责解决训练稳定性的老问题。适合手里有 Python 和机器学习基础、想认真验证强化学习在交易上有多大价值的人,不适合指望导出模型就稳赚的人。接下来我把建模、代码落地和踩坑点按可复现的顺序过一遍。

2. 把股票交易改造成强化学习问题:状态、动作与奖励的三步建模

2.1 交易环境是第一个要写死的模块:数据、撮合与手续费

很多人拿到源码第一件事是看网络结构,这是误区。深度Q学习网络在交易场景里翻车,八成不是网络的问题,是环境写错了。环境在这里做的不是“预测行情”,而是把“给定状态 → 执行动作 → 得到奖励 → 进入下一个状态”这个过程完整闭环。一个写坏的环境会直接污染所有后续训练结果。

我写环境时遵循一个原则:模拟器能多简单就多简单,但三个东西必须严格——价格时序、手续费、撮合时点。下面是一个能直接跑通最小训练循环的单标的交易环境,动作空间是 0(空仓)、1(持有)、2(买入):

import numpy as np import pandas as pd class TradingEnv: """单标的离散动作交易环境,满仓进满仓出,T+0 简化撮合""" def __init__(self, df, features, window=30, cost=0.001): self.df = df.reset_index(drop=True) self.features = features self.window = window self.cost = cost # 单边交易成本,按比例扣 self.reset() def reset(self): self.t = self.window self.position = 0 # 0 空仓,1 持有 self.cash = 1.0 # 初始资金归一化 self.shares = 0.0 self.prev_price = float(self.df['close'].iloc[self.t - 1]) return self._get_state() def _get_state(self): # 取最近 window 根 K 线的特征矩阵,压平成一维 x = self.df[self.features].iloc[self.t - self.window:self.t].values return x.flatten().astype(np.float32) def step(self, action): price = float(self.df['close'].iloc[self.t]) reward = 0.0 # 第一步:结算上一个 bar 到当前 bar 的持仓浮盈 if self.position == 1: log_ret = np.log(price / self.prev_price) reward += log_ret # 第二步:执行当前动作,扣交易成本 # 买入:空仓状态下才成交 if action == 2 and self.position == 0: self.shares = self.cash / price self.cash = 0.0 self.position = 1 reward -= self.cost # 卖出:持有状态下才成交 elif action == 0 and self.position == 1: self.cash = self.shares * price self.shares = 0.0 self.position = 0 reward -= self.cost # 动作 1 是持有:不改变仓位,也不额外扣费 self.prev_price = price self.t += 1 done = self.t >= len(self.df) - 1 return self._get_state(), reward, done, {}

代码逻辑分三步走:先结算浮盈,再执行动作,最后推进时间步。奖励使用对数收益log(price / prev_price),好处是收益可以直接跨时间相加,不会因为价格的绝对水平导致奖励尺度漂移。注意买入和卖出都加入了reward -= self.cost,交易成本是 DQN 能不能学会“少动手”的关键约束,去掉它模型会退化成高频乱动。

参数上有几个点我习惯固定下来:window=30表示用过去 30 根 K 线做状态,特征数量由features决定;cost=0.001是单边千一,如果你做 A 股,把印花税和滑点一起算进去,单边 0.001 到 0.002 都是常见取值。这个环境没做涨跌停限制,也没区分 T+1,目的是先跑通训练,真实约束后面再逐条加,一次加太多约束会让模型学不动。

2.2 状态空间设计:行情窗口、技术特征与“不要碰未来数据”的红线

状态空间决定了模型能看到什么。我见过很多人把网上那些指标源码里的金叉死叉信号直接拿来当特征,结果模型学到的是指标的滞后转译,而不是交易逻辑本身。这不是说技术指标不能用,而是用之前必须明确:特征向量是给 Q 网络做价值判断的输入,不是信号生成器。

我的常用特征是八到十二个维度的组合,全部基于滚动窗口内已知数据计算:收益率、最高最低价相对收盘的位置、成交量变化率、ATR(平均真实波幅)、RSI、MACD 线与其信号线的差。关键约束是所有特征在 t 时刻必须只用 t 时刻之前的数据,最稳妥的办法是先用shift(1)把整列特征向后平移一根 K 线,再进环境。

def prepare_features(df): """构造特征并统一做 z-score 标准化,注意用 expand 窗口避免未来数据泄漏""" df = df.copy() df['ret'] = df['close'].pct_change() df['high_low_pos'] = (df['close'] - df['low']) / (df['high'] - df['low']) df['vol_ratio'] = df['volume'] / df['volume'].rolling(20).mean() # 计算 RSI 这类指标后统一 shift(1) df = df.replace([np.inf, -np.inf], np.nan).fillna(0) return df

这里有两个坑。第一个是标准化:不能用全样本的均值和方差做 z-score,那等于让训练集偷看了测试集的分布,正确做法是在训练集上fit一个StandardScaler,再应用到测试集。第二个是shift(1),它能挡住所有计算出来的指标,但对一些天生带未来信息的字段没用,比如直接用close.shift(-1)做标签,这种低级前视偏差我在别人的课程设计源码里见过不少,回测曲线好看得离谱,实盘直接归零。

2.3 奖励函数选型:单步收益、持仓惩罚与折扣因子的配合

奖励函数是深度Q学习网络在交易场景里最敏感的旋钮。最直觉的做法是“赚钱给正奖励、亏钱给负奖励”,但直接这么写会让模型变得极其短视:它会抓住单步大波动去频繁交易,因为每步的即时奖励比折扣后的长期价值更吸引人。

我的做法是把奖励拆成两段:持仓浮盈变化加上交易成本惩罚,再叠加一个可选的“非必要不交易”惩罚项。环境代码里已经实现了前两段,第三段可以在训练时额外控制——每次判断动作与当前仓位不一致时,额外减去一个小常数。下面是奖励含义的对照关系,方便你按自己的策略微调:

奖励来源计算方式作用
持仓浮盈log(price_t / price_{t-1})让模型感知持仓收益变化
交易成本单边固定比例成本抑制过度交易
动作切换惩罚每次买卖额外减 0.0002~0.0005降低无效换手
终点大奖励最后一刻结算累计收益强化长期目标,但只适合短周期

折扣因子gamma在这里的作用比在游戏里更重要。金融市场是强非平稳环境,gamma=0.99意味着模型会看重未来几十步的收益,这适合趋势跟踪;如果做高频,gamma要降到 0.95 以下,让模型更关注眼前。我一般先固定gamma=0.99,看训练曲线的稳定性再决定是否调整,不要一开始就同时调三个旋钮。

3. 核心组件实现:经验回放、目标网络和ε-greedy策略的代码落地

3.1 Q网络与经验回放:打破样本相关性的第一个关键

交易数据天生是时间序列,相邻样本高度相关。如果像普通监督学习那样把相邻状态直接喂给网络更新梯度,Q值的估计会像醉汉走路一样来回震荡。经验回放就是为了解决这个问题:把转移样本(s, a, r, s2, done)存进一个大缓冲区,训练时随机抽样一个小批量。随机抽样在数学上的作用是打破样本间的时间相关性,让梯度更新近似满足独立同分布假设,这是深度Q学习网络能稳定收敛的基石。

网络结构我固定用最简单的三层全连接,别一开始就上 LSTM 或者 Transformer,先让整个训练循环能跑通再说。常见实现如下:

import torch import torch.nn as nn from collections import deque import random class QNetwork(nn.Module): """三层全连接 Q 网络,输入状态,输出每个动作的 Q 值""" def __init__(self, state_dim, hidden=128, n_actions=3): super().__init__() self.net = nn.Sequential( nn.Linear(state_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, n_actions) ) def forward(self, x): return self.net(x) class ReplayBuffer: """环形经验回放缓冲区,容量满后自动覆盖旧样本""" def __init__(self, capacity=100_000): self.buffer = deque(maxlen=capacity) def push(self, s, a, r, s2, done): self.buffer.append((s, a, r, s2, done)) def sample(self, batch_size): batch = random.sample(self.buffer, batch_size) states = torch.from_numpy(np.stack([x[0] for x in batch])) actions = torch.tensor([x[1] for x in batch], dtype=torch.int64) rewards = torch.tensor([x[2] for x in batch], dtype=torch.float32) next_states = torch.from_numpy(np.stack([x[3] for x in batch])) dones = torch.tensor([x[4] for x in batch], dtype=torch.float32) return states, actions, rewards, next_states, dones

回放容量的选择有讲究。容量太小,缓冲区里全是近期交易经验,时间相关性没有被打散;容量太大,老样本占多数,模型跟不上市场近期的状态分布变化。日线上做交易,我的经验是5_00050_000之间比较合适。选择10_000时,假设一个 episode 是 200 步,缓冲区大约覆盖 50 个完整交易周期,足够多样化,又不会让旧经验把新趋势盖住。

3.2 目标网络:延迟复制参数为什么能稳住训练

如果不加目标网络,Q 值的更新公式里r + gamma * max(Q(s2))用的和预测用的是同一个网络,这会造成“自己追自己尾巴”的效应:目标一直在变,梯度更新方向不稳定,表现为 loss 曲线锯齿状剧烈震荡、甚至发散。目标网络就是给更新目标加了一层延迟——用一份旧参数的网络去计算目标 Q 值,每隔固定步数再把当前网络的参数复制过去。

这种延迟参数复制机制在一个训练循环里就几行代码,但它的作用被很多人低估。我用一个全局步数计数器控制同步节奏,每UPDATE_TARGET=200步同步一次,让目标网络比当前网络“慢半拍”,Q 值更新时目标相对稳定,训练自然就稳了。以下是带目标网络同步的最小训练主循环:

optimizer = torch.optim.Adam(q_net.parameters(), lr=1e-4) replay = ReplayBuffer(capacity=10_000) target_net = QNetwork(state_dim) target_net.load_state_dict(q_net.state_dict()) UPDATE_TARGET = 200 BATCH_SIZE = 64 GAMMA = 0.99 EPSILON = 1.0 EPS_DECAY = 0.998 EPS_MIN = 0.05 global_step = 0 for episode in range(200): state = env.reset() done = False while not done: # ε-greedy 选择动作 if random.random() < EPSILON: action = random.randint(0, 2) else: with torch.no_grad(): state_t = torch.from_numpy(state).unsqueeze(0) q_values = q_net(state_t) action = q_values.argmax().item() next_state, reward, done, _ = env.step(action) replay.push(state, action, reward, next_state, done) if len(replay.buffer) >= BATCH_SIZE: s, a, r, s2, d = replay.sample(BATCH_SIZE) q_pred = q_net(s).gather(1, a.unsqueeze(1)).squeeze() with torch.no_grad(): q_next = target_net(s2).max(dim=1)[0] y = r + GAMMA * q_next * (1 - d) loss = nn.functional.smooth_l1_loss(q_pred, y) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(q_net.parameters(), 1.0) optimizer.step() global_step += 1 if global_step % UPDATE_TARGET == 0: target_net.load_state_dict(q_net.state_dict()) state = next_state EPSILON = max(EPS_MIN, EPSILON * EPS_DECAY)

这段代码里的smooth_l1_loss是个值得注意的选型:它结合了 L1 和 L2 损失的特点,Q 值目标误差大时梯度不会爆炸,误差小时收敛平稳。如果不做梯度裁剪,即使用了 Huber 损失,早期训练的异常奖励也可能让权重一步跳坏。clip_grad_norm_的 1.0 取值是我从实际训练里调出来的,日线级别奖励通常很小,裁剪阈值设太大等于没设,设太小会让学习变慢。

3.3 ε-greedy 与衰减:探索策略不是拍脑袋,是调参对象

ε-greedy 控制模型在“按当前策略行动”和“随机探索”之间的切换。初始EPSILON=1.0表示策略完全随机,随着训练推进降低到0.05,最终 95% 的动作来自 Q 网络、5% 的随机动作兜底。这个 5% 的探索要保留,因为在交易场景里,市场状态会变化,完全放弃探索意味着模型在没见过的行情下没有逃生通道。

衰减率EPS_DECAY=0.998在 200 个 episode 的训练里,大约 400 步后 ε 会降到 0.05 下限附近。对日线交易,一个 episode 大约就是两三个月的交易数据,200 个 episode 覆盖的数据量足够长。你要注意一个反向误区:ε 降得太快,模型过早固化到局部最优;降得太慢,训练前期大量时间在随机乱走,样本利用率低。我一般先在训练日志里同时打印“平均奖励”和“ε 当前值”,如果发现平均奖励在 ε 还很高的时候就开始上升,说明当前策略已经有效,可以略微提高衰减率。

4. 从训练到回测:数据切分、验证流程和一组可用参数

4.1 数据切分与信号执行时序:时间序列不能随机打乱

强化学习在交易里的数据切分比监督学习严格得多。常见错误是直接用train_test_split(random_state=42),这会把未来数据混进训练集,回测结果完全失真。时间序列必须按时间顺序切:前 70% 做训练集,中间 15% 做验证集(用来选超参数),最后 15% 做测试集(只允许跑一次)。

信号执行时序同样有硬性要求:策略在 t 时刻用收盘价算好特征、生成动作,但动作要在 t+1 时刻的 bar 上才能生效。环境代码里step执行动作前先按当前 price 结算上一段持仓,已经隐式实现了这个时序,但你要检查自己从外部接数据时有没有破坏这个顺序。

def evaluate(env, q_net, eps=0.0): """评估模式:关闭探索,输出累计收益和每次动作,用于画净值曲线""" state = env.reset() done = False total_return = 0.0 actions_log = [] while not done: with torch.no_grad(): state_t = torch.from_numpy(state).unsqueeze(0) action = q_net(state_t).argmax().item() if random.random() > eps else random.randint(0, 2) next_state, reward, done, _ = env.step(action) total_return += reward actions_log.append(action) state = next_state return total_return, actions_log

这套时序规则带来的一个直接效果是:回测净值曲线的最后一天会有“幸存者偏差”的假象,因为策略在最后一天只结算不交易。真实做法是剔除最后一天的浮盈或者把评估期多留一根 bar 做缓冲。验证集选参、测试集定论,这是交易策略设计里最低限度的纪律。

4.2 训练主循环与损失函数:什么时候看哪个日志

训练循环的日志设计是调试效率的分水岭。我见过有人只在命令行打印 loss,loss 从 0.5 慢慢降到 0.1 就觉得赢了,结果去回测发现模型从头到尾没买过一手。训练日志至少要有四列:全局步数、loss、累计 episode 奖励、动作分布占比。

动作分布是最重要的信号。如果一段时间内动作 2(买入)的占比趋近于 0,说明模型学成“永久空仓”策略——它在用“不亏钱”作为最优解,止损比止盈更狠。如果动作 0 占比接近 0,模型又变成“无脑满仓”。两种情况都要停下来查奖励函数和成本设置。

训练中 loss 的正常形态不是单调下降,而是先快速下降再在一个平台附近波动。交易数据的非平稳性决定了 Q 值不可能收敛到固定点,loss 在一个范围内震荡是正常的,别看到小幅回升就慌。真正的异常是 loss 突然跳高一个量级,那通常是因为某一步奖励出现了极端值,比如除权未复权造成的价格跳空。这种时候去查数据源,不要改学习率硬压。

4.3 评估指标和关键参数:一组能直接当起点的默认配置

回测评估不能只看累计收益。最大回撤、换手率、交易次数、夏普比率这四项必须一起看。我见过一个模型年化收益 80%,但最大回撤 45%,中间任意一次腰斩级别的回撤都足以让实盘者心理崩溃、手动干预乱掉整个策略。下面这个参数表是我在沪深 300 日线上跑过的最小可用配置,直接用它当起点,再按数据微调:

参数取值说明
window30状态窗口长度,日线短周期够用
hidden128隐藏层神经元数,256 以上容易过拟合
learning_rate1e-4日线场景推荐 1e-4,不要用默认的 1e-3
batch_size64常用 32~128,小批量噪声更大
replay_capacity10_000控制旧经验占比,日线 5k~50k 区间调
update_target200每 200 步同步目标网络参数
gamma0.99趋势策略首选,高频调低
epsilon_decay0.998200 episode 内在 1.0 → 0.05 之间完成衰减
cost0.001交易成本按单边千一,A 股可提到 0.0016

这组参数的调参逻辑是:先把学习率压住,训练曲线稳定之后再微调窗口和成本。回测结果的可视化同样重要,如果你习惯把行情脚本以本地方式跑起来自建图表,直接把评估日志里的资金曲线和基准指数曲线画在一起,能快速看出策略的超额收益集中在哪段行情、在震荡期是否失效。把每一笔交易的持仓区间标在图上,比盯着盈利数字更容易定位问题。

5. 深度Q学习网络做股票交易的常见问题和必看避坑点

5.1 现象:训练集收益曲线漂亮,测试集直接亏到怀疑人生

原因排除法按先后顺序查:第一查特征标准化是否用了全样本统计量;第二查是否用了未来数据,特别是shift(-1)这种目标泄漏;第三查数据集里有没有未复权的除权跳空。

解决:标准化器只在训练集fit,然后transform验证集和测试集;把特征工程里所有滚动指标统一shift(1);用后复权价格重新生成数据集。测试集只能跑一次,跑完发现不行就回去改特征,改完再回测试集等于把测试集当验证集用,这个习惯会让你对自己的策略产生系统性误判。

上一轮刚改完奖励函数就立刻去跑测试集,得到负收益后调整参数再跑一次,这次结果变正了,但我心里清楚:这个“正收益”已经不能说服任何人,包括我自己。正确顺序是训练集调整 → 验证集选参 → 测试集只做一次最终确认。

5.2 现象:loss 在某个平台反复震荡但不下降,训练几十轮没有进展

原因通常是奖励尺度与网络初始化不匹配。对数单步收益的量级在 0.001 到 0.01 之间,而 Q 网络输出的初始 Q 值通常在 0 附近、范围在正负几之间。两个尺度差距过大,TD 目标里的r会被gamma * max(Q(s2))盖住,模型学不到任何来自奖励的信号。

解决:把奖励整体乘以一个放大系数,比如reward * 20,让奖励信号和目标 Q 值在同一个数量级。另一个常见做法是把初始输出层权重调小,让网络的初始 Q 值分布在 0 附近一个小范围内,而不是标准的全连接初始化。我自己的习惯是先统计训练集里单步收益的均值与标准差,把奖励缩放到标准差为 1 左右,再进入训练循环。

5.3 现象:模型不交易,或者一直满仓不动

先说“一直空仓”:模型发现什么都不干的累计收益是 0,而瞎交易的期望收益是负的,于是学到空仓是最优策略。这说明奖励函数里缺少对机会成本的约束,或者交易成本设得过高。解决:检查动作分布,如果空仓动作占比超过 95%,把cost调低一个数量级测试,同时给持仓增加一个“行情上涨时未持仓的隐性惩罚”,比如if position == 0 and next_return > 0: reward -= 0.001

再说“一直满仓”:模型发现持有股票的长期收益期望为正,于是决定永远不卖。这个策略在单边上涨的训练集里是理性的,但测试集一旦进入熊市就全线崩溃。解决:训练数据里必须包含熊市和震荡市,不要只挑一段牛市训练;或者把奖励从“单步收益”改成“收益减去最大回撤惩罚”,让模型被迫学会出逃。

5.4 现象:除权除息让净值曲线出现诡异的跳空,模型在跳空位置疯狂交易

原因:数据源没有做复权处理。A 股除权除息会让价格在除权日断崖下跌,看起来像暴跌,模型会把它当成做空信号,实际上这只是权益分派导致的价格调整。

解决:全部使用后复权价格做训练与回测。后复权保证价格序列在历史任意一天的相对比例是真实的,实盘时再把信号映射回当前价格即可。数据下载时如果只拿到原始价,必须检查每个交易日的涨跌幅是否超过 10%,超过的多半是除权除息,停牌复牌也要单独标记。

5.5 现象:同参数、同数据,两次训练结果完全不一样,回测收益差 10 个百分点

原因:没有固定随机种子。PyTorch 的模型初始化、PyTorch 的 drop 操作、Python 的 random 模块、NumPy 的随机数生成器,四个随机源都要在同一处固定。只固定torch.manual_seed是不够的,因为经验回放采样用的是random.sample

import torch import numpy as np import random def seed_all(seed): torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) np.random.seed(seed) random.seed(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False

固定随机种子不只是为了复现报告里的数字,它更重要的作用是让你在调参时能对照实验:改了一个参数,收益变了,到底是参数的效果还是随机噪声?固定种子情况下收益变化可归因于参数调整,否则整个调参过程都是无效操作。多随机种子评估应该放到最后验证阶段,而不是每次调试都换不同种子。

6. 用多模型集成稳住策略:从单次训练到可信效果的进阶验证

6.1 多随机种子集成:用源码基础结构把单模型变成策略组合

单个 DQN 模型在日线交易上的稳定性通常不够,随机种子一变、结果就飘,这其实是正常现象。我的做法是从“训一个模型”升级成“训 N 个模型再投票”。用 5 个不同的随机种子训练 5 个 Q 网络,每个网络独立生成动作信号,最终动作取多数投票。当三个以上模型同时选择买入时,信号的一致性明显提高,假突破带来的噪声被中和掉。

6.2 多空市场切片验证:不追求永远赚钱

最后一个习惯是按行情状态切片复盘:把测试期按年或者按市场状态分成若干段,分别计算每段的收益、回撤和交易次数。如果模型的超额收益全部来自牛市段,熊市段全靠躲跌,那策略本质上是一个择时器;如果收益分散在各类行情里,说明模型学到的是更泛化的状态-动作关系。这两类策略的交易逻辑和实盘预期完全不同,提前知道比事后吃大亏好。

我现在再看一份交易策略源码时,第一眼不看它的收益曲线,先看它的训练集与测试集有没有严格隔离、随机种子有没有固定、交易成本有没有扣干净,这三个基础点在课程设计源码和开源的免费 Python 源码里恰恰最容易被跳过。用这套 DQN 源码方案做交易策略,真正的门槛不是算法本身,而是把所有“看起来小”的细节钉死。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询