资讯详情

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

📅 2026/10/12 17:22:42 | 华诺云谱 👁 阅读
深度强化学习交易策略从零搭建:环境、特征与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 lambdaSAC连续高维动作探索好熵正则天然支持随机策略温度系数敏感训练慢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( symbol600600, perioddaily, start_date20150101, end_date20231231, adjustqfq # qfq前复权hfq后复权 ) df.rename(columns{ 日期: date, 开盘: open, 收盘: close, 最高: high, 最低: low, 成交量: volume, 成交额: amount, 涨跌幅: pct_chg }, inplaceTrue) df[date] pd.to_datetime(df[date]) df.set_index(date, inplaceTrue) df df[[open, close, high, low, volume, amount, pct_chg]].astype(float) print(df.tail())这里的逻辑是symbol 是股票代码period 控制 K 线周期adjustqfq 表示前复权。前复权会把历史价格按最新股本和分红重新拉升避免除权除息造成的假跳空。如果不做复权除权日会显示一根大阴线模型很容易学到“在除权前卖出”这种虚假规律。如果你做的是指数数据要换用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, period14): delta series.diff() gain delta.clip(lower0).rolling(period).mean() loss (-delta.clip(upper0)).rolling(period).mean() rs gain / loss return 100 - 100 / (1 rs) def atr(df, period14): 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(inplaceTrue)关键点在于所有指标都只用历史窗口计算不能引入未来 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_cash100000.0, commission_rate0.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, highnp.inf, shape(len(features) 1,), dtypenp.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], dtypenp.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, dtypenp.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_rate3e-4, n_steps2048, batch_size64, n_epochs10, gamma0.99, gae_lambda0.95, clip_range0.2, ent_coef0.01, verbose1, ) model.learn(total_timesteps100_000) model.save(ppo_trading_first)参数说明n_steps2048表示每收集 2048 步样本做一次策略更新这个值最好大于一个 episode 的长度否则一次更新无法覆盖完整交易回合batch_size64是每个梯度 minibatch 的样本数调小会增加随机性调大则训练更稳但更慢gamma0.99让策略重视长期收益ent_coef0.01是熵正则系数保留一点探索度如果设成 0策略会过早收敛到某个固定动作。check_env会验证 action_space、observation_space 和 step 返回值的类型环境写错时会直接报错建议每次修改环境后都跑一遍。训练完成后用model.predict(obs, deterministicTrue)取动作deterministicTrue表示不采样随机动作用策略分布的均值做决策适合回测。这个脚本是整个方案的“引擎”后续调参都围绕它展开。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_rate3e-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, seedseed, n_steps2048, verbose0) model.learn(total_timesteps50_000) test_env TradingEnv(test, feat_cols) obs test_env.reset() total_ret 1.0 done False while not done: action, _ model.predict(obs, deterministicTrue) 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 验证的绩效曲线。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑