拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

深度Q学习网络在股票交易中的实战:从MDP建模到回测避坑指南

深度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根,特征选哪些

状态空间的第一个问题是“用多长的历史窗口”。窗口太短,模型看不到趋势;窗口太长,输入维度膨胀,全连接网络很容易过拟合到噪声上。我一般先用20~30个交易日做窗口起步,这个长度对日线级别的中期趋势已经够用,后续再根据验证结果往回缩或者往上加。

第二个问题是“用哪些特征”。不建议一上来就堆几十个指标,DQN的特征工程原则和传统机器学习一样:先少而精,再逐步扩展。我常用的基础特征组是这五类:

  • 对数收益率序列:能直接反映价格变化幅度,也天然做了尺度压缩
  • 收盘价相对过去N日均线的偏离度:相当于一个简单趋势强度信号
  • 成交量相对过去N日均量的比值:放量缩量的量化表达
  • RSI(14):超买超卖区间的刻画
  • 当前持仓状态:0表示空仓,1表示持有若干股

对应地,状态张量的形状大概是[batch, 窗口长度, 特征数]。如果你用全连接网络而不是LSTM,就需要把窗口内的特征摊平成[batch, 窗口长度 × 特征数]。下面这段是状态构造的常见写法:

import numpy as np import pandas as pd def build_state(df, idx, window=20, features=None, holding=0): """ 构造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(lower=0).rolling(14).mean().iloc[-1] loss = (-delta.clip(upper=0)).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_dim=3): 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(maxlen=20000) def train_step(q_net, target_net, optimizer, gamma=0.95, batch_size=64): 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(dim=1, keepdim=True) 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_norm=10.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, holding=0, cash=100000, fee_rate=0.0002): """ 模型推理阶段的回测循环 """ for i in range(window, len(df)): state = build_state(df, i, holding=holding) 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_rate=0.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_norm=10.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学习网络做股票交易这个方向,到底值不值得投入,核心在于你能不能建立一套“自己做决策,自己验证自己的决策”的闭环。把上面这些流程跑通之后,你手里留下的不只是几个模型文件,而是一套能持续迭代的实验环境——这也是我认为这个方向里最值得拿走的资产。希望这篇笔记里的踩坑经验和参数基准,能帮你少走几段弯路。

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

返回列表