简介:面向量化交易与投资组合管理方向的研究者,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.py,run 起来就等结果,跑完发现不知哪里错,这种用法等于把网络训练当黑匣子。先花半小时把文件结构过一遍,后面排错能省半天。
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.py | DQN 更新逻辑 | 想从 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, window=10, initial_cash=10000.0, feature_cols=None): self.df = df.reset_index(drop=True) 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_index=None): 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_dim=64): 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 个指标、窗口 10,state_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_index=start_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(drop=True) 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(close='close', length=14, append=True) # RSI df.ta.macd(close='close', fast=12, slow=26, signal=9, append=True) # MACD 三列 df.ta.cci(high='high', low='low', close='close', length=14, append=True) df.ta.adx(high='high', low='low', close='close', length=14, append=True) 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, window=10): 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。下面这份参数表我沿用到现在,适合日线频率的单标的训练:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| window | 10 | 状态窗口,太短看不到趋势,太长状态维度过高 |
| batch_size | 512 | 回放小批量大小,数据量小,设大一点梯度更稳 |
| lr | 1e-4 | 学习率,超过 1e-3 基本必发散 |
| gamma | 0.95 | 折扣因子,日频交易未来十几天内的影响就应该衰减掉 |
| target_update_freq | 20 episodes | 目标网络同步频率,太频繁等于没有目标网络 |
| epsilon_min | 0.01 | 最小探索率,保证测试时也有一定随机性 |
| epsilon_decay | 0.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,或者用资产变化的百分比作为 reward:
reward = (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[i+1] - 60] test = df.iloc[split_points[i+1] - 60: split_points[i+1]] 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(f'median: {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 的滚动验证。这个流程已经帮我挡掉了大量“回测很猛、实盘就蔫”的情况,也省掉了很多反复调参却始终不知道问题在哪的夜晚。希望这套排查顺序能帮你也少走一段弯路。
本文还有配套的精品资源,点击获取