ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深度强化学习交易策略从零搭建:环境、特征与PPO实战

深度强化学习交易策略从零搭建:环境、特征与PPO实战

简介:基于深度强化学习的自动化股票交易策略设计源码整合了PPO、A2C、DDPG三种Actor-critic算法,面向金融量化研究者与深度强化学习入门者,提供从数据预处理到策略回测的闭环方案,适用于股票自动交易策略的算法验证与二次开发。压缩包共40个文件,以14个Python脚本为核心,覆盖多股票交易环境构建、模型训练与验证等模块,另含4个CSV历史交易数据、4个XML配置、1个Jupyter Notebook演示及配套图像与说明,整体约6.4MB。源码复现了ICAIF 2020的集成策略框架,可在真实道琼斯指数数据上评估PPO等算法表现,并支持扩展自定义策略;同时提供预处理与参数配置模块,便于按自身数据调整训练流程。目前已有305人次学习下载,适合关注强化学习金融应用的研究者与开发者参考。

1. 为什么“深度强化学习交易策略”屡屡翻车,却仍值得自己从零搭一套

当你在回测里看到年化 80% 的“深度强化学习交易策略”一上模拟盘就变绿,别急着怀疑人生——大概率不是策略错了,而是你踩中了回测环境与实盘环境的裂缝。基于深度强化学习的自动化股票交易策略设计源码,本质上是把行情、持仓、交易成本打包成一个可交互的“交易环境”,再让智能体在环境里通过试错学到买卖策略。这套方案能解决“手工规则策略覆盖不了复杂市场状态”的问题,适合有 Python 基础、想做量化策略研究但不想手动写规则的人。但它不是“调参就能赚钱”的印钞机,更像一个需要你一步步校准环境、特征、奖励和验证方式的系统工程。下面我从原理到落地,用一套可复现的流程把它讲透,顺便把那些反复让你翻车的坑提前踩掉。

2. 先立理论:深度强化学习交易系统的三个关键决策

2.1 交易问题怎么建模成强化学习问题:状态、动作、奖励的最小闭环

强化学习把交易问题形式化为马尔可夫决策过程,核心五要素是状态、动作、转移概率、奖励和折扣因子。在股票交易里,每个 bar 结束时,智能体需要观察当前持仓、可用资金、最近 K 根 K 线的统计量,然后决定这一根 bar 内是买入、卖出还是持有。注意决策顺序:先接收观测,再执行动作,然后市场波动,最后得到奖励。这和传统监督学习“预测涨跌”有本质区别——监督学习只预测一个标签,强化学习则要为整个决策序列负责,所以会天然考虑手续费、持仓风险和连续性。

一个常见的错误是把“预测次日涨跌”当作动作,比如预测上涨就买入、预测下跌就卖出,然后用预测准确率做奖励。这样训练出来的智能体往往频繁买卖,因为每根 K 线预测的局部收益忽略了交易成本。正确的最小闭环是:状态向量至少包含价格变化率、波动率、持仓比例;奖励用“账户权益变化率减交易成本”;动作空间如果离散,建议三档:空仓、满仓买入、清仓卖出。还有一点容易被忽略:市场不是完全可观测的,所以状态里最好拼接最近 N 根 K 线,比如连续 20 个交易日的特征窗口,让网络自己提取短期动量和反转信息。

折扣因子 gamma 也得单独说。股票交易是长周期决策,今天止损离场可能换来下个月的更大盈利,所以 gamma 不能设成 0.9 这种只关心 10 步以内的值。我通常取 0.99 或者 0.995,相当于让智能体看 100 步以上的长期回报。gamma 设置过小会导致策略变得短视,频繁追逐两三天内的波动,最终收益被手续费蚕食。

2.2 选哪种算法:PPO 为主,SAC 与 DQN 为辅的对比

深度强化学习算法里,交易场景最常被拿来做对比的是 DQN、PPO 和 SAC。它们的核心差异在于动作空间的建模方式。

算法动作空间优点典型坑
DQN离散实现简单,适合三档仓位Q 值高估,训练震荡
PPO离散/连续策略梯度稳定,clip 约束防止更新过大需要调 clip、GAE lambda
SAC连续高维动作探索好,熵正则天然支持随机策略温度系数敏感,训练慢

PPO 是目前做自动化交易最稳妥的起点。它用重要性采样和 clip 目标限制每次策略更新幅度,即使奖励信号稀疏,也不容易一次更新过度。SAC 在持续动作场景(比如仓位比例 0 到 1 的连续控制)表现更好,但环境不稳定时容易崩溃。DQN 适合小离散动作空间,但交易场景奖励延迟明显,DQN 的目标网络和双 DQN 能缓解却增加复杂度。我第一次落地时直接选了 DQN,结果训练了两万步都不收敛,换成 PPO 之后一千步就能看到奖励曲线上升。这不是玄学,而是策略梯度对动作分布直接优化,比价值学习在连续状态空间里更鲁棒。

如果你要处理的动作是“买入多少比例”,建议用 PPO 的连续动作版本,然后在环境内部把动作值映射到仓位比例。但连续动作会带来更大的探索空间,对奖励函数设计、训练步数的要求也更高。新手我建议先做离散三档,跑通流程之后再加连续控制。

2.3 离线训练还是在线更新:仿真环境与实盘回测的边界

很多交易策略源码把训练和回测混在一起,训练结束后再用同一段数据做绩效,这是最容易翻车的点。正确做法是把数据集按时间切为训练段、验证段和测试段。智能体只在训练段的数据上采样学习,验证段用来做早停或超参调整,测试段只能最后跑一次来模拟未来,这就是默认的离线仿真方式。环境是确定的,样本是历史数据,agent 不能在真实市场里试错。

如果要做在线更新,常见做法是把模型部署到模拟盘,用每日成交后的数据做一次小步长 fine-tune。但真实市场存在非平稳性,模型可能因为几天的极端行情就被带偏。我一般建议先离线训练出稳定策略,然后固定策略参数跑模拟盘两个月,期间只做奖励监测,不实时调参。严格来说,这不是在线强化学习,而是“离线训练 + 人工监控”的保守方案。对于个人研究,这一步能帮你区分策略能力与运气。

另一个边界是“状态特征”是否包含未来信息。如果训练时用当天收盘价计算特征,却要求智能体在开盘时做动作,那么特征本身就有未来函数。最简单的规避方式是在环境里保证“观测时刻”与“可交易时刻”严格分离:用 bar 开始前已有的数据做决策,用 bar 收盘后的数据结算收益。

3. 数据准备与特征工程:决定策略上限的第一场硬仗

3.1 用 akshare 拉取行情并做前复权

数据是交易策略的起点。我习惯用 akshare 拉取 A 股日线,因为它免费、无需 token,而且支持前复权。下面的代码演示拉取单只股票并做字段映射:

import akshare as ak import pandas as pd # 拉取某只 A 股的前复权日线数据 # 注意:akshare 接口字段名在不同版本会有差异,建议先打印 columns df = ak.stock_zh_a_hist( symbol="600600", period="daily", start_date="20150101", end_date="20231231", adjust="qfq" # qfq:前复权;hfq:后复权 ) df.rename(columns={ "日期": "date", "开盘": "open", "收盘": "close", "最高": "high", "最低": "low", "成交量": "volume", "成交额": "amount", "涨跌幅": "pct_chg" }, inplace=True) df["date"] = pd.to_datetime(df["date"]) df.set_index("date", inplace=True) df = df[["open", "close", "high", "low", "volume", "amount", "pct_chg"]].astype(float) print(df.tail())

这里的逻辑是:symbol 是股票代码,period 控制 K 线周期,adjust="qfq" 表示前复权。前复权会把历史价格按最新股本和分红重新拉升,避免除权除息造成的假跳空。如果不做复权,除权日会显示一根大阴线,模型很容易学到“在除权前卖出”这种虚假规律。如果你做的是指数数据,要换用stock_zh_index_daily,那个接口返回的是不复权数据,直接使用即可。

拉完数据后我通常会再看一眼日期范围、有没有停牌导致的空行,然后删掉上市前三年数据不足的个股。次新股涨跌停幅度和次新炒作规律跟老股差别很大,放进训练集只会增加噪声。

3.2 特征编码:技术指标与收益率标签的取舍

原始价格序列是非平稳的,直接喂给神经网络会让前几天的股价数值主导梯度。常见的做法是把价格转成对数收益、振幅、量比,再叠加一两个经典技术指标。下面这段代码生成基础特征:

import numpy as np # 基础特征:对数收益、振幅、量比 df["log_ret"] = np.log(df["close"] / df["close"].shift(1)) df["amplitude"] = (df["high"] - df["low"]) / df["close"].shift(1) df["volume_ma5"] = df["volume"] / df["volume"].rolling(5).mean() # 技术指标:RSI 与 ATR def rsi(series, period=14): delta = series.diff() gain = delta.clip(lower=0).rolling(period).mean() loss = (-delta.clip(upper=0)).rolling(period).mean() rs = gain / loss return 100 - 100 / (1 + rs) def atr(df, period=14): tr = np.maximum( df["high"] - df["low"], np.maximum( np.abs(df["high"] - df["close"].shift(1)), np.abs(df["low"] - df["close"].shift(1)) ) ) return tr.rolling(period).mean() df["rsi_14"] = rsi(df["close"]) df["atr_14"] = atr(df) df.dropna(inplace=True)

关键点在于所有指标都只用历史窗口计算,不能引入未来 N 天的数据。比如rolling(14)里的 14 个样本包含当前 bar,这是允许的,因为当前 bar 已经收盘,决策也在收盘后发生;但如果某个指标用了未来 3 天的中心移动平均,就是典型的前视偏差,会让回测曲线好看但实盘完全失效。

特征不是越多越好。状态空间在 100 维以内通常没问题,超过 200 维时一个小批量样本会占很多内存,而且训练时间成倍增加。我一般会保留 5 到 10 个特征,例如对数收益、振幅、量比、RSI、ATR,再加一个持仓比例。你不需要把几十个技术指标全部塞进去,强化学习算法自己会从收益序列中发现规律,而不是像人工规则一样依赖某个指标的金叉死叉。

3.3 训练集与回测集切分:避免前视偏差的必备操作

时间序列数据不能随机打散,只能按时间先后切分。同时,归一化参数的 fit 范围必须只在训练段完成,再应用到验证段和测试段,否则会把未来的均值方差泄漏给模型。

from sklearn.preprocessing import StandardScaler train_df = df.loc["2015-01-01":"2020-12-31"].copy() val_df = df.loc["2021-01-01":"2022-12-31"].copy() test_df = df.loc["2023-01-01":].copy() feat_cols = ["log_ret", "amplitude", "volume_ma5", "rsi_14", "atr_14"] scaler = StandardScaler() scaler.fit(train_df[feat_cols]) train_df[feat_cols] = scaler.transform(train_df[feat_cols]) val_df[feat_cols] = scaler.transform(val_df[feat_cols]) test_df[feat_cols] = scaler.transform(test_df[feat_cols])

为什么不能在全量数据上 fit?因为这意味着模型在训练时已经知道未来 10 年每个特征的均值和方差。实际策略上线时,你只有历史截至当前的数据,所以这种“全局标准化”会让验证集和训练集共享未来统计量,最后测出来的胜率是假的。这个坑非常隐蔽,很多人跑出的绩效曲线异常漂亮,一换到后续年份就崩,原因就在这里。

此外,验证集的价值是用来做早停或超参选择的。你可以训练多个候选参数,在验证集上选最好的,再用测试集只评估一次。测试集只能看一次,多看几次模型选择就会过拟合到测试集。

4. 搭建交易环境与策略主体:从 Gym 到自研环境的落地

4.1 交易环境设计:持仓、手续费、涨跌停的最小实现

交易环境是整套源码的核心。它负责把 DataFrame 转成强化学习可交互的模拟器,并强制执行交易规则。下面是一个可用于训练的最小 Gym 环境,动作空间为三档离散:0 持有、1 买入、2 卖出。

import numpy as np import gym from gym import spaces class TradingEnv(gym.Env): def __init__(self, data: pd.DataFrame, features: list, initial_cash=100000.0, commission_rate=0.0003): super().__init__() self.data = data.reset_index() self.features = features self.initial_cash = initial_cash self.commission_rate = commission_rate self.n_steps = len(self.data) self.action_space = spaces.Discrete(3) self.observation_space = spaces.Box( low=-np.inf, high=np.inf, shape=(len(features) + 1,), dtype=np.float32) self.reset() def reset(self): self.current_step = 0 self.cash = self.initial_cash self.position = 0.0 self.total_value = self.initial_cash return self._get_obs() def _get_obs(self): # 当前特征拼接当前持仓比例 obs = np.array( self.data.iloc[self.current_step][self.features], dtype=np.float32 ) price = self.data.iloc[self.current_step]["close"] pos_ratio = self.position * price / self.total_value return np.concatenate([obs, [pos_ratio]]) def step(self, action): prev_total_value = self.total_value price = self.data.iloc[self.current_step]["close"] # 执行动作:满仓买入或清仓卖出 if action == 1 and self.position == 0: shares = self.cash * (1 - self.commission_rate) // price cost = shares * price * (1 + self.commission_rate) self.cash -= cost self.position = shares elif action == 2 and self.position > 0: self.cash += self.position * price * (1 - self.commission_rate) self.position = 0.0 if self.current_step >= self.n_steps - 1: done = True obs = np.zeros(self.observation_space.shape, dtype=np.float32) else: self.current_step += 1 obs = self._get_obs() price_next = self.data.iloc[self.current_step]["close"] if not done else price self.total_value = self.cash + self.position * price_next reward = np.log(self.total_value / prev_total_value) info = {"total_value": self.total_value} return obs, reward, done, info

逻辑说明:step 先保存当前总资产,再执行动作,然后推进到下一个 bar 结算收益。奖励用的是单步对数收益,这样累计乘积就能还原整个回测期间的净值曲线。注意买入时用// price取整,模拟最小交易单位为 1 股,手续费按买入和卖出各收一次计算。observation_space的形状是特征维度加 1,多出来的维度是持仓比例,这能让策略网络感知当前仓位状态。

这里没有模拟涨跌停。真实 A 股中,涨停时你可能买不进,跌停时卖不出。一个简化但有效的做法是在 step 中加入判断:如果当日pct_chg >= 9.8%,禁止执行买入动作;如果pct_chg <= -9.8%,禁止执行卖出动作。20cm 的创业板和科创板阈值不同,需要根据股票代码区分。

4.2 奖励函数的常见误用:锐化收益的陷阱与改进策略

奖励函数是强化学习里最容易被玩坏的地方。直接用“当前步收益率”做奖励,会让策略偏向高波动组合,因为收益率序列的尖峰很容易被策略网络捕捉,导致模型不断尝试全仓押注。更合理的方式是在单步收益后减去换手惩罚,或者加入最大回撤惩罚。最简单的改进是这样:

# 在 step 中计算 reward 的位置替换为: trade_amount = abs(self.total_value - prev_total_value) # 近似换手 turnover_penalty = self.commission_rate * trade_amount / self.initial_cash reward = np.log(self.total_value / prev_total_value) - turnover_penalty

这样每次交易都会产生额外惩罚,智能体必须判断多出来的收益能否覆盖手续费。还有一类做法是使用“微分 Sharpe 比率”作为奖励,公式更复杂,但对风险敏感的品种效果好。新手阶段我不建议一上来就弄复杂奖励,先用简单的“收益减交易成本”训练,看训练过程是否稳定,再逐步加风险项。奖励函数是告诉智能体“什么是对的”,不是把所有约束都塞进去;塞多了策略会变得胆小,甚至长时间选择空仓,学不到有效买卖逻辑。

4.3 基于稳定基线库跑通 PPO 的最小训练脚本

环境写好之后,训练最小脚本就非常短了。我用 stable-baselines3 里的 PPO 作为默认算法,因为它经过了大量社区验证,接口稳定。

from stable_baselines3 import PPO from stable_baselines3.common.env_checker import check_env env = TradingEnv(train_df, feat_cols) check_env(env) # 检查环境是否合法 model = PPO( "MlpPolicy", env, learning_rate=3e-4, n_steps=2048, batch_size=64, n_epochs=10, gamma=0.99, gae_lambda=0.95, clip_range=0.2, ent_coef=0.01, verbose=1, ) model.learn(total_timesteps=100_000) model.save("ppo_trading_first")

参数说明:n_steps=2048表示每收集 2048 步样本做一次策略更新,这个值最好大于一个 episode 的长度,否则一次更新无法覆盖完整交易回合;batch_size=64是每个梯度 minibatch 的样本数,调小会增加随机性,调大则训练更稳但更慢;gamma=0.99让策略重视长期收益;ent_coef=0.01是熵正则系数,保留一点探索度,如果设成 0,策略会过早收敛到某个固定动作。check_env会验证 action_space、observation_space 和 step 返回值的类型,环境写错时会直接报错,建议每次修改环境后都跑一遍。

训练完成后,用model.predict(obs, deterministic=True)取动作,deterministic=True表示不采样随机动作,用策略分布的均值做决策,适合回测。这个脚本是整个方案的“引擎”,后续调参都围绕它展开。

5. 训练与回测中的常见问题排查

5.1 奖励一直不涨,先检查状态归一化和 reward scaling

现象:训练几千步后奖励曲线始终横盘,甚至缓慢下降。

原因:最常见的两个来源,一是特征数值量级差异巨大,比如股价是几千,RSI 只有 0 到 100,网络梯度被大数值特征主导;二是奖励值本身幅度太小或太大,导致 PPO 的 clip 机制无法有效更新。

解决:所有特征先按第 3 章的方式做标准化;然后看 reward 的实际量级。如果每次 reward 是 0.001,建议在环境里乘一个 100 或 1000 的缩放系数,让策略更新的梯度合理。这个缩放系数要固定,不能在训练中动态改。我一般先把奖励打印出来,观察中位数在1e-3到1e-2之间,再决定是否缩放。只要代码里留一个reward_scale参数,调起来非常方便。

5.2 回测大赚实盘大亏:价差、滑点与未来函数的三连坑

现象:回测绩效年化 50%,模拟盘两周就亏掉全部收益。

原因:回测用收盘价成交,但实盘有买一卖一价差和滑点;模型可能用到未来函数;手续费设得太低。三件事叠加,让回测变成“理想交易”而非“可交易”。

解决:在回测引擎中加入滑点模型,比如买入价close * 1.0005,卖出价close * 0.9995,并设置部分成交比例。手续费按双边至少 3 个基点,加上千分之一的滑点才是真实成本。另外检查特征计算有没有使用未来数据:比如用每日收盘价计算当日技术指标,再在当日开盘时做动作,这就是时间错位。一个血泪经验是:用前复权数据训练,模型学会了在除权日“暴跌”时买入,因为前复权把除权前的价格拉高了,看起来像暴跌;解决办法是改用后复权数据,或者在除权日加一个 mask 特征。

5.3 动作空间连续与离散的选择:交易成本敏感性

现象:换手率极高,策略一年交易几百次,手续费吃掉大部分收益。

原因:连续动作空间下,仓位比例每次只会微调一点点,PPO 倾向于每一步都调整仓位以获得微小收益,但这些微调累积出来的手续费远超收益。

解决:对连续动作增加换手惩罚,或者在环境里设置“最小交易单位”和“最小调整阈值”,比如只有当目标仓位与当前仓位差超过 0.1 时才执行操作。离散三档动作空间天然限制交易频率,新手建议从离散开始。如果非要用连续动作,可以把动作空间从全仓比例改成“目标持仓增减幅度”,并在环境内部离散成几档,这样既保留连续性又减少无效换手。

5.4 训练不收敛:学习率、batch size 与 episode 长度的关系

现象:loss 震荡,验证集收益随机。

原因:学习率太大导致策略更新步子过大;batch size 太小导致梯度噪声;episode 长度太短,序列依赖被随机采样打散。股票数据天然具有自相关性,如果每个 episode 只有 30 根 bar,模型看不到持续的趋势,学到的都是噪声。

解决:PPO 默认learning_rate=3e-4,如果震荡就降到1e-4。batch size 建议不低于 64。episode 长度最好覆盖至少一个趋势波段,日线数据我一般取 250 根 bar,也就是约一年。如果你的训练数据不够长,可以用滑窗生成重叠的 episodes,但要注意滑窗之间的重叠部分可能造成训练样本重复,影响评估。更干净的做法是让环境每次随机选择一个时间起点,运行固定长度,这样每个 episode 都拥有不同的市场区间。

5.5 数据泄漏:用全体数据归一化导致的“伪胜率”

现象:训练集上绩效极好,但在时间外推的测试集上立刻失效。

原因:在全量数据上做StandardScaler.fit,或者特征计算用了整个时间序列的统计量,让模型间接看到了未来数据分布。

解决:所有统计量只能在训练段计算,验证集和测试集只transform。技术指标使用rolling或expanding时也要确保窗口内只有历史数据。这里再提一个容易漏的点:如果训练数据里包含了 2023 年的一段,而测试集正好从 2023 年开始,那么你的训练数据已经看过“未来”的一部分行情,哪怕只有一天,也会带来轻微优势。正确的做法是严格按时间切分,比如训练到 2022 年 12 月 31 日,测试从 2023 年 1 月 1 日开始。

6. 进阶:用 walk-forward 验证和多随机种子让策略更可信

6.1 walk-forward 验证与多随机种子评估

单次回测等于“买彩票”,walk-forward 是滚动窗口反复验证。我一般把历史数据切成多个窗口,每个窗口用前三年训练,用后一年测试,并在每个窗口训练三个随机种子,看结果的方差。

windows = [ ("2015-01-01", "2018-12-31", "2019-01-01", "2019-12-31"), ("2016-01-01", "2019-12-31", "2020-01-01", "2020-12-31"), ] for train_start, train_end, test_start, test_end in windows: train = df.loc[train_start:train_end] test = df.loc[test_start:test_end] rets = [] for seed in [0, 42, 2024]: env = TradingEnv(train, feat_cols) model = PPO("MlpPolicy", env, seed=seed, n_steps=2048, verbose=0) model.learn(total_timesteps=50_000) test_env = TradingEnv(test, feat_cols) obs = test_env.reset() total_ret = 1.0 done = False while not done: action, _ = model.predict(obs, deterministic=True) obs, reward, done, _ = test_env.step(int(action)) total_ret *= np.exp(reward) rets.append(total_ret) print(f"窗口 {test_start}~{test_end}: " f"均值={np.mean(rets):.3f}, 标准差={np.std(rets):.3f}")

这里np.exp(reward)是因为环境奖励用的是单步对数收益,累乘还原净值。每个窗口评估三个种子,能筛掉“碰巧在一段行情里生效”的策略。如果某个窗口的标准差远大于均值,说明策略对初始化敏感,不值得上实盘。多跑几个窗口后,你会看到策略在不同市场风格下的真实表现,而不是一条被过度优化的收益曲线。

6.2 我的实战习惯:保留一份“傻瓜基线”做对照

每次跑策略前,我都会先写一个“始终空仓”和“始终满仓”的基线。如果 PPO 的平均收益连满仓基线都跑不赢,说明环境或奖励函数还有问题,而不是策略不够智能。这个对照成本很低,却能避免你把随机性当成策略能力。训练实验我还会把 tensorboard 日志保存成带日期和seed的目录,比如run_20250101_seed0/,方便复盘是哪次超参产生了某一个结果。这个方向值不值得投入?如果你能忍受调参和回测的反复,值得;但永远别相信一份没有 walk-forward 验证的绩效曲线。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进