☰
深度强化学习股票交易策略:从MDP设计到回测避坑指南
2026/9/28 3:05:51 网站建设 项目流程

简介:一套基于深度强化学习的自动化股票交易策略设计源码,面向量化研究者、金融分析师和具备Python基础的开发者,解决人工交易受情绪影响、难以适应市场动态变化的问题。项目用PPO、A2C、DDPG三种Actor-Critic算法训练交易代理,覆盖数据预处理、强化学习环境构建、模型训练、回测验证与结果可视化全流程,可在真实行情数据上复现与扩展。压缩包共40个文件,大小6.4MB。核心为14个Python脚本,负责算法实现与交易环境封装;4个CSV保存历史行情数据,4个XML配置算法参数,1个Jupyter Notebook提供交互式回测演示,另有模型参数存档、训练图表和许可证说明,目录结构清晰,便于按模块学习。已有304人学习下载。通过阅读源码与Notebook,可掌握深度强化学习在金融决策中的应用技巧,理解策略网络调参与评估方法,为自主设计自动化交易系统提供可运行的参考模板。

1. 先泼盆冷水:深度强化学习做自动交易,解决的从来不是“预测涨跌”

刚接触这个标题的人,最容易产生一个误会:以为这是一套用深度强化学习来预测股票明天涨跌、然后自动买入卖出的系统。真按这个思路去做,结果通常很惨。原因在于,预测模型的目标是“最小化预测误差”,而交易系统的目标是“在有限本金和交易成本约束下最大化收益风险比”,这两件事的优化方向并不一致。预测准了不代表赚钱,赚到钱也不代表预测准。深度强化学习在这条链路里真正扮演的角色,不是预报员,而是“决策器”——它通过不断试错,学习的是“在给定当前市场状态时,该买、该卖、还是该持有”。

如果你手里已经拿到一份“基于深度强化学习的自动化股票交易策略设计源码”,你会发现它的骨架通常不是一堆预测模型,而是一个强化学习环境,加上一个策略网络,再配上一套回测评估循环。这三块东西,也是我们这篇笔记接下来要拆解的主线。这个方向适合谁?适合那些已经熟悉股票数据、有一定的Python和PyTorch基础,想把强化学习真正落到交易决策上的人。不适合指望拿到源码直接躺赢的人——这点我放在前面说清楚,因为后面很多坑,都是从“拿源码就直接跑”开始的。

2. 策略设计第一步:把交易问题改写成强化学习能懂的MDP

2.1 为什么交易问题能套进强化学习框架,以及MDP五元组怎么对应

任何强化学习问题,第一步都是把业务问题转成马尔可夫决策过程,也就是MDP。股票交易天然适合做这个转化:智能体是交易者,环境是市场,状态是当前可观测的行情和技术指标,动作是仓位操作,奖励是资金曲线的变化。五元组(S, A, P, R, γ)可以这样对应:

  • 状态S:当前时刻的持仓、现金、最新K线特征、均线、波动率等信息。注意这里有个前置假设:市场满足马尔可夫性,即当前状态已经包含了决策所需的全部历史信息。实际操作中我们不可能拿到所有信息,所以尽量把有用的特征塞进状态向量里。
  • 动作A:要么是离散的(买入、卖出、持有),要么是连续的(仓位比例,比如-1到1之间,负代表做空)。
  • 转移概率P:交易环境里的转移是部分可观测且非平稳的,所以通常不显式建模,交给神经网络去逼近。
  • 奖励R:单步收益或收益的某种变形,比如对数收益率减去交易成本。
  • 折扣因子γ:通常取0.99到0.999之间。γ太小会让策略变得短视,只盯着眼前一两天的收益;γ太大则训练难度上升,回报方差变大。

源码里最核心的“环境”类,一般就是实现reset和step这两个函数。reset负责把账户状态清零、把数据指针拨到序列开头;step输入动作,根据当前K线数据计算成交、更新账户、返回新的状态和奖励。这个设计其实和OpenAI Gym的接口完全一致,方便后续接上现成的强化学习算法库。

2.2 状态空间怎么构造:特征不是越多越好,时机对齐才是最大坑

写环境代码时,我见过最多的问题出在状态构造上。常见的做法是取最近N根K线的“开盘、收盘、最高、最低、成交量”,再拼上一些指标(RSI、MACD、布林带),最后做归一化。这里有两个非常容易翻车的细节。

第一个是“未来数据泄漏”。如果归一化用的是全量数据的均值和标准差,那么在回测时,某个时间点的状态其实已经包含了未来信息。正确的做法是在序列上做滚动标准化——对每一个时间点,只用该点之前的数据计算均值和方差。很多号称“回测很漂亮”的源码,一上实盘就崩,根因往往就在这里。

第二个是“特征对齐”。假如你取最近30根日线作为状态,那么在第t天做决策,RNN或Transformer可以处理时序信息,但如果是用MLP(多层感知机),你需要把序列展平成向量。展平之前必须确认,向量最后一个位置对应的是最新一根K线,而不是最早那根。这个顺序反了,网络学出来的东西完全是噪声。踩过这个坑之后,我习惯在环境里加一段断言,专门检查状态张量最后一维的数据创建时间戳是不是递增排列的。

2.3 奖励函数设计:收益率、对数收益率、带交易成本,三者差别巨大

奖励函数决定了策略优化的方向。最简单的写法是reward = (equity_now - equity_before) / equity_before,即单步资金变化率。但直接用这个,策略会倾向于“赌一把大的”——因为涨10%的一次收益,可以覆盖前面20次小亏损。于是常见做法改成对数收益率reward = log(equity_now) - log(equity_before),让收益趋向对称。

再进一步,必须在奖励里扣除交易成本,否则策略会学到高频买卖:每次动作只要产生细微的正预期收益,就频繁进出。对A股而言,双边费用大约在万几到千一之间,取决于是否包含印花税。我一般会在环境里配置一个cost参数,默认值设成0.001,动作与上一动作不同时,就扣除一次成本。这样策略学出来才更接近真实约束下的选择。

下面是一段最小可运行的股票交易环境骨架,按上述逻辑实现:

import numpy as np class StockTradingEnv: """最小化的股票交易环境,只为说明MDP五要素的落地方案。""" def __init__(self, prices, features, cost=0.001, gamma=0.99): self.prices = prices # shape: [T] self.features = features # shape: [T, feature_dim] self.cost = cost self.gamma = gamma self.reset() def reset(self): self.t = 0 # 当前K线下标 self.cash = 1.0 # 初始资金归一化为1 self.shares = 0.0 # 持仓股数 self.prev_action = 0 # 0:空仓, 1:满仓, 便于计算换手成本 return self._get_state() def _get_state(self): # 只使用t时刻之前的数据,避免未来函数 end = self.t + 1 start = max(0, end - 30) return self.features[start:end].reshape(-1) def step(self, action): # action: 0 清仓, 1 满仓买入 price = self.prices[self.t] # 执行动作,扣除手续费 if action == 1 and self.prev_action == 0: self.shares = self.cash * (1 - self.cost) / price self.cash = 0.0 elif action == 0 and self.prev_action == 1: self.cash = self.shares * price * (1 - self.cost) self.shares = 0.0 # 进入下一时刻,计算账户总资产 self.t += 1 if self.t >= len(self.prices): done = True self.t = len(self.prices) - 1 else: done = False equity = self.cash + self.shares * self.prices[self.t] reward = np.log(equity) - np.log(self.equity_before) self.equity_before = equity self.prev_action = action return self._get_state(), reward, done, {"equity": equity}

这段代码里有几个参数需要关注。cost是交易成本,我在0.001到0.003之间调过,成本越高,策略换手率越低,但过高的成本会让模型彻底不敢交易,学出来的策略变成了“永远空仓”。gamma是折扣因子,0.99适合日线级别,如果是分钟级数据,可以设到0.995以上,因为分钟级决策更密集,远期奖励的重要性更高。另外注意_get_state里强制只用当前时刻之前的数据,这个结构能挡住未来数据泄漏,不要为了方便把整个数据集放入状态。

3. 算法选型与网络结构:DQN、PPO、DDPG到底怎么选

3.1 DQN处理离散动作:适合三分类场景,但别指望它做仓位控制

许多源码里首选的是DQN(Deep Q-Network),因为实现简单、资料多、调参经验丰富。DQN的输出是一个Q值向量,每个维度对应一个离散动作的价值,对股票交易来说自然映射成“买入、卖出、持有”三分类。

但实际跑下来,DQN有两个明显短板。第一,它学出来的策略倾向于“死守一个动作”,因为Q值初始化接近,训练初期随机探索会让网络固化了“持有最优”的偏见,尤其当奖励里包含交易成本时,模型宁可不动也不愿尝试。第二,DQN的评估依赖Q值,但股票数据是非平稳的,Q值的分布漂移很严重,训练过程中的target network更新不及时,容易发散。

如果你手里源码用的DQN,建议你把重点放在探索策略上。把ε从1.0按指数衰减到0.05,衰减步数设到总步数的60%以上;并把replay buffer设大,至少10万条以上,打破相邻样本的相关性。这样还能让DQN跑出一点像样的结果。

3.2 PPO与DDPG:连续动作场景下的主力选手

如果要做“仓位比例”这种连续动作,DQN就不够用了。常见的有两类方案:基于策略梯度的PPO(Proximal Policy Optimization),和基于Actor-Critic的DDPG(Deep Deterministic Policy Gradient)。实际源码里这两类都很多,我的选型经验是:

  • 用日线、数量少(单标的或十只以下)、训练时间充裕:选PPO。它天然适合离散或连续动作,对超参不敏感,收敛稳定,是通用场景最稳的选择。
  • 用分钟线、高频交易、需要平滑的仓位变化:选DDPG。DDPG输出确定性动作,加上OU噪声或高斯噪声做探索,交易动作的连续性更好,但训练极不稳定,需要花大量时间调target网络软更新系数。

这两种算法在源码里通常都会预置网络骨架。PPO需要维护两个网络:actor输出动作分布(连续时是高斯分布的均值和标准差),critic输出状态价值V(s)。DDPG则是四个网络:actor在线网络、actor目标网络、critic在线网络、critic目标网络。新手建议先跑PPO,因为它对随机种子和奖励尺度的敏感度低一些。

3.3 Actor-Critic网络输入输出细节:一篇代码骨架说明白

无论选哪个算法,网络输入输出都围绕“状态进入网络,动作和值从不同头出来”这个结构。我经常用下面这段方式去检验一份源码的网络实现是否合理:

import torch import torch.nn as nn import torch.nn.functional as F class ActorCritic(nn.Module): """ 适用于离散/连续动作的共享骨干结构。 股票特征向量输入 -> 共享层 -> actor头 + critic头。 """ def __init__(self, state_dim, num_actions, continuous=False): super().__init__() self.continuous = continuous self.shared = nn.Sequential( nn.Linear(state_dim, 128), nn.ReLU(), nn.Linear(128, 64), nn.ReLU(), ) self.actor_discrete = nn.Linear(64, num_actions) self.actor_continuous_mean = nn.Linear(64, num_actions) self.actor_continuous_logstd = nn.Parameter(torch.zeros(num_actions)) self.critic = nn.Linear(64, 1) def forward(self, x): features = self.shared(x) value = self.critic(features) if self.continuous: mean = self.actor_continuous_mean(features) logstd = self.actor_continuous_logstd.expand_as(mean) std = torch.exp(logstd) return mean, std, value else: logits = self.actor_discrete(features) return logits, value

这里有一个关键参数值得关注:logstd在源码里经常被初始化成全零向量,意思是初始标准差为1,探索范围比较大。如果你发现训练到一半策略突然变成“乱开仓”,把logstd初始值调到-1或-2,可以缩小初始探索步长,让策略在前期更稳定。共享骨干的维度,128-64适合特征维度在几十这个量级;如果你的状态里拼了300个以上的特征,中间层可以加宽到256。critic输出的是标量价值估计,不套激活函数,因为价值可以是任意实数。

4. 跑通一套最小实现:数据准备、训练循环与参数调优

4.1 数据怎么喂:训练集验证集划分,必须按时间块而不是随机划分

股票数据最大的特点是时序依赖,随机打乱训练集验证集会直接造成未来数据泄漏。常见做法是按时间顺序把数据切成三段:前70%训练、中间15%验证、最后15%测试。训练时用滑动窗口方式在训练集内采样,每个episode从随机时间点开始,跑一段固定长度(比如250根K线)后结束。这样做的好处是每个episode能覆盖不同的市场状态,模型不至于只见过单一行情。

如果只有一份数据,也至少要按时间留出最后一段做样本外验证。我见过不少源码的训练集和回测集是重叠的,回测收益曲线看起来漂亮,但要知道那只是模型在“背答案”,不是真正的策略能力。

下面是一段数据切分和采样的常规写法:

import numpy as np def split_data(features, prices, train_ratio=0.7, val_ratio=0.15): """按时间顺序切分训练/验证/测试集,禁止随机打乱。""" n = len(prices) train_end = int(n * train_ratio) val_end = int(n * (train_ratio + val_ratio)) train = (features[:train_end], prices[:train_end]) val = (features[train_end:val_end], prices[train_end:val_end]) test = (features[val_end:], prices[val_end:]) return train, val, test def sample_episode(features, prices, seq_len=250): """在训练集内随机采样一个episode的起始点。""" total = len(prices) - seq_len start = np.random.randint(0, total) end = start + seq_len return features[start:end], prices[start:end]

注意这里seq_len的选择直接影响训练效率。250对于日线来说大约是1年的交易日,能覆盖较完整的趋势和回调;设太短(比如20)会让奖励方差巨大,模型学不到长期因果关系;设太长(超过1000)则单次episode耗时太长,训练很慢。实践中我习惯先用250跑通流程,验证策略有效后再尝试500或750。

4.2 训练循环里决定成败的3个参数:buffer、batch、learning rate

很多人调参只盯算法自带的学习率,忽略了两个更基础的东西:replay buffer容量和batch size。

以PPO为例,每轮迭代要收集一批轨迹,然后用这个数据做多轮mini-batch更新。buffer太小时,模型过拟合当前市场片段,下一个episode换到不同行情就崩盘;buffer太大时,数据里包含了太多过时的市场状态,策略更新方向被平均掉,学得很慢。我给一组经验参考:日线级别,单episode长度250,buffer存2000-5000条转移样本比较合理。

学习率方面,actor和critic建议分开设置,actor用3e-4,critic用1e-3。如果训练时发现loss震荡剧烈,先把两个学习率都除以5,再看训练曲线。DDPG里target网络的软更新系数τ,我一般初始化0.005,训练过程中如果发现目标Q值波动太大,把τ降到0.001。

4.3 回测指标不只是看累计收益:Sharpe、最大回撤、换手率一起上

训练完成后,大多数源码会给出一张资金曲线图。如果只盯这条曲线,很容易被“某一年翻了几倍”迷惑。真正要看的指标至少有三个:

  • 年化Sharpe:收益与波动的比值,低于1的策略实盘意义就不大。
  • 最大回撤:从峰值跌到谷底的最大幅度,超过30%的策略遇到连续亏损时你很难拿得住。
  • 换手率:日均换手高,说明策略在频繁交易,实盘里滑点和冲击成本会让收益大幅缩水。

下面这段代码计算上述指标,并判断策略是否值得继续调下去:

def evaluate_strategy(equity_curve, trades_per_day=1.0): """输入每日账户净值序列,输出核心绩效指标。""" equity = np.array(equity_curve, dtype=float) daily_returns = np.diff(equity) / equity[:-1] mean_ret = np.mean(daily_returns) std_ret = np.std(daily_returns) sharpe = np.sqrt(252) * mean_ret / (std_ret + 1e-8) peak = np.maximum.accumulate(equity) drawdown = (equity - peak) / peak max_drawdown = drawdown.min() turnover = trades_per_day # 还需要从动作记录里统计实际换手 return {"sharpe": sharpe, "max_drawdown": max_drawdown, "turnover": turnover}

很多源码在评估阶段没有扣手续费或滑点,导致Sharpe虚高。建议你在写评估函数时,把交易成本从收益里扣除后再计算这些指标,这样一份策略有没有落地价值才看得出来。

5. 回测不涨、训练崩溃、策略失效:5个高频踩坑点与排查顺序

5.1 训练loss在下降,但收益曲线完全不动

现象:训练日志里actor的loss稳步下降,critic的loss也在低位,但生成的回测资金曲线是一条水平线,几乎没有任何交易。

原因:这种情况十有八九是策略收敛到了“永远空仓”。原因可能是奖励函数里交易成本太高,或者探索率下降太快,模型在早期尝试几次交易亏损后,发现空仓的期望收益最高,于是再也不敢开仓。

解决:先调低交易成本,把cost从0.003降到0.0005,看是否会出现交易动作;再把探索率固定在一个下限,不要让它降到0,比如PPO中entropy系数保底0.01,保证策略一直有探索动力。最后确认奖励函数是否除以了某种尺度,避免收益绝对值太小,导致模型认为“做了和没做差不多”。

5.2 训练阶段收益很高,但样本外回测崩盘

现象:训练集上的累计收益曲线非常漂亮,Sharpe能到3以上;但切到样本外测试数据,策略收益变成大幅亏损,资金曲线直线下滑。

原因:这是最典型的过拟合和未来函数。未来函数的根源通常是数据预处理时使用了全量统计信息,或者是状态特征里包含了未来时点的数据。过拟合则是因为模型容量太大、训练轮次太多,把训练集里的噪声也“背”了下来。

解决:先排查未来函数,把归一化改成滚动归一化。这一步通常能解决一半以上问题。如果仍然过拟合,就加大训练集和验证集的间隔(比如中间留出10%数据完全不用),再把模型的隐藏层数减少,或者增大L2正则项的系数。不要迷信“更大网络”,在股票数据这个信噪比极低的场景下,小网络往往更稳。

5.3 训练到一半loss变成NaN或巨大正数

现象:训练跑到几千步之后,loss突然变成NaN,或者出现一个极大的正数,之后程序报错退出。

原因:最常见的是梯度爆炸。股票特征的值域波动大,比如某天价格突涨10%,归一化后仍然是一个较大值,传给网络的梯度会异常放大;另一个常见原因是奖励reward的值域没控制住,log收益在极端行情下会输给网络一个异常输入。

解决:给reward做clip,限制在[-1, 1]区间;给网络梯度做clip,PyTorch里用torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=0.5)。同时检查状态特征是否做了对数变换,价格类特征建议取log后再归一化,能有效压值域。

5.4 模型训练正常,但策略总是过度交易

现象:回测结果显示日均换手率超过100%,手续费和滑点吃掉大部分利润,扣费后Sharpe只有0.2。

原因:奖励函数里没有足够的“动作平滑”约束,模型发现每次微调仓位都能获得微小收益,于是拼命高频操作。幅度上的微小优势被高频执行放大,最终被真实交易成本抵消。

解决:在奖励函数里加动作惩罚项,比如penalty = -alpha * abs(action - prev_action),alpha从0.01开始调。这样模型每次改变仓位都要付出代价,自然而然学会了低频决策。更彻底的做法是把决策周期拉长,比如每5根K线才允许做一次动作,从环境层面降低交易频率。

5.5 多标的训练不收敛,但单标的收敛得很好

现象:同一份代码,在单只股票上训练能稳定盈利,扩展到10只股票同时训练时,loss震荡剧烈,策略最终表现很差。

原因:多标的场景下,每个episode采样到的股票不同,状态分布差异大,共享的神经网络需要同时拟合多个市场的规律。这种非平稳性会造成策略更新方向来回摆动,模型学不到一个“通用”规律。

解决:一个稳妥的方案是“先单标的预训练,再多标的微调”。先在1只股票上训练到策略稳定,冻结一部分骨干参数,再用其他股票数据做小学习率微调。另一种办法是保持多标的采样,但每个episode固定使用同一只股票的数据,让策略在更长的时间尺度上逐渐适应不同标的的风格。

6. 怎么验证一份交易策略源码真的能跑:最小行动路径与进阶建议

拿到一份深度强化学习交易源码,先别急着换算法、改网络。我建议你先做三件事:第一,用源码自带的环境,随机策略跑200个episode,记录奖励分布的均值和方差;第二,把训练集切小到原来的20%,只训练500步,确认整个训练流程能不出错地跑完;第三,把源码里的评估函数单独抠出来,用一组手工构造的“永远持有一半仓位”的简单策略跑一遍回测,确认评估指标的计算逻辑没有明显bug。这三步做完,你对这份源码的工程质量心里就有底了。

进阶方向上,我优先级排序是:先修环境层(奖励、成本、数据切分),再调算法层(学习率、探索率),最后才折腾网络结构。换网络结构往往是收益最低的改动,因为股票交易的主要矛盾从来不是网络拟合能力不够,而是目标函数和成本建模不够真实。

另外提一个日常习惯:训练过程中每隔一段时间保存一次模型权重,同时把当时的回测指标写进日志。很多时候你回看训练曲线,会发现最好的模型不是训练步数最多那个,而是中间某个阶段出现过一次“明显跑赢基准”参数快照。有了日志,还能反推是哪个阶段开始过拟合的。这个习惯救过我很多次,也建议你在自己复现时坚持。希望这篇梳理能帮你在碰这一堆源码时少走点弯路。

提示:所有涉及实盘的参数,先用仿真数据验证,再考虑接入模拟盘小仓位跑。深度强化学习策略的价值来自回测和样本外验证的严谨程度,而不是模型本身的复杂度。初学者优先把环境做扎实,比堆叠算法更能提升最终收益的确定性。

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

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

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

立即咨询