Python强化学习PPO在A股投资组合中的实战:从环境搭建到滚动训练
发布时间:2026/10/11 10:40:53 锦皓数字建站

简介这份资源面向量化投资与强化学习方向的开发者、金融工程学生及策略研究者提供将PPO算法落地中国A股市场的完整实践方案解决从环境搭建到策略训练、绩效评估的全流程问题。压缩包共35个文件约4.29MB以10个pth模型权重、8个py源码脚本为主辅以pyc缓存、xml配置、npz与dataframe格式的A股行情数据以及收益率曲线图覆盖训练、回测与结果展示各环节。项目选取15只A股构建投资组合通过每日调仓让智能体在连续动作空间中学习买卖与持有决策并借助stable-baselines3、pandas、numpy、matplotlib等库完成建模与可视化。已有1615人学习下载。读者可据此理解PPO限制新旧策略差异、提升训练稳定性的机制掌握投资组合动态调整与风险收益评估思路并参考收益率曲线判断策略表现为智能投资研究提供可复用的代码框架与实验参考。1. 用 PPO 在 A 股做投资组合为什么它比追涨杀跌更值得写代码A 股散户最熟悉的场景是盯盘一整天追进去就回调割掉就反弹。把这件事交给 Python 强化学习 PPO 算法本质是换一个思路——不再预测明天涨跌而是让模型在连续决策中学会「当前仓位该往哪挪」。PPOProximal Policy Optimization是深度强化学习里落地最稳的一类策略梯度算法它把「探索新策略」和「别把旧策略丢太远」这对矛盾用裁剪目标函数压住训练不容易崩。放到 A 股市场构建投资组合它要解决的是给定一篮子股票的历史行情和当前持仓输出下一期的调仓权重。适合有 Python 基础、懂一点 pandas、想认真跑通一条量化流水线的从业者。这篇不吹收益只讲怎么把环境、奖励、训练、回测这条链路搭起来以及哪里最容易翻车。2. PPO 做组合管理的建模前提状态、动作、奖励怎么定2.1 为什么选 PPO 而不是 DQN 或纯规则A 股组合调仓是典型的连续动作空间问题。DQN 这类基于值函数的方法输出的是离散动作的 Q 值你要把「每只股票买多少」离散成几十个档位维度一高就爆炸。PPO 直接输出连续分布通常是高斯分布的均值和方差天然适配权重向量。另一个现实原因是稳定性A 股噪声大、非平稳强策略梯度类算法对超参敏感PPO 的裁剪机制相当于给每次更新加了「后悔药」单步更新幅度被 clip 参数卡住不会因为某一段极端行情把策略带偏。常见做法是 PPO 配一个 Actor-Critic 结构Actor 出权重Critic 估状态价值用优势函数降低方差。选型上还有一层A 股有涨跌停、T1、停牌这些约束必须在环境里硬编码而不是指望模型自己学。PPO 的好处是动作后处理灵活——你可以在 Actor 输出后做一层归一化和约束投影再送进环境算奖励这个接口比 DQN 干净得多。2.2 状态特征工程把行情翻译成模型能吃的张量状态设计决定了模型上限。我一般把状态拆成三块市场级、个股级、账户级。市场级放指数收益率、成交量变化、波动率用来让模型感知整体环境。个股级放每只标的的过去 N 日收益率、换手率、均线偏离度。账户级放当前持仓权重、现金比例、浮动盈亏。三块拼成一个扁平向量或者按「时间步 × 特征」组织成序列喂给带 LSTM 的 Actor。import numpy as np import pandas as pd def build_state(price_df, holdings, cash, window20): # price_df: index日期, columns股票代码, value收盘价 ret price_df.pct_change().fillna(0.0) # 个股级过去 window 日收益率序列展平 stock_feat ret.iloc[-window:].values.flatten() # 市场级等权组合收益 滚动波动率 mkt_ret ret.iloc[-window:].mean(axis1).values mkt_vol ret.iloc[-window:].std(axis1).values # 账户级当前权重 现金 acc_feat np.concatenate([holdings, [cash]]) state np.concatenate([stock_feat, mkt_ret, mkt_vol, acc_feat]) return state.astype(np.float32)这段代码把三类特征拼成一个一维向量。window是回看窗口A 股日频常用 20 或 60太短噪声大太长维度爆炸且早期信息稀释。holdings是当前各标的权重必须和price_df的列顺序严格对齐否则模型学到的就是错位信号。注意pct_change首行是 NaN用fillna(0)处理别直接 drop否则时间轴会错位。2.3 动作空间与权重约束别让模型输出非法仓位Actor 网络输出一个长度为股票数的向量经过 softmax 变成权重再留一部分给现金。但 A 股有单票仓位上限、不能做空、T1 不能当日卖出这些都要在动作后处理里做。import torch import torch.nn.functional as F def postprocess_action(raw_logits, max_weight0.2, cash_ratio0.1): # raw_logits: Actor 输出的原始分数 w F.softmax(raw_logits, dim-1) # 单票上限截断 w torch.clamp(w, maxmax_weight) # 重新归一化到 (1 - cash_ratio) w w / w.sum() * (1.0 - cash_ratio) return wmax_weight是单票上限A 股公募常用 10%私募灵活些我一般设 0.2 做压力测试。cash_ratio是强制保留的现金比例用来应对 T1 和赎回设 0.1 意味着最多九成仓。这里用clamp再归一化是近似投影严格来说应该用单纯形投影但工程上这个近似够用且可微不影响梯度回传。注意 softmax 后再 clamp 会破坏概率和为 1所以必须重新归一化这一步漏了模型会输出总仓位超过 100% 的非法动作。3. 把 A 股行情接进 Gym 环境奖励函数与训练循环3.1 自定义 Gym 环境step 里到底算什么强化学习落地最脏的活是环境。A 股环境要处理交易日对齐、停牌填充、涨跌停不可成交。我一般继承gym.Env在step里做三件事按新权重调仓、按当日收益更新净值、算奖励。import gym from gym import spaces class AStockPortfolioEnv(gym.Env): def __init__(self, price_df, window20, cost0.0013): super().__init__() self.price_df price_df self.ret_df price_df.pct_change().fillna(0.0) self.window window self.cost cost # 单边交易成本含佣金和印花税近似 self.n_stock price_df.shape[1] self.action_space spaces.Box(low0, high1, shape(self.n_stock,), dtypenp.float32) self.observation_space spaces.Box(low-np.inf, highnp.inf, shape(window * self.n_stock window * 2 self.n_stock 1,), dtypenp.float32) self.reset() def reset(self): self.t self.window self.holdings np.zeros(self.n_stock, dtypenp.float32) self.cash 1.0 self.nav 1.0 return self._get_state() def _get_state(self): return build_state(self.price_df.iloc[:self.t], self.holdings, self.cash, self.window) def step(self, action): action np.clip(action, 0, 1) action action / (action.sum() 1e-8) * 0.9 # 留 10% 现金 # 交易成本权重变化绝对值之和 × 成本率 turnover np.abs(action - self.holdings).sum() cost turnover * self.cost # 当日收益 day_ret self.ret_df.iloc[self.t].values port_ret float(np.dot(action, day_ret)) - cost self.nav * (1 port_ret) self.holdings action self.cash 1.0 - action.sum() self.t 1 done self.t len(self.price_df) - 1 reward port_ret # 基础奖励单期对数收益可替换 return self._get_state(), reward, done, {}cost设 0.0013 是 A 股单边成本的粗略量级佣金万三加印花税千一卖出单边收实际要按你的券商费率改。turnover用权重变化绝对值之和衡量换手乘成本率就是这次调仓的摩擦。奖励先用单期收益简单但有效想控回撤可以改成port_ret - lambda * drawdown。注意self.t从window开始保证状态里始终有完整回看窗口别从 0 开始否则前 20 步状态是残缺的。3.2 奖励函数设计收益、回撤、换手怎么权衡奖励是 PPO 在 A 股里最玄学的地方。只用收益率模型会学出高频满仓梭哈只用夏普训练信号太稀疏。我一般用组合奖励def compute_reward(port_ret, nav_series, turnover, lambda_dd0.5, lambda_to0.001): # 回撤惩罚 peak np.max(nav_series) drawdown (peak - nav_series[-1]) / peak reward port_ret - lambda_dd * drawdown - lambda_to * turnover return rewardlambda_dd控制回撤惩罚力度设 0.5 意味着回撤 10% 扣 0.05 的奖励量级要和日收益通常 0.01 量级匹配太大模型会躺平不交易。lambda_to压换手A 股 T1 下高频调仓成本吃掉收益很快。这三个系数没有理论最优我一般先在训练集上网格粗搜再在验证集上确认不 overfit。3.3 PPO 训练循环clip 参数和并行环境PPO 的核心是裁剪目标。用 stable-baselines3 能省很多事但要知道关键参数在调什么。from stable_baselines3 import PPO from stable_baselines3.common.vec_env import DummyVecEnv env DummyVecEnv([lambda: AStockPortfolioEnv(train_price_df)]) model PPO( MlpPolicy, env, learning_rate3e-4, n_steps2048, batch_size64, gamma0.99, gae_lambda0.95, clip_range0.2, ent_coef0.01, verbose1, ) model.learn(total_timesteps500_000)clip_range0.2是 PPO 的标志性参数限制新旧策略概率比在 [0.8, 1.2]太大训练不稳太小学习慢。gamma0.99是折扣因子A 股日频下对应约 100 天的有效视野想更长设 0.995。ent_coef0.01是熵奖励系数鼓励探索A 股噪声大这个值别设太高否则策略太随机。n_steps是每次更新前采样的步数显存够就调大2048 是稳妥起点。gae_lambda控制优势估计的偏差方差权衡0.95 是通用值。4. 回测与评估训练完怎么知道模型是不是在自欺4.1 样本外回测时间序列切分别用随机切分量化里最常见的翻车是随机切分训练测试集。A 股时间序列有强自相关随机切会让未来信息泄漏到训练集回测收益虚高。必须按时间切前 70% 训练中间 15% 验证调参最后 15% 测试。测试集在调参阶段一次都不能看。n len(price_df) train_end int(n * 0.7) val_end int(n * 0.85) train_df price_df.iloc[:train_end] val_df price_df.iloc[train_end:val_end] test_df price_df.iloc[val_end:]切完之后环境里的window回看要跨切分点取数据否则验证集开头状态残缺。我一般把完整price_df传进环境用t索引控制起止而不是物理切 DataFrame。4.2 评估指标别只看年化收益单看年化收益会被幸存者偏差骗。至少看四个年化收益、最大回撤、夏普比率、换手率。A 股还要看相对沪深 300 的超额。指标计算方式健康区间参考年化收益净值末/初 开 n 次方减 1不设死线看超额最大回撤净值峰值到谷底最大跌幅控制在 20% 内较稳夏普比率超额收益均值/波动率日频年化后 1 算可用年化换手日均换手 × 250别超过 10 倍成本吃不消这些指标要在测试集上算且和等权买入持有基准对比。如果模型跑不赢等权说明特征或奖励设计有问题别急着上实盘。4.3 用净值曲线和持仓热力图做归因数字之外画图能看出模型行为。净值曲线对比基准看它在什么行情段跑赢跑输。持仓热力图看它是不是长期集中在少数几只票如果是说明分散化没学好可能是max_weight太松或熵系数太低。import matplotlib.pyplot as plt def plot_nav(nav_model, nav_bench): plt.plot(nav_model, labelPPO) plt.plot(nav_bench, labelEqual Weight) plt.legend() plt.title(Out-of-Sample NAV) plt.show()画图时横坐标日期太密集是常见问题用plt.gca().xaxis.set_major_locator(plt.MaxNLocator(10))控制刻度数量别硬塞所有日期。5. 避坑与排查A 股 PPO 训练里最容易翻车的五件事5.1 奖励不收敛净值一路向下现象训练几千步后 episode reward 持续为负净值曲线单调下跌。原因通常是奖励量级和网络输出不匹配或者交易成本设太高导致模型学出「不交易」的退化解。解决先把cost设为 0 跑一遍确认模型能学到正收益再逐步加成本同时检查奖励是否做了标准化PPO 对奖励尺度敏感可以用 running mean/std 归一化。5.2 模型只买一只股票现象持仓热力图显示权重长期集中在单只标的。原因是熵系数ent_coef太低策略过早收敛到局部最优或者max_weight设得太高。解决把ent_coef提到 0.05 试max_weight压到 0.15并在奖励里加分散化项比如权重向量的赫芬达尔指数惩罚。5.3 回测收益漂亮但实盘对不上现象样本外夏普 2 以上实盘一上就亏。原因多半是前视偏差状态里用了当日收盘价算收益但实际调仓只能按次日开盘价成交。解决环境里收益用t1的收益率动作在t时刻基于t及之前的信息生成成交价用次日开盘。这个错位是量化里最经典的血泪经验务必在环境里对齐。5.4 训练极慢一步要好几秒现象model.learn跑一天才几万步。原因是环境step里做了全量 DataFrame 切片或重复计算。解决把ret_df在__init__里预计算好step里只做索引取值状态构建避免每次重新pct_change。用DummyVecEnv或SubprocVecEnv并行多个环境CPU 核多就开 4 到 8 个。5.5 换手率高到成本吃光收益现象年化换手 30 倍以上扣费后收益归零。原因是奖励里没惩罚换手模型为了抓短期波动频繁调仓。解决奖励加lambda_to * turnover项lambda_to从 0.001 起调动作后处理里加权重变化平滑比如新权重和旧权重做指数移动平均限制单期最大换手。6. 进阶技巧用滚动训练和集成降低 A 股非平稳性的伤害A 股最大的敌人是非平稳——三年前的规律今年可能完全失效。一次性训练一个模型跑到底在实盘里迟早翻车。我现在的习惯是滚动训练每 250 个交易日重新训练一次用最近 3 到 5 年数据做训练集滚动窗口往前推。这样模型能吸收最新的市场结构代价是训练成本乘以滚动次数。具体做法是把训练循环包一层def walk_forward_train(price_df, train_years4, retrain_freq250): models [] n len(price_df) start train_years * 250 for t in range(start, n - retrain_freq, retrain_freq): train_slice price_df.iloc[t - train_years * 250:t] env DummyVecEnv([lambda: AStockPortfolioEnv(train_slice)]) model PPO(MlpPolicy, env, learning_rate3e-4, n_steps2048, batch_size64, clip_range0.2, ent_coef0.01, verbose0) model.learn(total_timesteps200_000) models.append((t, model)) return modelstrain_years是每次训练用的历史长度4 年约 1000 个交易日够 PPO 学到基本结构又不至于太旧。retrain_freq是重训频率250 约一年一次行情剧烈时可以缩到 60。注意每次重训后模型参数是全新的别接着上次的继续训否则旧策略会拖累新环境。再进一步是集成滚动训练出的多个模型在预测时对动作取平均或者按近期验证集表现加权。集成能显著降低单模型在特定行情段的过拟合。我一般保留最近 3 个滚动模型做等权集成实盘调仓时三个模型各出一版权重平均后再做约束投影。验证集成是否有效别只看收益看「模型间动作分歧度」如果三个模型输出的权重差异很大说明当前市场状态让模型们没共识这时候应该降低仓位而不是硬上。这个分歧度指标我自己用权重向量的 L1 距离算超过阈值就触发降仓。这套东西没有标准答案但比单模型裸奔稳得多。最后说个我踩过的坑别在训练集上反复调奖励系数调到满意为止那是在拟合训练集噪声。我的习惯是奖励结构定死后只在验证集上确认一次然后测试集跑一遍就封存不再回头改。A 股 PPO 这条链路工程细节比算法本身重要得多把环境对齐、成本算准、切分做对比换更花哨的算法管用。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。