深度强化学习驯服A股:从数据到回测的自动交易智能体实战
2026/9/15 6:28:00 网站建设 项目流程

简介:面向金融科技与深度强化学习交叉方向的课程设计、毕业设计场景,这份源码包提供了一套基于PPO等算法训练A股市场自动交易智能体的完整工程。项目包含数据回测、策略对比、交易结果可视化与模型参数分析等环节,适合有一定Python与机器学习基础、希望将强化学习落地到真实股票场景的开发者参考。压缩包共130个文件,约20.92MB,包含12个Python脚本、7个Jupyter Notebook、82张交易结果图表,以及日志、配置文件等。Notebook按成分股表现、传统策略、PPO参数查看、交易结果绘图等主题拆分,便于一步步复现实验;zip内还附带常用Shell脚本、配置文件与Git忽略规则等。解压后可按目录顺序运行调试,目前已有101人学习浏览,代码经过调试,对正在完成股票量化和智能交易类项目的学生具有较高借鉴价值。

1. 深度强化学习智能体跑A股自动交易,难在哪儿

把深度强化学习接到A股上,很多人第一反应是“能不能预测涨跌”。跑过一年的分钟数据之后你会意识到,交易智能体的上限从来不是PPO、SAC哪个更强,而是数据怎么对齐、成交假设离真实有多远、奖励函数到底在优化什么。这三个问题决定了智能体是在学交易,还是在对着一堆历史K线背答案。

这篇文章适合已经会写Python、有基础机器学习经验的工程师。你的目标不是复现某一张惊艳的收益曲线,而是拿到一份“基于深度强化学习训练A股股票自动交易的智能体源码”之后,能拆得开、补得全、改得动。我按“数据与交易环境 → MDP建模 → 训练调参 → 回测验证 → 源码改造”的路径,把从离线数据到可上线交易智能体的完整链路讲清楚。先从不引人注意但决定成败的数据与撮合环境说起。

2. 深度强化学习的A股数据地基:分钟K线、复权与gym交易环境

拿到那份源码.zip,第一步不是看模型,而是看它怎么把K线喂给环境。A股有涨跌停、停牌、除权除息,也有T+1和交割规则,这些都得在数据层先处理掉。我一般把数据准备拆成三件事:选数据源与复权方式、清洗停牌与异常bar、提供统一的gym接口。

2.1 前复权还是后复权:训练数据里最容易被忽略的bug

复权直接决定价格序列是否连续。以后复权数据训练、以前复权数据回测,是回测环境最典型的污染方式。训练时我统一用后复权价格:后复权以上市首日为基准往后调整,最新价格等于真实市价,历史价格按除权因子放大或缩小,不会引入未来的复权信息。前复权以最新价格为基准反向调整历史价格,每次新增交易日都会改动整条历史序列,等于让环境状态里藏了未来。

停牌处理也直接影响训练质量。A股停牌期间成交量为0,一些数据接口仍会补出时间戳但没有有效价格。常见做法是把停牌bar直接丢掉,并在环境里记录“当前bar之后是否有下一根可用bar”,没有就提前结束回合。我还会过滤上市不足60个交易日的次新股,避免开板前后的极端波动把策略带偏。

2.2 自定义gym环境:收盘出信号、次日开盘成交的撮合逻辑

训练智能体需要标准交互接口。下面是最小可跑的gym环境骨架,撮合逻辑采用“本bar收盘出信号、下一bar开盘成交”,这是回测里避免未来函数的底线:

import numpy as np import gymnasium as gym from gymnasium import spaces class AShareTradingEnv(gym.Env): def __init__(self, features: np.ndarray, opens: np.ndarray, closes: np.ndarray, cost: float = 0.001): super().__init__() self.features = features self.opens = opens self.closes = closes self.cost = cost # 动作:目标持仓比例的增量,范围 [-1, 1] self.action_space = spaces.Box(low=-1.0, high=1.0, shape=(1,), dtype=np.float32) # 状态:历史特征 + 当前持仓比例 self.observation_space = spaces.Box( low=-np.inf, high=np.inf, shape=(features.shape[1] + 1,), dtype=np.float32 ) self.idx = 0 self.cash = 1_000_000.0 self.shares = 0.0 def reset(self, *, seed=None, options=None): super().reset(seed=seed) self.idx = 0 self.cash = 1_000_000.0 self.shares = 0.0 state = np.append(self.features[self.idx], 0.0) return state.astype(np.float32), {} def step(self, action): action = float(np.clip(action[0], -1.0, 1.0)) price = self.opens[self.idx + 1] # 成交价取下一根bar的开盘价 # 用成交价重估净资产,作为调整仓前的基准 net_worth = self.cash + self.shares * price cur_ratio = (self.shares * price) / net_worth target_ratio = np.clip(cur_ratio + action, -1.0, 1.0) target_value = target_ratio * net_worth trade_value = target_value - self.shares * price commission = abs(trade_value) * self.cost # 先扣成本再更新持仓 self.cash -= trade_value + commission self.shares += trade_value / price self.idx += 1 # 按本bar真实收盘价结算净资产,奖励取对数差分 net_worth_now = self.cash + self.shares * self.closes[self.idx] reward = np.log(net_worth_now / net_worth) done = self.idx >= len(self.closes) - 2 state = np.append(self.features[self.idx], self.shares * self.closes[self.idx] / net_worth_now) return state.astype(np.float32), reward, done, False, {}

代码里的核心逻辑是把动作解释为持仓比例的增量而不是目标仓位本身。这样智能体每次决策只做边际调整,训练早期不容易出现一步满仓、一步清仓的疯狂节奏。成本在成交瞬间从现金里扣除,而不是在期末一次性计提,更贴近真实账户的资金流。奖励用对数收益,天然支持多期复利,数值上也比净收益更平滑。

这版代码是演示用骨架,普通账户不允许透支买入,完整实现里还要把target_ratio限制在现金可覆盖的范围内;真实源码包则通常会再补三个核心约束:当日涨停时禁止买入、跌停时禁止卖出、T+1限制当日买入的份额不能当日卖出。涨停不能买、跌停不能卖是A股撮合的真实规则,不加进去,智能体就会学到在跌停价上疯狂抄底的动作模式。

2.3 成本模型参数:佣金、印花税与滑点怎么给

交易成本对深度强化学习结果的杀伤力远大于模型结构。我用的是一组常见默认值,训练和回测必须统一:

成本项A股常见取值说明
佣金万2.5,双向不同券商差异大,最低5元起收
印花税卖出单边0.05%按卖出成交金额计
过户费十万分之一,双向沪市股票收取,通常并入佣金估算
滑点0.01%~0.05%信号价与成交价的平均偏差
冲击成本按成交额比例递增小资金可忽略,单日换手率高于10%必须建模

把单边成本直接放大到千分之三来训练,是验证成本敏感度最有效的做法。智能体在模拟环境里学会频繁换手且还能盈利,说明它对成本模型足够鲁棒;反过来,训练集上高换手、高收益,把cost从0.0005改到0.003后收益曲线掉头向下,那就是典型的成本过拟合。

3. 把A股自动交易写成MDP:状态、动作与奖励的取舍

深度强化学习训练交易智能体的本质,是把“每天怎么买卖”变成一个序贯决策问题:环境给出行情特征,智能体输出动作,环境结算奖励。很多源码包最终跑不出结果,往往不是强化学习实现有问题,而是MDP的三个组件设计粗糙。下面拆开讲。

3.1 状态空间:别再把过去N天涨跌幅堆成一个向量

状态是智能体观察世界的全部信息。我常用的最小特征分四组:价格衍生(过去5/10/20日收益率、最高最低价位置、当日振幅)、成交(量比、5日均量偏离度、换手率)、技术指标(RSI、MACD柱、布林带位置)、账户状态(当前持仓比例、现金比例、累计盈亏)。

特征不是越多越好。A股分钟数据通常只有量价,强行构造几百维特征会拉低样本效率。我一般先用相关性过滤掉冗余维度,保留20到50维,然后做归一化。归一化的窗口选择很讲究:用整段历史的均值和方差做标准化,等于是把未来信息渗进状态;正确做法是用截至当前bar的滚动统计量,或者用一个固定的滑动窗口。这个细节决定训练集和验证集是不是被“连体”。

源码包里常见的错误是全样本fit标准化器,训练和回测共用同一组参数。虽然省事,但上线后状态分布一变,策略表现马上垮掉。把标准化器封装进env里,每个bar实时计算,训练和上线行为才一致。

3.2 动作空间:连续仓位调整与三档离散动作怎么选

动作空间直接决定训练难度。三种最常用的设计:

类型形式适合场景常见问题
连续仓位输出 [-1,1] 映射为目标持仓比例资金量大、追求精细调仓边界动作保守,难触发加减仓
离散三档加仓/减仓/不变交易频率低、手续费敏感长期退化成只减不增
离散五档-1/-0.5/0/+0.5/+1 仓位变化表达力与收敛速度折中档位语义不清晰

A股T+1规则下,当日买入的股票次日才能卖出,所以动作集合要区分开仓和平仓的可行区间。环境内部必须维护“今日可卖数量”这个状态,否则智能体可以偷偷学到无风险的日内“来回”操作,回测收益越漂亮越要警惕这一点。常见实现是给每只持仓股记录一个冻结份额,买入产生的份额标记为不可卖,次日解锁。

我一般选连续仓位增量:动作a∈[-1,1],目标仓位=当前仓位+a,再clip到[-1,1]。连续动作空间的策略梯度更平滑,配合PPO和SAC都容易收敛。离散动作适合交易信号本身就很粗的场景,比如只做日线级别的加减仓。

3.3 奖励函数:累计收益不是唯一选择,夏普也能写进奖励

奖励是智能体唯一的优化信号。直接用每日净资产的对数收益做奖励最简单:

reward_t = log(V_t) - log(V_{t-1})

问题在于它只鼓励“净值涨”,完全不惩罚回撤。同样回测三年,策略A年化30%但最大回撤40%,策略B年化20%但最大回撤8%,对数收益奖励会把A排在前面,可实盘里你大概率拿不住A。一个常用的改法是差分夏普比率(DSR),把回报均值除以回报波动,让智能体主动学习低波动路径,并支持在线流式计算。一个最小实现是这样的:

def differential_sharpe_reward(returns: np.ndarray, eta: float = 0.1) -> np.ndarray: """差分夏普奖励:用增量式矩估计近似滚动夏普,适合在step里逐项更新。""" A = 0.0 # 收益的一阶矩(EMA均值) B = 0.0 # 收益的二阶矩(EMA平方均值) rewards = np.zeros_like(returns) for t, r in enumerate(returns): denom = B - A * A if denom > 1e-8: # 先用历史矩计算本步奖励,再更新矩 rewards[t] = (B * r - 0.5 * A * r * r) / np.power(denom, 1.5) A += eta * (r - A) B += eta * (r * r - B) return rewards

注意循环里的顺序:奖励用上一时刻的A、B计算,然后才更新矩估计,这保证当前步不会把“自己的收益”提前算进历史矩里。eta控制历史记忆长度,eta越大对近期波动越敏感,A股行情风格切换快,我一般取0.05到0.1。把每个step的reward替换成这个输出,PPO训练时策略自然会对收益和波动做平衡。风控上更重的话,可以在log收益基础上直接扣回撤惩罚:

reward_t = log_ret_t - lambda * max(0, dd_t)

其中dd_t为当前净值相对历史峰值的回撤率。lambda取1.0时,策略每承受1%回撤就需要1%的额外收益来弥补,这比训练完成后再叠加风控规则干净得多。

4. 用PPO训练A股自动交易智能体:选型、代码与五组关键参数

MDP定义好之后,算法选型是下一个分水岭。A股自动交易智能体开发中,绝大多数源码包以PPO为骨干,这一章讲清楚为什么、怎么跑、什么参数值得先调。

4.1 为什么选PPO而不是DQN或SAC

DQN对连续仓位不友好,做离散动作又额外引入档位设计,状态值函数在非平稳行情里容易震荡。SAC的最大熵框架在奖励尺度不稳定时需要更多调参,A股奖励经常因为单日涨跌停出现极端值,SAC要让温度系数追上奖励尺度变化,训练过程很磨人。PPO用clip机制限制每次策略更新的幅度,对奖励尺度漂移和非平稳环境的容忍度最高,这是交易源码包里它出场率最高的直接原因。

另一个考虑是样本组织方式。A股日线单只股票每年只有两百多个样本,on-policy只能把多只股票拼接起来训练;分钟数据样本量大,PPO的on-policy策略反而稳定。我一般保持PPO为主力,同时留一个SAC对照实验,骨干代码复用同一套env和wrapper,超参各跑一组就行。

4.2 最小训练代码:stable-baselines3并行环境与LSTM策略

A股交易序列天然有时序依赖,用RecurrentPPO能记住最近若干bar的价格演化过程。下面是可跑的最小例程,数据准备好后直接在命令行执行:

from sb3_contrib import RecurrentPPO from stable_baselines3.common.vec_env import DummyVecEnv, VecNormalize def make_env(features, opens, closes): def _init(): return AShareTradingEnv(features, opens, closes, cost=0.001) return _init # 多只股票拼接后的训练集在此传入,每个env对应一只股票切片 env = DummyVecEnv([make_env(train_feats, train_opens, train_closes)]) env = VecNormalize(env, norm_obs=True, norm_reward=False, clip_obs=10.0) model = RecurrentPPO( "MlpLstmPolicy", # MLP+LSTM,兼顾当前特征与时序记忆 env, learning_rate=3e-4, n_steps=2048, # 每次收集2048步再更新 batch_size=256, n_epochs=10, gamma=0.99, gae_lambda=0.95, # 优势估计的长期依赖强度 clip_range=0.2, ent_coef=0.005, # 熵系数,控制探索 verbose=1, seed=42, ) model.learn(total_timesteps=200_000) model.save("ashare_ppo_agent.zip")

VecNormalize负责对状态做实时标准化,norm_reward=False是因为对数收益已经带有成本和仓位变化的物理尺度,再做窗口归一化会在不同回测期之间引入奖励尺度漂移,不利于观察成本对策略的约束。n_steps乘以并行环境数是每次更新的样本量,单env下2048步大约覆盖8个交易日,对分钟数据来说是几百根bar,更新频率合理。总步数20万在分钟数据上能覆盖多段完整行情,日线拼接数据稍显不够,可把total_timesteps提到50万以上。

4.3 决定智能体训练成败的五组参数

训练中要盯的参数很多,真正能决定成败的是下面五组,我按排查顺序排列:

参数常见范围调参方向失败信号
learning_rate1e-4 ~ 3e-4损失震荡就减半训练曲线抖动或NaN
n_steps1024 ~ 4096样本方差大就加大更新过频,收益剧烈摆动
clip_range0.1 ~ 0.3策略乱跳就调小单次更新破坏旧策略
ent_coef0 ~ 0.02熵跌到0就加大动作分布坍缩成单点
gamma0.95 ~ 0.999想要更长周期就调大持仓黏住不换手

排查顺序建议是:先看训练过程的奖励滑动均值是否稳定上行,再看熵保持是否健康,最后才看回测收益。很多人上来就调学习率,结果换手率骤降、动作固化,问题其实在ent_coef设成了0。

5. 回测与过拟合排查:让智能体在A股策略里不发疯

模型能跑通只完成了三分之一。A股源码包最常见的死法不是训练失败,而是过拟合叠加未来函数后的虚假繁荣。这一章给出回测的硬性流程和四个过拟合信号。

5.1 滚动回测与三段数据切分

训练前先把时间序列切成训练、验证、测试三段,不能随机打乱。常见比例是70%训练、15%验证、15%测试。验证集用来做early stopping和选超参;测试集只能跑一次,跑完不能再回头动参数。如果测试集失败,唯一的出路是回去换假设,而不是继续调参再测。

更严格的玩法是滚动回测。把训练窗口向前滚动,每训练一年就在接下来三个月回测,回测结果并入训练集继续滚。这能暴露策略在风格切换下的适应性。A股风格切换很频繁,2020年的核心资产、2021年的新能源、2024年的小微盘,想用一套参数吃遍所有年份基本不可能。我习惯固定几个市场风格区间,单独看每个区间的夏普与最大回撤,而不是只盯整体数字。

用简单的split配置控制流程:

SPLITS = [ ("2015-01-01", "2018-12-31"), # 训练 ("2019-01-01", "2019-12-31"), # 验证 ("2020-01-01", "2021-12-31"), # 测试区间1 ("2022-01-01", "2023-12-31"), # 测试区间2 ] # 每个测试区间只允许使用之前所有区间的数据做标准化与特征工程 # 回测配置里一旦出现测试区间内的统计量,直接判为失败

5.2 交易智能体过拟合的四个典型信号

回测报告里出现下面四种情况,都要停下来排查:

训练集夏普很高,验证集立刻掉头。这是最典型的过拟合,说明特征或超参把噪声当成规律。处理办法是简化特征集,并利用验证集做早停。

换手率和收益同时暴涨。多半是动作在利用撮合漏洞或未来函数。把cost放大10倍重跑一次,如果收益曲线明显塌掉,策略就没有真正学会交易逻辑。

最大回撤集中在某一段特定行情。策略可能对某类微观结构做过拟合。换一批训练期未出现过的股票池做外推测试,能快速识别这类脆弱性。

参数微小扰动导致结果剧烈变化。把随机种子从0改成1,训练结果差异很大,说明策略还没收敛或训练方差过高。一个稳定的交易智能体,对seed和训练集切片都应该有可解释的稳健性。

5.3 实盘部署的边界:合规通道、半自动上线与复盘指标

实盘前必须确认程序化交易的身份合法。个人和机构都通过券商提供的合规程序化交易通道执行,通常需要提前开通对应权限,订单频率和报单比例也受券商风控管理,这是底线问题。

上线第一版我强烈建议半自动:智能体只输出目标仓位,由人工在交易软件里执行。原因不是策略不行,而是实盘状态永远比模拟滞后,先跑两周模拟盘,记录实际成交价与信号价的偏差。复盘的指标只有一个:滑点偏差分布。如果偏差中位数超过训练时设定的滑点参数,就把环境的滑点调到实际值以上,重新训练。

6. 源码落地的进阶改造:延迟修复、奖励塑形与部署陷阱

6.1 特征延迟:紧急修复未来函数的最后一道防线

即使环境撮合做了“次日开盘成交”,如果特征里直接用了当天收盘价计算的技术指标,智能体依然能在盘中“看到”未来。检查时不光看状态向量,还要看特征生成的时间戳。一个直觉规则:t时刻能用的特征,只能是t-1及之前数据的函数。遇到shift(-1)或者直接引用close[t]的特征生成代码,一律改成close[t-1],并在环境里加一个信号延迟参数。

6.2 奖励塑形:把回撤惩罚写进训练

回撤惩罚最常见的做法是净值曲线最低点向下的距离。把该值所在bar截断,只对“当前净值低于历史最高值”的部分惩罚。

def reward_with_drawdown(log_ret: float, equity: float, peak: float, lam: float = 1.0) -> float: dd = max(0.0, (peak - equity) / peak) return log_ret - lam * dd

每一步rollout时维护peak并传进这个函数。lambda取1.0,策略回撤时惩罚强度相当于用收益去对冲;取0.3则更温和。比训练后再叠加风控更稳定,因为策略的每个动作都感知到回撤成本。源码包里常把这段逻辑写在train里而不是env里,导致分布式训练时奖励不一致。把它收敛进env的step函数,所有worker的奖励口径才能保持统一。

6.3 部署前把源码.zip过一遍自检清单

拿到源码包后,先跑通再谈改进,自检顺序建议如下表:

检查项最简验证方法
复权方式训练集与回测集统一用后复权
未来函数特征只依赖t-1前数据,成交使用次日开盘价
T+1约束环境里维护可卖持仓状态
成本参数训练与回测共用同一套佣金/印花税/滑点
过拟合换seed再训练,验证集结果保持合理
滑动窗口标准化不引入整段历史的统计量

把这六项写进训练管线的入口函数,任何一份新下载的交易智能体源码都要先过这关,再投入算力去调参。这样源码包就不会只停留在“能跑”的阶段,而是变成一套你看得懂、敢跑、能上线的A股自动交易智能体。

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

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

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

立即咨询