ARTICLE DETAIL

资讯详情

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

深度强化学习交易实战:拆解DeepTrader源码中的DQN与奖励机制

深度强化学习交易实战:拆解DeepTrader源码中的DQN与奖励机制 简介面向量化交易与投资组合管理方向的研究者DeepTrader源代码复现包解决的是如何利用深度强化学习实现风险收益平衡的组合管理问题底层方法可对照参考论文《DeepTrader: A Deep Reinforcement Learning Approach for Risk-Return Balanced Portfolio Management with Market Conditions Embedding》逐模块理解。压缩包内含24个文件整体约26KB以Python源码、Jupyter Notebook、XML配置与NumPy数据文件为主py脚本支撑策略训练与回测ipynb提供交互式示例npy保存市场特征数据目录结构清晰便于按模块阅读和二次开发。已有466人学习使用适合具备一定深度强化学习基础、希望复现论文实验的读者。资源附带手写中文注释可免去对照论文逐行梳理的精力快速定位状态嵌入、市场条件编码、组合优化等关键代码段同时保留原始项目配置与数据类型方便直接运行与调试对理解风险收益平衡训练流程和改造自身策略都有参考价值。1. DeepTrader 源代码在解决什么问题让交易决策从规则变成奖励函数DeepTrader 是一套用深度强化学习做单标的日频交易的源代码项目。别把它当成股价预测模型——它不输出涨跌概率而是直接输出在当前K线位置上“买、卖、持有”三个动作之一用最大化账户资产的奖励来反向训练网络。第一次跑完你会觉得回测曲线很漂亮但等它上样本外数据就现原形这就引出了核心问题环境、代理、训练循环这三个部分到底怎么配合以及哪些地方容易被写错。它适合两类人。一类是已经写过策略、被“规则过拟合”折磨够了的量化新手想看看强化学习能不能自动发现规则另一类是刚接触 DQN 的工程师想找一个状态、动作、奖励定义都齐全且能落到真实行情上的练习项目。这套代码不复杂读明白它你对“序列决策问题怎么建模”会有很直接的体感。2. 拿到源码先认骨架环境、代理与训练循环的分工DeepTrader 的项目规模不算大几十个文件里面最值钱的不是模型结构而是环境定义和奖励写法。很多人拿下来直接跑 main.pyrun 起来就等结果跑完发现不知哪里错这种用法等于把网络训练当黑匣子。先花半小时把文件结构过一遍后面排错能省半天。2.1 从文件结构猜它想怎么工作# 常见的 DeepTrader 复现版目录结构细节因 fork 而异 DeepTrader/ ├── config.py # 全局参数路径、学习率、epsilon、窗口长度 ├── main.py # 训练入口读数据 - 建环境 - 跑代理 - 存模型 ├── environment/ │ ├── __init__.py │ └── stocks_env.py # 交易环境状态、动作、奖励、终止条件 ├── agent/ │ ├── __init__.py │ ├── network.py # Q 网络定义 │ ├── replay.py # 经验回放缓冲区 │ └── agent.py # DQN 代理采样、更新、目标网络 ├── utils/ │ ├── data_loader.py # 行情读取与清洗 │ └── indicators.py # 技术指标计算 └── test.py # 加载模型做回测/样本外测试这个结构里值得关注的是 environment 和 agent 两个目录。DeepTrader 的核心不是某个更强的网络而是把“交易一整段K线”建模成强化学习里的一个 episode你从某根K线进入不断做决策直到数据末尾每一步根据账户资产变化拿到奖励。主循环里做的无非是“环境给状态 - 代理给动作 - 环境返回下一状态和奖励 - 存进回放缓冲区 - 小批量更新网络”这是所有 DQN 类项目的标准流程。文件职责你改它的时机config.py所有超参数集中地调参第一站几乎所有问题都从这里开始stocks_env.py定义成交、仓位、奖励想加手续费、加多标的、改单次买卖数量时replay.py经验缓存与随机采样一般不动除非你想优先采样好经验agent.pyDQN 更新逻辑想从 DQN 换成 DDQN 或 Dueling DQN 时indicators.py特征列计算想加入新指标或换数据频率时2.2 交易环境状态、动作、奖励如何定义DeepTrader 的交易环境实际上是一个离散动作的马尔可夫决策过程。状态是过去 N 根K线的一组技术指标动作只有三个奖励则来自账户资产变化。下面是最常见的一种实现形态注意不同 fork 的动作编号可能相反看源码时先看注释。# environment/stocks_env.py —— 简化后的常见实现形态 import numpy as np class StockEnv: def __init__(self, df, window10, initial_cash10000.0, feature_colsNone): self.df df.reset_index(dropTrue) self.window window self.initial_cash initial_cash self.feature_cols feature_cols self.current_step window # 从第 window 根K线开始 self.cash initial_cash self.stock 0 # 持仓股数整数 self.last_asset initial_cash self.done False def reset(self, start_indexNone): self.current_step start_index or self.window self.cash self.initial_cash self.stock 0 self.last_asset self.initial_cash self.done False return self._get_state() def step(self, action): # 动作约定0持有/等待, 1买入, 2卖出 price self.df[close].iloc[self.current_step] if action 1 and self.cash price: self.cash - price self.stock 1 elif action 2 and self.stock 0: self.cash price * self.stock self.stock 0 self.current_step 1 # 用总资产变化量作为奖励注意这里没有扣手续费 new_asset self.cash self.stock * self.df[close].iloc[self.current_step - 1] reward new_asset - self.last_asset self.last_asset new_asset self.done self.current_step len(self.df) - 1 return self._get_state(), reward, self.done, {} def _get_state(self): # 返回最近 window 根K线的特征矩阵展平后作为网络输入 return self.df.iloc[self.current_step - self.window: self.current_step] \ [self.feature_cols].values.flatten()这里的奖励只用“总资产差值”没有对收益率做归一化也没有惩罚换手。价格在几十元的股票和几百元的股票之间reward 尺度完全不同后面训练发散十有八九和这个有关到时候你会回来给 reward 除以一个缩放系数。_get_state返回的是展平特征但如果你仔细看会发现它没有把“当前持仓 0/1”和“现金余额”拼进状态向量。模型并不知道自己手里有没有股票很多复现版在这一点上处理得很随意导致同一个状态下买入和卖出决策缺乏上下文。一般我会把stock和cash也拼进状态向量或者至少把当前持仓状态拼进去模型才能学明白“已经持仓时应该等还是卖”。这也是 DeepTrader 这类单标的环境里最容易忽略的信息加上之后效果通常比调两层网络更明显。另外这个实现里买入是逐股加仓没有仓位上限如果只想做满仓/空仓把self.stock当布尔值用即可。2.3 代理端为什么选 DQN 而不是策略梯度DeepTrader 的动作空间只有三个天然是离散动作问题用 DQN 比用 DDPG 或者 A2C 更自然。DDPG 是给连续动作设计的你要硬用也可以但每个动作还得先映射成连续值再切回离散麻烦不说还容易震荡。A2C 这类 on-policy 方法对样本效率要求高交易数据本来就不长一局跑完才几百个样本浪费严重。DeepTrader 常见实现用的是 Double DQN核心思路是用当前网络选择最优动作用目标网络评估这个动作的 Q 值避免标准 DQN 里“同一个网络又选动作又打分”导致的高估问题。Q 网络本身很简单一个两层全连接加上 ReLU 就够用# agent/network.py —— 两层全连接 Q 网络 import torch.nn as nn class DQN(nn.Module): def __init__(self, state_dim, hidden_dim64): super().__init__() self.net nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, 3) # 输出三个动作的 Q 值 ) def forward(self, state): return self.net(state)state_dim 等于“特征列数乘以窗口长度”。如果你用 6 个指标、窗口 10state_dim 就是 60。hidden_dim 不用太大64 已经偏大交易数据量小网络宽了只会更快过拟合训练段。这类项目真正吃效果的地方在环境定义和奖励函数网络深了反而让问题变复杂这是新手容易走偏的地方总想加层数其实大多数时候问题在 reward。2.4 训练循环收集、回放、更新与 epsilon 衰减训练循环是所有 DQN 项目的骨架DeepTrader 也不例外。常见做法是每个 episode 从行情中间随机取一个起点然后一路运行到数据末尾这样每条训练序列都是一段连续的真实走势每次执行动作产生的五元组存进回放缓冲区等缓冲够了一批再开始小批量更新目标网络隔若干轮同步一次探索率按指数衰减。# 训练主循环main.py 中的核心片段 import random num_episodes 300 epsilon 1.0 epsilon_min 0.01 epsilon_decay 0.995 for episode in range(num_episodes): start_index random.randint(env.window, len(df) - 200) state env.reset(start_indexstart_index) # 随机起点 done False while not done: action agent.choose_action(state, epsilon) # epsilon-greedy 探索 next_state, reward, done, _ env.step(action) agent.replay.store(state, action, reward, next_state, done) state next_state if len(agent.replay.buffer) agent.batch_size: agent.update() # 从回放里抽 batch 做一次梯度更新 if episode % 20 0: agent.update_target_network() epsilon max(epsilon_min, epsilon * epsilon_decay)注意这个循环只在每个 episode 结束时做一次网络更新而不是每步都更新。这样写梯度更稳但学习节奏慢另一些实现是每次存储后都 update训练更快但更容易震荡。两种都有人用我的习惯是每个 episode 结束后更新一次因为交易序列的 reward 噪声本身很大更新太频繁反而放大波动。epsilon 从 1.0 衰减到 0.01大约 300 个 episode 后基本接近纯利用如果这时测试曲线还是不行问题基本可以断定在环境定义而不是训练参数。3. 把 DeepTrader 跑起来数据清洗、特征构造与最小训练入口很多拿到源码的人第一件事是找数据接口四处找数据源能不能直接接。我的建议反而不是先接数据而是先用一份你完全信任、格式简单的 CSV 把它跑通。数据是这套代码最容易出隐形问题的地方一份几百根K线的 ETF 日线数据足够让训练先转起来。3.1 一份干净的日线行情 CSV 是一切的前提DeepTrader 对数据的要求很简单日期、开高低收、成交量按时间升序日线级别。不要直接拿数据库里原始表往里灌很多行情接口导出的数据包含停牌、缺失、未复权价格这些都会让训练结果失真。先写一个清洗脚本import pandas as pd df pd.read_csv(daily.csv, parse_dates[date]) df df.sort_values(date).reset_index(dropTrue) df df[[date, open, high, low, close, volume]].copy() # 基础校验有没有空值、有没有重复日期、数据长度够不够 assert df[close].notna().all(), 存在缺失收盘价 assert df[date].is_unique, 存在重复日期 assert len(df) 300, 至少需要 300 根日K否则训练段和测试段都切不出来 # 零成交天数过多说明标的不适合训练 zero_vol_days (df[volume] 0).sum() assert zero_vol_days 10, 停牌/零成交天数过多换一只流动性好的标的这段代码不是为了把数据洗得多干净而是把最常见的几个坑先挡住空值、重复日期、数据太短。很多训练发散不是模型问题而是数据里混了几行 NaN前向传播直接变 NaN。如果手上暂时没有 CSV也可以用交易所公开的指数数据或场内 ETF 数据不要用单只小票起步流动性差、跳空多训练出来的策略全是噪声。关于复权我一般统一用后复权价格。后复权会改变近期价格但训练和回测用的是同一套口径模型学的是相对变化影响不大用不复权数据遇到除权日会出现假跳空模型会以为发生了什么大事这个坑在第 4 章专门展开。3.2 特征构造把指标算成状态而不是把形态当信号DeepTrader 的状态通常由技术指标组成。你需要把原始行情扩展成“原始行情 指标列”然后切成固定窗口。用 pandas_ta 或 TA-Lib 都可以下面这份代码展示用 pandas_ta 的写法# utils/indicators.py import pandas as pd import pandas_ta as ta def add_indicators(df: pd.DataFrame) - pd.DataFrame: df df.copy() df.ta.rsi(closeclose, length14, appendTrue) # RSI df.ta.macd(closeclose, fast12, slow26, signal9, appendTrue) # MACD 三列 df.ta.cci(highhigh, lowlow, closeclose, length14, appendTrue) df.ta.adx(highhigh, lowlow, closeclose, length14, appendTrue) df[bb_pos] (df[close] - df[BBL_20_2.0]) / (df[BBU_20_2.0] - df[BBL_20_2.0] 1e-9) return df feature_cols [RSI_14, MACD_12_26_9, MACDs_12_26_9, MACDh_12_26_9, CCI_14_2.0, ADX_14, bb_pos, close]这里有个细节很多复现版把close本身也留在特征里如果不做缩放价格绝对值会主导网络前几层的梯度。你在图上看 MACD 双底、金叉死叉觉得很直观但模型眼里这些都只是数值特征它自己去组合不需要你把它转成“上升趋势”之类的语义。要做的不是给模型提供信号而是把窗口数据整理成一个形状固定的矩阵import numpy as np def build_states(df, feature_cols, window10): values df[feature_cols].fillna(0).values states [] for i in range(window, len(df)): states.append(values[i - window:i].flatten()) # 展平成一个一维向量 return np.array(states), df[date].iloc[window:].values展平方式是 DeepTrader 这类实现里最常见的做法状态里包含最近 10 根K线的全部特征模型需要自己学会“最近走势如何”。注意fillna(0)是对指标初始段的处理这一段不参与训练也没关系但如果不填 NaN网络前向传播会直接挂掉。指标计算是从第 14 根开始才有值的所以实际可用状态从第 24 根K线附近才开始。3.3 最小训练入口关键参数与日志怎么看跑训练前先看一眼配置。DeepTrader 这类项目保存模型靠 checkpoint调参也集中在 config。下面这份参数表我沿用到现在适合日线频率的单标的训练参数推荐值说明window10状态窗口太短看不到趋势太长状态维度过高batch_size512回放小批量大小数据量小设大一点梯度更稳lr1e-4学习率超过 1e-3 基本必发散gamma0.95折扣因子日频交易未来十几天内的影响就应该衰减掉target_update_freq20 episodes目标网络同步频率太频繁等于没有目标网络epsilon_min0.01最小探索率保证测试时也有一定随机性epsilon_decay0.995衰减系数300 episodes 后接近纯利用训练入口一般长这样python main.py \ --data daily.csv \ --train_range 2015-01-01/2021-12-31 \ --test_range 2022-01-01/2023-12-31 \ --window 10 \ --batch_size 512 \ --lr 1e-4 \ --gamma 0.95 \ --episodes 300 \ --seed 42训练过程中每几十个 episode 会打印平均 reward 和 epsilon。判断训练是否正常的标准不是平均 reward 一直上升而是“前期震荡、中期上升、后期稳定”。如果 reward 全程乱跳先检查环境里是不是现金变成负数或者 state 里混入 NaN如果 reward 稳定上升但测试集资产曲线平得像直线则多半是过拟合训练段特征太多或者训练段太短。3.4 跑通后立刻改的三个地方第一个要改的是买入条件。很多实现里买入动作不检查现金是否足够盲目买入导致现金负值模型在负数现金上还能“赚回来”学出一堆虚假规律。硬性加一个条件就行上面环境代码里其实已经带了但你可以确认自己的复现版里有没有if action 1 and self.cash price: self.cash - price self.stock 1第二个要改的是手续费。先不讨论佣金比例对不对只要让模型知道“频繁交易有成本”就能压掉大量无意义的换手fee_rate 0.001 # 千分之一模拟单边佣金滑点 if action 1 and self.cash price: self.cash - price * (1 fee_rate) self.stock 1 elif action 2 and self.stock 0: self.cash price * (1 - fee_rate) * self.stock self.stock 0第三个地方是把每局资金曲线输出成 CSV而不是只看一个总分。我习惯在 test.py 里把每一步的账户总资产追加到一个列表最后pd.DataFrame落盘。有了这条曲线你可以看到模型是在哪一段行情里赚的钱是拿住了大波段还是靠高频来回磨出来的这两者的可信度完全不同。4. DeepTrader 源码避坑训练发散、未来函数与样本外失效这套代码里我见到最多的不是“效果不好”而是“结果看着好得很假”。以下 5 个问题是自己踩过、也在很多复现版源码里见过的坑按现象、原因、解决的方式写你可以把这一章当成排查手册来用。4.1 训练 loss 不降反升reward 一路下行现象训练日志里平均 reward 从正值一路掉到负loss 曲线像锯齿没有收敛迹象。原因最常见的不是网络结构而是 reward 绝对值太大。股票价格 100每次买卖资产变化几十上百DQN 对 reward 尺度非常敏感这个量级下 1e-4 的学习率也嫌大梯度来回震荡。还有个常见写法错误买入时不判断现金现金变负后每个后续 reward 都被带偏。解决先给 reward 除以一个固定缩放系数比如 100或者用资产变化的百分比作为 rewardreward (new_asset - self.last_asset) / self.last_asset同时检查现金约束的代码是否正确。如果改了 reward 尺度后 loss 稳定下降说明之前是尺度问题不是模型问题。4.2 样本内资产曲线漂亮样本外直接亏给 buy-and-hold现象训练段回测年化亮眼拉到测试段直接跑输“买入并持有”。原因交易数据只有几百根K线网络虽然只有两层容量也足够把这小段走势背下来特征列越多过拟合越容易。DeepTrader 只用了少量指标、窗口也不长本质上就是在用网络容量换泛化你加特征加层数反而破坏了它原本的边界。解决缩短训练段到总数据的前 60%-70%把特征列裁到核心的几个用滚动窗口整体评估。一个可落地的验证框架# 滚动窗口验证框架 split_points [240, 480, 720, 960] # 假设 df 有 1200 根K线 for i in range(len(split_points) - 1): train df.iloc[split_points[i]: split_points[i1] - 60] test df.iloc[split_points[i1] - 60: split_points[i1]] model train_dqn(train) # 每个窗口重新训练 result backtest(model, test) # 记录这一折的收益这种“折叠”方式模拟的是不同市场环境下模型的表现比单一训练/测试切分更接近真实。如果四折里有三折跑输持有那就别往实盘想问题出在模型根本没有学到可迁移的规律。4.3 指标里藏了未来函数回测成绩是假的现象用今天收盘价算出的 RSI、MACD走一步就用这个状态买入而且成交价也是今天收盘价。在日线频率下收盘价出来以后你最早只能在次日开盘成交当天成交的所有成绩都不可信。原因特征没有 shift把当日信息当成了决策输入同时成交价用了当日 close 造成未来函数。解决特征列整体 shift(1)成交价改用下一步开盘价# 成交价调整为下一根K线的 open避免同日 close 成交 exec_price self.df[open].iloc[min(self.current_step 1, len(self.df) - 1)]注意 shift 之后状态索引和成交索引差一位千万别把两个索引写错。排查方法也很简单在源码里搜索当前K线 price 对应的是 open、close 还是 high只有 next open 是可接受的凡是出现close且后面不带shift(1)的全部标红。4.4 分红除权让训练数据出现假跳空现象回测里某次分红后模型突然大量买入因为除权导致价格“跳空下跌”模型把这当成了大跌机会。原因用的是不复权数据除权除息造成价格断层不是真实下跌。用后复权数据时历史价格会因为复权因子被连续调整训练样本里不会出现这种断裂。解决统一用后复权价格做训练和测试。顺手在清洗阶段加一个跳空幅度过滤df[gap] df[open] / df[close].shift(1) - 1 df df[df[gap].abs() 0.2] # 单日跳空超过 20% 的K线直接丢掉这个过滤不区分跳空原因只把异常样本排除。真实市场里单日涨跌超过 20% 的行情极少出现在数据里基本都是除权、拆股或数据错误过滤掉不会损失正常信息。4.5 同一份数据跑两次结果完全不同现象同样的代码、同样的数据两个 seed 下训练出来的模型收益差很多有的赚有的亏。原因DQN 训练依赖经验回放随机抽样和网络随机初始化单次实验的方差极大这就是强化学习里常说的“seed 玄学”。解决不能用单次结果下结论至少跑三个 seed 取中位数import numpy as np results [] for seed in [1, 2, 3]: set_seed(seed) # 同时固定 torch/numpy/random model train_dqn(cfg) results.append(evaluate(model, test)) # evaluate 返回小数收益如 0.12 print(fmedian: {np.median(results):.2%}, std: {np.std(results):.2%})我会在代码里把所有随机源都收口到set_seed()里这样至少保证单 seed 可复现不然连问题定位都做不了。如果三个 seed 的测试收益中位数仍然跑不赢 buy-and-hold说明环境定义或者特征本身有问题而不是运气差。5. 让 DeepTrader 结果可信滚动验证、奖励塑形与验收口径DeepTrader 这类项目最后能拿得出手的结果不是“训练段三个月翻了 3 倍”而是“多个样本外窗口下稳定跑赢 buy-and-hold 且回撤可控”。如果你打算把这个方向继续做下去我建议把验收口径收成三条缺一不可第一测试段的标准化参数必须只从训练段统计出来任何混进测试段信息的预处理都算未来函数第二至少三个 seed 的中位数结果作为最终结论单次的最好和最差都不能代表模型能力第三报告里同时给出最大回撤和换手率一个靠每天买卖十几次堆出来的高收益没有参考价值。奖励塑形方面单纯的总资产差会让模型倾向于“能买就买能卖就卖”因为每次换手若产生微小正收益模型就会反复尝试。我一般会在奖励里加一项换手惩罚# 在 step() 中计算奖励后追加惩罚项 turnover 0 if action 1: turnover 1 # 这次动作发生了换手 elif action 2: turnover 1 reward (new_asset - self.last_asset) / self.last_asset - 0.02 * turnover0.02 的意义是一次换手至少要贡献 2% 的资产提升才不亏这个值要根据你的手续费和滑点调整手续费高的品种可以给到 0.05。调完之后观察训练曲线的换手次数如果模型明显安静下来但总收益没掉说明之前的收益确实有一部分是摩擦成本堆出来的。说到最后我的习惯是拿到任何一个 DeepTrader 复现版第一件事不是跑结果而是把环境里所有close成交点全部改成next open再跑一组三 seed 的滚动验证。这个流程已经帮我挡掉了大量“回测很猛、实盘就蔫”的情况也省掉了很多反复调参却始终不知道问题在哪的夜晚。希望这套排查顺序能帮你也少走一段弯路。本文还有配套的精品资源点击获取
返回列表