深度强化学习股票交易实战:PPO/A2C/DDPG集成与MDP建模避坑指南
简介基于深度强化学习的自动化股票交易策略设计源码是一套面向金融研究者、量化交易爱好者及高校学生的完整实验项目。项目以PPO、A2C和DDPG三种Actor-Critic算法为核心构建了从环境模拟、数据预处理到策略训练、回测验证的闭环流程可帮助读者理解智能体如何通过交互学习最优交易动作降低人工交易中的情绪干扰。资源共40个文件约6.4MB以Python脚本为主干覆盖模型定义、训练与交易环境等模块CSV数据文件提供道指成分股及ETF历史行情XML配置用于调整算法参数Jupyter Notebook则支持交互式回测与结果可视化目录结构清晰便于按模块研读和二次开发。该项目已吸引306人学习下载。通过研读源码读者既能掌握深度强化学习落地股票交易的工程方法也能获得一份可直接扩展的量化策略实验框架适合作为课题研究或入门进阶的参考资料。1. 深度强化学习做股票交易为什么「能不断自我修正」比「预测得准」更值钱一份包含 30 只成分股日线、市场指数和一个专门衡量市场是否异常的 turbulence 指数配上 PPO、A2C、DDPG 三个深度强化学习代理加上把训练、验证、回测接到同一套接口的工程骨架——这个自动化股票交易策略源码让我印象最深的是它对一个问题想得很清楚交易不是预测游戏而是决策游戏。监督学习模型追求涨跌预测准确率换个市场风格就快速失效深度强化学习代理的梯度来自环境奖励它学的是让账户净值逐步上行的决策序列而这才是自动化交易策略真正要优化的目标。想读懂这套源码关键在于弄明白环境怎么建模、奖励怎么塑形、三种算法如何在同一个框架里协作。这篇就把机制拆开讲透。2. 决策建模股票交易如何被改写成马尔可夫决策过程2.1 状态空间为什么「OHLCV 波动率」直接塞给神经网络还不够一个常见误区是把今天的开盘、最高、最低、收盘、成交量拼成一个向量丢给网络任务就成 MDP 了。真相是单日快照不满足马尔可夫性。股票价格行为是部分可观测的当前时刻的价格和成交量不足以决定下一秒的最优动作同一条 K 线出现在上升趋势和下跌趋势里含义完全不同。所以这套源码的关键一步是在状态里拼接时间窗口用最近 N 天的特征张量表示“市场上下文”让代理在决策时“看到”的不只是一根 K 线而是一段趋势。具体到文件preprocessing/preprocessors.py 干的就是这件事。典型的流水线是读入 dow_30_2009_2020.csv30 只成分股日线、^DJI.csv大盘指数、ETF_SPY_2009_2020.csv可交易标的或基准之后先做缺失值填充和复权处理再按滚动窗口生成特征。常见的实现思路是先算技术指标——简单移动平均、指数移动平均、RSI、布林带这类量价衍生特征——然后把每只股票在窗口内的价格序列做归一化拼成一个形如窗口长度, 股票数, 特征数的张量。这里的归一化不是在整个数据集上做而是在滚动窗口内逐步计算否则训练阶段就会偷看到未来的价格分布。为什么 turbulence index 也必须在状态里占一席dow30_turbulence_index.csv 记录的是滚动窗口内 30 只成分股收益的离散程度。市场剧烈分化时个股相关性骤降某几个股票暴涨暴跌会把指数瞬间拉高此时趋势类特征全面失真动作的方差会被放大。把 turbulence 作为额外特征或作为模型切换开关相当于给代理装了一个“市场状态雷达”这是单看价格序列做不到的。源码里它同时出现在特征和风险控制两个层面后面避坑章会讲清楚阈值设错会出什么事。2.2 动作与奖励连续仓位如何演化交易成本又为什么必须写进奖励动作空间的设计直接决定策略风格。把动作定义成“买/卖/持有”三分类是最省事的但结果必然粗糙三分类没有“买多少”的信息模型只能靠反复切换仓位来表达力度于是换手率虚高。这套源码的思路是连续动作空间对每只股票输出一个在 [-1,1] 区间内的仓位调整信号正数表示在当前权重上加仓负数表示减仓再通过一个变换映射到实际持仓权重。这样代理既能表达“小幅加仓”也能表达“清仓离场”策略表达能力比三分类高一个量级。奖励塑造是整个源码里最接近“玄学”的部分但也是最有规律可循的部分。最朴素的奖励是账户总价值的单步变化率第 t 步组合净值减去第 t-1 步净值再除以第 t-1 步净值。问题在于如果不把交易成本写进奖励模型会发现“频繁调仓”是免费的——每一步都可以朝着当日最优方向微调回测曲线平滑得不像话一到实盘就被手续费和滑点打回原形。所以环境里必须引入成本惩罚在奖励函数中减去与换手量成正比的项让模型在“调仓带来的期望收益”和“调仓付出的成本”之间做权衡。成本系数设多大会直接改变策略的保守程度这也是避坑章要展开的重点。2.3 环境封装StockTradingRLEnv 的一次 step 里发生了什么提到环境就要看 env 目录下那三个文件名EnvMultipleStock_train.py、EnvMultipleStock_validation.py、EnvMultipleStock_trade.py它们共享同一套状态转移逻辑差异在初始化方式和使用目的。一次典型的 step 可以这样拆# 示意StockTradingRLEnv 中 step 的核心逻辑结构与 env/StockTradingRLEnv.py 对应 def step(self, actions): # actions: 每只股票的仓位偏移范围约 [-1, 1] for i, action in enumerate(actions): # 先裁剪防止模型输出非法仓位导致现金为负 clipped np.clip(action, -1.0, 1.0) # 计算目标持仓与当前持仓的差额并按当步价格成交 target_weight self.current_weights[i] * (1 clipped) trade_volume target_weight - self.current_weights[i] self.cash - trade_volume * self.current_price[i] # 扣除手续费直接降低账户净值 self.cash - abs(trade_volume) * self.transaction_cost * self.current_price[i] self.current_weights[i] target_weight # 更新持仓市值并计算新净值 new_value self.cash np.dot(self.current_weights, self.prices) reward (new_value - self.prev_value) / self.prev_value self.prev_value new_value self.t 1 return self.get_observation(), reward, self.t self.max_steps, {}这里最值得注意的细节是交易成本直接在现金里扣而不是在奖励函数里事后打折。这保证了累计净值曲线本身就包含了成本消耗回测结果更接近真实账户reward 用的是组合净值的相对变化率而不是绝对变化量这样不同资金规模的实验之间可以直接对比。状态转移的概率 P 完全由历史价格序列驱动模型没有“模拟器”可用只能在真实历史数据上反复走位这也是金融强化学习环境和游戏环境最本质的差别。3. 三大算法PPO、A2C、DDPG 在同一套演员-评论家骨架下的三种梯度流3.1 A2C优势函数如何给策略梯度降噪A2C 是 Advantage Actor-Critic 的缩写。它的策略梯度可以写成 E[ A_t · ∇log πθ(a_t|s_t) ]。这里的关键是 A_t——优势函数而不是直接用回报 G_t。原因在于回报的绝对值波动剧烈牛市里每一步的回报都为正策略会认为“所有动作都正确”梯度被整体抬高熊市里同理被拉低。减去一个基线评论家网络估计的状态价值 V(s_t)得到“这一步动作比平均水平好多少/差多少”的相对信号梯度的方差才会降下来。这个方差问题在股票数据上尤其严重收益分布肥尾偶尔一天涨 5%Q 值瞬间被顶到很高后续几步的动作都会带偏。A2C 的工程实现里还有一个关键细节n 步回报估计算法。单步的 TD 误差在部分可观测环境下偏差很大而用完整序列的蒙特卡洛回报又噪声过高所以常规做法是在 N 步之后截断用中间奖励之和加上最后一步的价值估计来替代完整回报。env/EnvMultipleStock_train.py 这类文件的存在就是为了让 A2C 能从同一段环境中一个接一个地采样轨迹模型通过若干完整回合的交互来估计优势再更新策略和价值网络。由于 A2C 是 on-policy一段轨迹用完即弃它对样本的利用效率不高但在低信噪比的金融数据上这种“用完就丢”反而避免了旧数据对策略更新的污染。3.2 PPOclip 机制如何保证策略更新不「突变」PPO 和 A2C 的更新逻辑长得像但加了一个决定性的限制。A2C 在每一步采样后只做一次梯度更新而 PPO 允许同一批轨迹数据被反复用于多次更新代价是它要保证新旧策略不能偏离太多。如果直接用旧轨迹去更新新策略策略分布会越走越偏这就是 on-policy 算法使用 off-policy 数据时的经典病灶——旧数据给出错误梯度方向策略可能一次更新就崩掉。PPO 用重要性采样比率 r_t πθ(a_t|s_t) / πθ_old(a_t|s_t) 来修正数据偏差再用 clip 操作把比率限制在 [1-ε, 1ε] 内。# 示意PPO 策略目标中的 clip 项对应 models.py 中 agent 的损失构造思路 ratio torch.exp(new_log_probs - old_log_probs) surr1 ratio * advantage surr2 torch.clamp(ratio, 1.0 - clip_eps, 1.0 clip_eps) * advantage policy_loss -torch.min(surr1, surr2).mean()当旧策略认为某个动作概率很高而新策略把它调低时比率远小于 1优势越大损失惩罚越大clip 又保证即使优势极大更新步长也被限制。放在股票场景里看这个机制的价值很直接市场突然大涨模型不能因为这一天的“好运气”就把策略全部押注到追涨上clip 限制了单日经验对策略的改造幅度。训练多个回合后PPO 的策略曲线往往最平滑这也是为什么它被用在长期持仓的主策略上。3.3 DDPG确定性策略与经验回放怎样稳定连续动作DDPG 是三个算法里最特殊的一个。A2C 和 PPO 输出的是动作概率分布DDPG 直接输出确定性动作 aμ(s)——它没有“掷骰子”的随机性。配合经验回放DDPG 能反复利用过去的样本这一点在股票历史数据上很有价值2009 到 2020 的日线数据只有几千条on-policy 算法对样本的利用率太低DDPG 把 (s, a, r, s) 存进回放缓冲区每次更新随机抽样批量来打破相邻样本之间的时间相关性。但确定性策略带来一个新问题探索不足。所以常规实现会向动作添加噪声比如 Ornstein-Uhlenbeck 噪声或简单高斯噪声让代理在训练初期“乱动”来收集多样性的反馈。DDPG 的另一个机制是目标网络。演员和评论家各维护一套主网络和一套软更新的目标网络目标参数不是定期硬拷贝而是每步按一个很小的比例向主网络滑动。没有这套设计Q 网络在自己给自己产生回归目标时会不断发散这是 off-policy 连续控制最经典的翻车点。交易场景里 DDPG 的优势集中在低波动期它能像“拧螺丝”一样细腻地调整每只股票的权重把组合的潜在收益一点点抠出来。3.4 集成策略为什么三份模型叠加而不是只挑「回测最好」的那个不同算法在同样的历史数据上训练完之后不会收敛到同一个策略。A2C 探索性强、更新频繁对趋势转折反应快但方差大PPO 稳定、每一步更新都被 clip 钳住适合长期累积收益但可能错过急转弯DDPG 对连续仓位控制细腻在平淡行情里优化最充分但过度依赖近期数据风格切换后会持续亏损一段时间。把它们各自独立训练成三个代理再在决策时集成是一种常见的稳健设计思路。算法输出类型样本利用率主要风险在集成里的定位A2C动作概率分布低on-policy更新方差大、对噪声敏感快速反应适合趋势跟踪PPO动作概率分布低但同批数据多轮更新对单日极端值不敏感可能过于平滑稳定主攻适合长期持仓DDPG确定性动作高off-policy 经验回放探索不足、对近期数据过度拟合精细调仓适合低波动期集成的方式有很多种简单投票、按净值加权、或者用市场状态做硬切换——当 turbulence 指数超阈值时切到最保守的模型低于阈值时让信号按份额加权。这套思路的关键动机是不知道未来市场会落入哪种状态区间。数据分训练与测试只是模拟市场风格缓慢上涨、高波动震荡、急跌后反弹不可能在单一时间序列中被覆盖完全。三个模型对同一段状态可能有分歧分歧本身就是一种风险信号当三个模型都看多时信号置信度更高当模型之间矛盾时应该降低仓位而不是取平均。集成策略在这里不是“平均三个模型”而是“利用模型间分歧来做风险管理”。4. 训练与回测闭环run_DRL.py、preprocessors 与三份环境文件如何拼起来4.1 从 config.py 到 run_DRL.py主流程为什么能一键跑完训练验证回测看文件清单能发现一个很典型的单入口工程结构config/config.py 集中了算法超参、环境路径、训练轮数、评估频率等一切可变项run_DRL.py 是唯一直接执行的脚本models.py 定义网络和三种算法代理。主流程可以被抽象成三段# 示意run_DRL.py 主流程骨架 def main(): cfg load_config(config/config.py) # 第一步构造训练环境与验证环境 train_env build_env(cfg, env_typetrain) val_env build_env(cfg, env_typevalidation) # 第二步训练循环每隔 eval_freq 轮在验证环境上评估一次 for episode in range(cfg.episodes): state train_env.reset() while not done: action agent.choose_action(state) next_state, reward, done, info train_env.step(action) agent.learn(state, action, reward, next_state, done) state next_state if episode % cfg.eval_freq 0: evaluate(agent, val_env) # 第三步回测 backtest_env build_env(cfg, env_typetrade) results run_backtest(agent, backtest_env) save_results(results, results/)这段代码值得注意的不只是“能跑通”而是三个环境用同一套 build_env 接口差别只在 env_type 参数。训练环境负责采样验证环境用来早停和选模型trade 环境用来做不可干预的回放式回测。这种分层避免了业界常见的翻车很多人拿训练环境直接回测结果每次 reset 重新随机初始仓位回测出来的净值和实盘的起点逻辑根本对不上。trained_models 目录里保存的是三种算法在验证集上表现最好的权重results 目录里的 CSV 记录了每个时间步的净值、持仓、换手率供 Jupyter Notebook 里的 backtesting 做事后分析和画图。4.2 数据管线指数、30 只成分股与 turbulence 指数怎样组装成观测数据目录里的 CSV 各有各的用途。dow_30_2009_2020.csv 是核心的面板数据每列一只成分股、每行一个交易日的价格序列^DJI.csv 是市场指数的日线用来计算大盘趋势类特征ETF_SPY_2009_2020.csv 除了可以做基准对比还可以作为可交易的宽基标的纳入状态或动作空间。dow30_turbulence_index.csv 则是前面提过的市场异常度标量。数据管线的组装顺序有讲究。常见流程是先用 preprocessors.py 把 CSV 读入并对齐交易日历接着在滚动窗口上计算技术特征和时间序列归一化然后把每个时刻的窗口数据加上 turbulence 标量、指数收益率等全局特征拼成模型输入的观测向量。这里最容易犯的错是在整个数据集上做归一化正确做法是只使用截至当前时刻的历史窗口统计量进行归一化。preprocessors 这一层如果出了问题后面模型再怎么调参都是在水面上绣花。另一个容易忽略的点是交易日对齐指数、成分股和 turbulence 的日期索引必须严格取交集否则会出现“用当天的指数预测当天的成分股”这类时间错位。4.3 train / validation / trade 三份环境文件的具体分工三个环境文件名后缀不同的原因在于职责边界。train 环境允许随机重启、可以打乱起点目的是让代理见过尽可能多样的状态组合validation 环境在训练中固定起点序列保证每次评估的初始条件一致从而能公平地比较模型在不同训练阶段的水平trade 环境则强调“不可回退”——状态只能顺着时间往前推不允许随机重置用来模拟真实盘中连续决策。这就是 Jupyter Notebook 里的 backtesting 才有意义的原因它把 trade 环境的完整轨迹画成净值曲线让研究者看清楚最终策略在样本外序列上的表现。环境切换时还有一个细节容易忽略train_env 和 trade_env 里对动作 clip 的边界、初始现金、手续费系数必须完全一致否则训练好的策略在 trade 环境里会因为动作空间不同而失真。比如训练环境把动作 clip 在 [-1,1]trade 环境却允许 [-1.5,1.5]模型在决策时就会输出训练时从未见过的动作尺度策略表现自然崩坏。这个问题在集成策略里尤其隐蔽因为三个模型各自的环境可能被分别修改很难一眼察觉。5. 避坑回测收益很漂亮一上实盘就翻车的五个细节5.1 走读数据泄露归一化参数里混进了未来信息现象回测曲线漂亮得不真实训练集和验证集的夏普都异常高但模型在样本外连续亏损。原因preprocessors.py 在做特征归一化时如果用全量数据的均值方差去缩放训练窗口模型在 t 时刻的输入里其实已经包含了 t 时刻之后的分布统计量。深度强化学习的环境观测在这种条件下等于被“预知”了未来。解决把归一化改成滚动窗口算法——用截至当前时刻的均值、方差对当前窗口做标准化验证集和回测集沿用训练阶段拟合的统计量绝不重新计算。对归一化参数做一次“时间线审查”是金融强化学习项目里成本最低、收益最高的排查手段。5.2 手续费惩罚不匹配策略为何迷恋「每天全仓换手」现象训练完成后发现策略每天全仓调仓换手率高得惊人回测收益却含手续费后依然为正。原因奖励函数里的 transaction_cost 系数比真实佣金和滑点低一到两个数量级模型发现“每日调仓”所带来的方向性收益总能覆盖微小的成本惩罚于是把换手率拉满。解决把成本系数调到真实市场同量级——佣金、印花税加上一定的冲击成本。更强的经验法则是让成本系数略高于真实值宁可让策略少动也不能让它在回测里假繁荣。调整后观察换手率是否明显下降如果策略收益曲线依然平稳才说明它学到的不是“靠换手蹭收益”的伪策略。5.3 turbulence 阈值失效市场已进入异常波动而模型毫无反应现象大跌行情里 turbulence 指数明显越过阈值但代理仍然高仓位运行回撤失控。原因turbulence 的使用方式通常是两种作为开关在超阈值时停止交易或作为特征让模型自己判断。前者的问题是阈值设置得过高只有极端行情才触发后者的问题是模型在训练数据里没见过几次超阈值状态学习不到避险动作。解决复盘历史上几次急跌统计 turbulence 在急跌前的分布把阈值定在分位数附近而不是拍脑袋。对开关式策略超阈值时强制清仓或切换保守模型对特征式策略在训练时对超阈值样本做上采样让代理对这种状态有足够经验。检查方法很简单打印训练过程中 turbulence 超阈值的天数占比如果少于 1%模型大概率没见过真正的“坏市场”。5.4 随机种子设了却不复现框架级随机性在作怪现象代码里设置了随机种子重启进程后训练结果仍然不一致实验无法对比。原因深度强化学习涉及的随机源不只有 np.random 和 torch。手动 seed 了标准库但没有对数据加载器的 shuffle 顺序、多线程的采样顺序做约束甚至 GPU 上的非确定性算子也会引入漂移。股票数据量小顺序抖动对结果的影响被放大。解决在训练入口统一设置 np.random.seed、torch.manual_seed 和随机数据加载的固定种子并把 shuffle 改成显式索引重排。如果追求完全可复现就固定 CPU 线程数并打开框架的确定性模式至少保证在同一硬件和同一环境版本下能复现。我在调试集成策略时吃过一次亏三个模型分别训练都能复现合起来结果就漂最后发现是集成阶段用了未 seed 的随机采样来决定模型切换。5.5 回测成交价过于理想收盘价成交不等于真实滑点现象单笔交易在回测中按收盘价成交但把它搬上模拟盘后每次下单的实际成交价与收盘价偏离明显净值曲线被四周的滑点磨损。原因回测环境中 step 的价格取的是当根 K 线的收盘价且假设模型下单即成交、不产生冲击成本。真实市场上按市价单交易会吃掉盘口价差按限价单交易又可能不成交。解决回测时把成交价改造成次一期的开盘价或在当前价格上加减一个滑点比例。集成策略下尤其要检查 DDPG 这类高频调仓模型它的换手率高对滑点的放大效应最明显。用“次日开盘价成交”回测一遍如果收益曲线衰减超过 20%说明策略对成交价过于敏感实盘风险极高。6. 设计取舍奖励塑形、集成策略与「把模型当工具而非印钞机」的边界如果从头重做这套策略我不会把全部力气花在调网络结构上而是花在奖励塑形和评估协议上。因为股票数据本身就是低信噪比序列网络结构带来的边际收益远不如奖励函数里每一个数值对策略行为的塑造。用连续仓位动作替代离散买卖是值得的取舍它让策略有了表达“小幅试探”的能力但代价是动作空间变大、训练更难收敛需要更频繁的验证和早停。集成策略则是一个更微妙的决策它用三个模型来对冲市场状态的不确定性换来了更强的稳健性代价是解释性变差——出了问题不知道是哪个模型在决策排障成本成倍上升。我见过某开发者在验证集上反复比较单独模型和集成模型的净值曲线花了整整一周时间最后发现 A2C 子模型在训练过程中随机重启时引入的噪声被当成了策略改进记录进了 results 里。从那以后我每次换数据或换环境都强制走一遍审查流程先把预处理输出和第一步观测的数值打印出来确认没有跨窗口的统计量混进来再检查交易成本系数和真实佣金是否同量级最后才让模型开始训练。这套源码给的不是一个必胜策略而是一套完整的机制框架——环境怎么建模、奖励怎么塑形、算法怎么协作。以前总想着训练出更聪明的模型来战胜市场碰了几次壁后学会把模型当工具而不是印钞机。它的价值在于帮你验证策略假设、暴露你在奖励设计和数据管线里的盲区而不是承诺一条确定盈利的净值曲线。希望帮到你。本文还有配套的精品资源点击获取