
简介基于Python强化学习PPO算法在中国A股市场的应用资源包这里包含完整投资组合构建与自动化交易训练代码面向具备一定Python和量化基础、希望用强化学习实战A股的开发者。压缩包共35个文件约4.29MB核心为8个Python脚本环境、智能体、训练、回测等、10个不同训练阶段的actor模型权重、A股市场数据文件与收益率曲线图另有xml/iml项目配置和pyc缓存文件便于直接复用与二次修改。已有1615人学习下载。资料提供从StockTradingEnv构建、PPO策略训练到每日调仓的完整链路通过actor_*.pth可观察不同训练步数下的模型收敛情况借助China_A_shares数据与return.png可复现实验并评估策略收益曲线。项目以15只A股为投资池涵盖资金分配、风险控制和收益最大化等多环节配有训练脚本、回测环境与模型权重适合作为量化投资入门到进阶的参考。1. 为什么拿 PPO 做 A 股投资组合先打破几个预期用 Python 强化学习 PPO 算法在中国 A 股市场构建投资组合很多人的第一反应是去调网络结构和训练轮数但真正做过几次就会发现决定项目生死的根本不是算法而是数据对齐、奖励设计和交易成本有没有写进环境。PPO 这类深度强化学习算法的优势是能直接输出连续动作非常适合“每个股票配多少权重”的组合控制问题配合 PyTorch 可以自己掌握全部细节不用被商业平台的黑匣子绑住。这篇文章会从 A 股数据处理开始讲到一个最小能跑的 PPO 组合环境再给出训练循环和超参数最后把最容易被忽视的验证方法说清楚。无论你是做量化研究还是强化学习入门按这个路径落地的成本最低。2. 把 A 股行情变成强化学习的状态数据对齐、特征工程与奖励设计2.1 A 股交易规则先决定环境怎么建模A 股不是美股直接套用常见强化学习交易环境会翻车。第一个要处理的是涨跌停涨停板上买不进跌停板上卖不出模型却可能在那个位置给了很大权重第二个是 T1 制度当天买入的股票不能当天卖出训练循环里如果允许“当天买当天卖”模型会学到现实中不存在的套利行为第三个是复权问题除权除息会造成价格跳变直接把原始 K 线价格拿来算收益会产生大量假信号。第四个是交易成本佣金、印花税、过户费、滑点叠加起来远高于美股而 PPO 策略特别容易用高换手去刷奖励不处理成本就会训练出一个“回测赚钱、实盘亏钱”的组合。把这些规则映射到强化学习环境之前先列一张表明确每个规则怎么落地A 股规则对 PPO 环境的影响我一般这样处理涨跌停动作无法成交特征出现极端值对涨跌停股票加 mask封板时强制权重为 0T1当天买入不能当天卖出动作在下一个交易日生效建模单期持仓除权除息价格跳变产生虚假收益全部用前复权数据交易成本高频换手的“收益”变成负数在 reward 里按换手率扣成本这条表不是空话后面的环境代码会实际体现这些约束。很多网上流传的 Python 量化交易策略代码看见净值曲线很漂亮一检查发现连复权和手续费都没处理这种代码是不能当策略用的。2.2 用 tushare 拉前复权行情并做日期对齐A 股日频数据最方便的来源是 tushare但不是所有接口都能直接开箱即用。下面这个函数固定一个股票池拉取前复权日线收盘价最后统一成一个以日期为索引的 DataFrame。注意股票池必须是固定列表因为强化学习动作维度不能动态变化换股票池等于换环境。import pandas as pd import numpy as np import tushare as ts # 我习惯先固定 5-20 只股票维度稳定调试也方便 STOCK_POOL [600519.SH, 000001.SZ, 601318.SH, 000858.SZ] def load_qfq_close(codes, start20190101, end20231231): 拉取日线前复权收盘价返回 (日期, 股票代码) 的 DataFrame。 pro ts.pro_api(你的tushare_token) # 需要 tushare pro 权限 close_list [] for code in codes: # pro_bar 是 tushare 封装好的接口adjqfq 表示前复权 df pro.pro_bar(ts_codecode, adjqfq, start_datestart, end_dateend) if df is None or df.empty: print(f数据缺失: {code}) continue df df[[trade_date, close]] df[trade_date] pd.to_datetime(df[trade_date]) df df.set_index(trade_date).rename(columns{close: code}) close_list.append(df) close pd.concat(close_list, axis1).sort_index() # 停牌会让某一只股票没有当天数据先删掉全市场都休息的日期 close close.dropna(howall) # 单只股票停牌用前向填充环境里再用 mask 控制交易 close close.ffill() return close这段代码里最值得注意的地方是前复权。adjqfq把分红除权引起的跳空拉平使得收益率序列连续。但前复权价格会随着最新除权事件变化如果你拿 2010 年到 2023 年的数据做回测前复权后的早期价格会被反复修正这就是所谓“前复权漂移”。常见做法是回测区间不要太长比如只取 2019 年到 2023 年或者干脆改用后复权做特征避免这个坑。日期对齐不是简单地拼一个 DataFrame 就完事。环境里每个时间步要同时看到所有股票的观察值哪怕是停牌股票也需要有一个“维持上个交易状态”的占位。这个函数里ffill()就是干这件事的但要注意停牌股票在真实世界里不能交易必须回到环境里用 mask 处理不能只靠前值填充。2.3 特征面板给 PPO 的不是价格而是价格衍生的状态PPO 的输入不能直接放原始价格因为价格是绝对数值不同股票价格差异大神经网络会花很多精力去适配价格尺度。常见做法是把价格转成多种收益率和统计量最后堆成一个形状为(T, N, F)的特征面板。T是交易日数N是股票数量F是特征数量。def build_feature_panel(close, window20): 把收盘价 DataFrame 转成 (T, N, F) 的 numpy 数组。 close close.sort_index() # 简单收益率注意第一行是 NaN ret_1 close.pct_change() # 20 日滚动波动率第二步直接归一化 vol_20 close.pct_change().rolling(window).std() # 当前价距离 60 日最高点的回撤幅度反应趋势位置 high_gap close.rolling(60).max() / close - 1 feature_dict { ret_1: ret_1, vol_20: vol_20, high_gap: high_gap, } panel np.stack([feature_dict[k].values for k in feature_dict], axis-1) # 停牌/首日产生 NaN统一替换成 0 clean np.nan_to_num(panel, nan0.0, posinf0.0, neginf0.0) return clean这是一个非常朴素的特征版本目的不是拿到盈利因子而是把 PPO 的输入维度控制住。真正生产环境里我会在这个面板里加入量比、换手率、市值、五日资金流等截面因子。有一个关键原则计算特征时绝不能用整个回测区间统计量比如“全周期 z-score 标准化”。因为训练时模型已经看到了未来数据分布做样本外回测时会严重高估收益。正确做法是滚动标准化或者只计算上文提到的这类简单的相对指标至少不会直接引入未来信息。2.4 奖励设计不要把净值涨幅直接当奖励很多初学强化学习的人在股票环境里写reward 当日收益然后训练出来一个不停交易的模型。问题是组合的日收益率只有千分之一级别而换手产生的成本也是千分之一级别两者在数值上很接近PPO 只要稍微没调整好就会忽略成本。更好的奖励函数是组合收益减掉交易成本和组合风险惩罚。def calc_reward(weights, next_ret, prev_weights, cost0.001, risk_penalty0.5): weights: 当前权重向量 next_ret: 下一期收益向量 prev_weights: 上一期权重 cost: 单位换手成本 risk_penalty: 风险惩罚系数 port_ret float(np.dot(next_ret, weights)) turnover float(np.abs(weights - prev_weights).sum()) # 组合收益的波动作为风险惩罚避免权重过度集中 risk float(np.var(weights * next_ret)) return port_ret - cost * turnover - risk_penalty * risk这里turnover是全部权重变化绝对值之和除以 2 就是换手率的一半近似表现直观。risk_penalty控制模型对集中持仓的态度设为 0 时模型很容易学到“全押一只股票”设得过大模型又会变成懒汉长期不调仓。以 A 股日频数据来说cost至少取 0.001也就是万十实际操作中我会再加滑点。下章会把这段奖励逻辑嵌进 Gym 环境的step()里让训练循环直接消费。3. 用 Gym 搭建一个 A 股组合交易环境核心代码与动作约束3.1 环境接口为什么选 Gym环境是策略和数据的隔离层。PPO 算法只需要三个能力回到起点reset()、执行一个动作step()、描述动作/观察空间。Gym 的接口简单社区里的 PPO 训练框架基本都兼容所以不用自己造轮子。对 A 股组合交易来说观察空间是特征面板的展平向量动作空间理论上是一个连续的权重向量维度等于股票数量。observation_space这里设置为Box(low-inf, highinf, shape(N*F,))因为输入特征已经做过去 NaN 处理action_space设置为Box(low0, high1, shape(N,))动作代表各股票权重。但这种低层动作没法保证权重和为 1所以环境内部必须再做一次归一化和约束这部分也是自定义环境最常见的坑。3.2 最小可运行的组合环境下面这个类是一个完整的组合交易环境但刻意做了一些简化每步做一次权重再平衡奖励已经包含扣除交易成本和风险惩罚。它的核心逻辑是处理“动作转权重”和“计算下一期收益”这两步最容易写错。import gym from gym import spaces import numpy as np class ASharePortfolioEnv(gym.Env): 固定股票池的组合交易环境。 参数说明 feature: (T, N, F) 特征面板T 是交易日数 next_ret: (T, N) 下一期收益用于计算奖励 cost: 单笔换手成本建议按实际佣金印花税滑点合计 def __init__(self, feature, next_ret, cost0.001, risk_penalty0.5): super().__init__() self.feature feature self.next_ret next_ret self.T, self.N, self.F feature.shape self.action_space spaces.Box( low0.0, high1.0, shape(self.N,), dtypenp.float32 ) self.observation_space spaces.Box( low-np.inf, highnp.inf, shape(self.N * self.F,), dtypenp.float32 ) self.cost cost self.risk_penalty risk_penalty self.t 0 self.prev_w np.zeros(self.N) def reset(self): self.t 0 self.prev_w np.zeros(self.N) return self.feature[self.t].flatten() def step(self, action): # 1. 动作转权重截断负数、归一化到总和为 1 w np.maximum(action, 0.0) if w.sum() 1e-8: w w / w.sum() else: w self.prev_w.copy() # 2. 计算奖励注意这里使用 self.next_ret[self.t] # 就是上一个交易日收盘时定下权重获得的下一期收益 r self.next_ret[self.t] port_ret float(np.dot(r, w)) turnover float(np.abs(w - self.prev_w).sum()) risk float(np.var(r * w)) reward port_ret - self.cost * turnover - self.risk_penalty * risk # 3. 推进时间 self.t 1 done self.t self.T self.prev_w w.copy() next_obs self.feature[min(self.t, self.T - 1)].flatten() return next_obs, reward, done, {}这里的关键是self.next_ret[self.t]。它在环境外计算时就做了shift(-1)所以环境中直接用当期t取收益不会造成未来数据泄漏。如果你把收益率和特征在同一个时间切面上做模型就会学到一个“已经知道明天涨跌”的伪策略回测曲线再好都是幻觉。动作转权重这一步我直接用了np.maximum(action, 0.0)然后归一化。因为 PPO 为了让奖励最大总会尝试输出负权重来做空A 股做空门槛高普通组合不应允许空头所以这一步把负权重截断为 0。如果你想保留“空仓”能力需要把现金单独作为一个维度加入到股票列表尾部并允许权重全部落在现金上这是一个简单但实用的扩展。3.3 交易成本和 T1 的建模细节交易成本建模直接决定策略的换手行为。表格里是一般日内策略的成本假设日频组合可以按保守值设定成本来源大致数值说明佣金万 2.5-万 3买卖双边收印花税千 1卖出单边收滑点万 5-千 1取决于流动性和单量冲击成本不定权重集中时更要考虑把cost设成 0.001比很多公开代码里的 0.0001 更接近实盘。PPO 模型在成本太高时倾向于不交易这是正确的行为如果回测出的策略每天都调仓但净值还在涨大概率是成本参数设小了。T1 在这个环境里通过“单期持仓”实现。动作在t时刻生效收益也只在t1获得两天后模型已经根据新的特征重新决策不会出现同一天买进卖出。如果做更高频的日内策略那要重新设计时间轴但 A 股日频组合用这种“隔日结算”的建模是安全和稳妥的。3.4 观察空间别忘记展平Box环境要求观察是一个向量所以reset()和step()返回的观察都用flatten()把 (N, F) 矩阵展平成(N*F,)的向量。不少从强化学习教程抄来的代码会忘记这一步导致策略网络输入维度对不上。如果你后续要用 LSTM 或 Transformer 处理时序可以不展平改用自定义观察结构但那样需要同步修改 PPO 实现建议先把基础版本调通再考虑。4. 手写 PPO 训练循环PyTorch 实现与关键超参4.1 为什么组合控制任务适合用 PPO股票组合控制是一个连续动作问题。DQN 没法直接输出连续权重DDPG 和 SAC 容易因为价值网络估计不准出现 Q 值发散PPO 作为 on-policy 算法对超参数的敏感度相对低只要 clip 不设置太激进策略更新不会一步走到死区。PPO 算法变种很多但核心都在损失函数和优势估计上搞明白这一节其他变种都只是调结构。PPO 的核心是防止策略更新幅度过大。它用 ratio 表示新旧策略在同一个动作上的概率比值如果这个比值偏离 1 太远就把损失函数截断让策略只能“小步快跑”。另外一个重点是 GAE 优势估计它平衡偏差和方差让奖励在各时间步之间传播得更平滑。4.2 Actor-Critic 网络用 Dirichlet 分布输出组合权重组合权重有三个约束非负、和为 1、连续。常规做法是让 Actor 输出正态分布的均值再用 softmax 归一化但更自然的是直接用 Dirichlet 分布因为 Dirichlet 的采样点天然落在一个单纯形上正好对应“所有权重非负且和为 1”的组合约束。我给 Actor 输出一个正的浓度参数 alpha价值网络从同一个特征中估出状态值 V(s)。import torch import torch.nn as nn import torch.nn.functional as F from torch.distributions import Dirichlet class DirichletActorCritic(nn.Module): def __init__(self, input_dim, n_assets, hidden_dim128): super().__init__() self.net nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.Tanh(), nn.Linear(hidden_dim, hidden_dim), nn.Tanh(), ) self.actor_head nn.Linear(hidden_dim, n_assets) self.critic_head nn.Linear(hidden_dim, 1) def forward(self, obs): h self.net(obs) # softplus 保证 alpha 全为正加 0.01 防止出现 0 浓度 alpha F.softplus(self.actor_head(h)) 0.01 value self.critic_head(h) return alpha, value def let_act(self, obs, deterministicFalse): 采样动作或输出确定性权重并返回 log_prob 和 value。 alpha, value self.forward(obs) dist Dirichlet(alpha) if deterministic: # 确定性动作用 alpha 归一化后的均值满足 sum1 action alpha / alpha.sum(dim-1, keepdimTrue) else: action dist.sample() log_prob dist.log_prob(action) return action, log_prob, value def evaluate(self, obs, action): 更新阶段使用计算给定动作的 log_prob、value 和熵。 alpha, value self.forward(obs) dist Dirichlet(alpha) log_prob dist.log_prob(action) entropy dist.entropy() return log_prob, value, entropy这段代码里alpha是 Dirichlet 分布的浓度参数浓度越大采样出的权重越接近确定性均值浓度太小权重会散到边界附近模型可能在测试时输出极端组合。所以softplus加0.01不是随便写的它保证分布始终有一个最小方差避免训练早期直接卡在边界上。如果不想要这个性质可以把0.01调成0.1但过大的加数会压缩高 alpha 的差异让模型难以表达“极度看好某只股票”的意图。4.3 训练循环GAE 优势估计 clip loss下面是一个最小可跑的 PPO 训练循环。它按轨迹收集一批数据计算 GAE 优势然后执行ppo_epochs轮更新。为了容易读懂刻意省略了向量化加速和剪裁超长 episode 的逻辑但核心公式是完整的。def compute_gae(rewards, values, next_value, gamma0.99, lam0.95): GAE 优势估计返回 advantage 和 return。 rewards torch.tensor(rewards, dtypetorch.float32) values torch.stack(values).squeeze() next_value next_value.squeeze() deltas rewards gamma * next_value - values advantages torch.zeros_like(deltas) gae 0.0 for t in reversed(range(len(deltas))): gae deltas[t] gamma * lam * gae advantages[t] gae returns advantages values return advantages.detach(), returns.detach() def ppo_update(model, obs, actions, old_loglp, adv, returns, opt, ppo_epochs5, clip0.2, ent_coef0.01): # 标准化 advantage避免奖励尺度影响 adv (adv - adv.mean()) / (adv.std() 1e-8) for _ in range(ppo_epochs): log_prob, value, entropy model.evaluate(obs, actions) ratio torch.exp(log_prob - old_loglp.detach()) clip_loss torch.clamp(ratio, 1.0 - clip, 1.0 clip) * adv policy_loss -torch.min(ratio * adv, clip_loss).mean() value_loss F.mse_loss(value.squeeze(), returns.detach()) loss policy_loss 0.5 * value_loss - ent_coef * entropy.mean() opt.zero_grad() loss.backward() opt.step() def train_ppo(env, model, steps2000, rollout_steps128, lr3e-4, gamma0.99, lam0.95, ppo_epochs5, clip0.2, ent_coef0.01): opt torch.optim.Adam(model.parameters(), lrlr) obs env.reset() for step in range(steps): obs_list, act_list, lp_list, val_list, rew_list [], [], [], [], [] # 收集 rollout_steps 长度的轨迹 for _ in range(rollout_steps): with torch.no_grad(): act, logp, val model.let_act( torch.FloatTensor(obs).unsqueeze(0) ) next_obs, rew, done, _ env.step(act.squeeze(0).numpy()) obs_list.append(torch.FloatTensor(obs).unsqueeze(0)) act_list.append(act.squeeze(0)) lp_list.append(logp) val_list.append(val.squeeze()) rew_list.append(rew) obs next_obs if done: obs env.reset() # 最后一个状态如果是终止状态bootstrap 应为 0 with torch.no_grad(): if done: next_value torch.zeros(1) else: _, next_value model.forward( torch.FloatTensor(obs).unsqueeze(0) ) adv, returns compute_gae(rew_list, val_list, next_value) ppo_update( model, torch.cat(obs_list), torch.stack(act_list), torch.stack(lp_list), adv, returns, opt, ppo_epochs, clip, ent_coef, ) if step % 100 0: print(fstep {step}, avg_reward{np.mean(rew_list):.5f})训练循环里最容易出错的地方是done后的next_value。很多代码在轨迹终止后直接拿下一个 episode 的初始值做 bootstrap等于给上一个终止时刻凭空加了未来信息这是典型的“奖励泄漏”。这里判断done之后把next_value置 0才符合 MDP 终止条件。如果你在真实项目中到了深夜还在查“为什么回测净值一直向上但样本外崩掉”先检查这里是不是写错了。4.4 关键超参数怎么调PPO 在 A 股数据上不是越大的 epoch 越好。股票日频数据只有几千个交易日一个rollout_steps就占了近十分之一多次更新同一个 batch 容易导致过拟合。常用参数整理如下参数推荐范围我的经验ppo_epochs3-5超过 5 容易让当前 batch 过度拟合clip0.1-0.20.2 更通用0.1 更保守gamma0.99日频数据可以设 0.99太低会让模型短视lam (GAE)0.95越小越偏 bootstrap越大越偏蒙特卡洛ent_coef0.005-0.01太小输出极端权重太大策略随机乱走lr1e-4 到 3e-4过高训练很快崩尤其 Dirichlet 采样方差大rollout_steps128-512数据少时 256 是一个好起点训练刚开始如果看到loss上升不用太紧张第一轮更新会把价值网络调整到正常尺度。但连续几十个 episodeloss都不下降就要检查 reward 是否恒定、特征是否全为 0、环境是否在某个状态死循环。PPO 是出了名的“网络结构简单数据问题复杂”尤其股票数据噪声大、信噪比低很多调参看起来是算法问题实际是 reward 尺度问题。建议先把reward打印出来确认单步奖励在[-0.01, 0.01]附近再回头调clip和lr。5. 避坑PPO 在 A 股数据上容易反复踩的 5 个现场PPO 用在 A 股踩坑频率远高于跑通基准模型。这里把最常见的 5 个问题按“现象 → 原因 → 解决”写出来全部是我在实际项目中反复确认过的不是从教程里抄出来的经验。5.1 训练不到 500 步loss 变成 NaN现象loss从正常值变成 NaNlog_prob也没有幸免而且std()开始报警。原因绝大多数情况是特征或奖励里出现了inf。比如pct_change()在复权价格有 0 的时候返回inf或者np.var(r * w)在权重全为 0 时得到 0扣除成本后 reward 仍然正常但权重归一化过程中如果某只股票数据缺失feature里会留下NaN。解决所有特征在进入网络前做一次np.nan_to_num(..., posinf0, neginf0)奖励计算时对turnover和risk加一个小 epsilon另外把torch.FloatTensor改成torch.nan_to_num(torch.FloatTensor(obs))也可以兜底。不要把问题丢给 Adam优化器救不了NaN。5.2 训练集净值曲线漂亮样本外直接崩盘现象同一个策略在训练区间里年化 30%到验证区间变成回撤 20%。原因最普遍是全局归一化泄漏。比如你对整个数据集的收益做 Z-score模型已经知道了未来的均值和标准差或者你用未来数据计算滚动均线特征那特征本身包含未来信息。解决特征只能用当前时刻及以前的数据计算不能把未来值算进均值。如果非要做标准化先在训练段上拟合 scaler再分别应用到训练段、验证段和测试段。还有一个更简单的做法直接用相对指标例如“当前收益率”和“滚动波动率”这类指标天然规避了全局统计量泄漏的问题。5.3 模型每天都在调仓但扣掉手续费后净值一动不动现象观察权重曲线换手率极高几乎每个step都会把组合权重改成完全不同的结构。原因reward 里没有交易成本或者cost设得远远低于真实佣金。PPO 会用“每期都调仓”去榨取微小的隐含噪声收益但这个收益在真实交易里会被交易成本吃光。解决把cost从 0.0001 改成 0.001再观察换手率是否下降。如果换手率还是高就提高risk_penalty或者在step中增加一个“权重变化大于阈值才允许调仓”的规则。记住一个血泪经验策略在回测里调仓越频繁样本外衰减越快。5.4 组合权重集中在单只股票风险被严重高估现象输出权重经常是某只股票 0.95其他股票合计不到 0.05。原因可能是ent_coef太低Dirichlet 采样的方差被压到很窄也可能是 reward 里没有激励分散化模型发现押注一只高波动股票能拿到更大的期望收益。解决不要只靠risk_penalty把 reward 里的风险惩罚改成“组合收益方差”更直接的做法是对权重加边界约束比如单只股票不超过 0.3。在环境step()中做截断和再归一化即可。权重上限是最粗暴但最有效的控制手段它牺牲了一点点最优性但保住了组合的真实风险特征。5.5 换一段历史数据同一套算法结论完全不同现象用 2019-2021 训练2022 测试效果不错改成 2018-2020 训练2021 测试就崩掉。原因A 股的风格轮动和波动聚集性非常强模型学到的不是稳定规律而是适应某一段市场环境的“记忆”。解决不要在单一时间窗口上反复调参至少把数据切成“训练 3 年 验证 1 年 测试 1 年”并且多次滚动窗口训练。如果每次窗口内最优超参数都不同说明策略的过拟合概率很高这种现象用 PBO概率最优过拟合可以量化下一章专门讲它。6. 把 PPO 策略做进取证滚动回测、PBO 与最终检查清单6.1 时间切分训练、验证、测试三层缺一不可很多开源项目只划分训练集和测试集超参数在测试集上调了一遍又一遍测试集就等于训练集。我的习惯是严格切三段训练段选 2019-2021验证段选 2022测试段选 2023。在验证段上调整ent_coef、cost、risk_penalty选好之后测试段只运行一次绝不能根据测试段结果再回头改参数。6.2 用 PBO 量化过拟合概率PBO 不是一篇文章而是一种思想把训练窗口多次滑动每次重新训练记录每个策略在所有窗口内表现为“最优”的分布。如果某一次最优策略在相邻窗口内表现垫底说明最优策略选择高度不稳定。实际操作时我会对同一段训练数据用 3 个随机种子训练分别生成三个 PPO 模型然后在不同滚动窗口上评估它们的收益率排名。如果“最优模型”每次都在换就说明当前策略存在严重的过拟合。这个流程不需要额外依赖库写一个循环把模型存下来即可。6.3 评估时用确定性权重不要用采样权重训练时为了让模型探索我们让 Dirichlet 分布在let_act中采样评估时再把采样打开就会导致结果抖动很难判断策略是变好了还是随机噪声带来的。评估阶段应该使用确定性权重def deterministic_weight(model, obs): 用 alpha 分布的均值作为最终组合权重。 alpha, _ model.forward(torch.FloatTensor(obs).unsqueeze(0)) w alpha / alpha.sum(dim-1, keepdimTrue) return w.squeeze(0).numpy()这段代码在验证和回测时都能用。注意如果只在最终测试阶段把采样换成了均值可能高估策略表现因为训练时模型经验过的动作分布更散所以我在任何一次正式评估前都会跑两遍一遍采样、一遍确定性两者差异过大说明策略还没收敛。6.4 我最终一定会做的一次检查最后一个习惯是在整个回测结束后把cost0.002重跑一次。这个成本已经是真实交易成本的保守上限如果策略在这里还能盈利才敢谈实盘。每次改完超参我都会记录三行改动内容、验证集表现、测试集表现防止自己陷入“调参调出幻觉”的循环。希望这份记录能让你少走一些弯路也希望你自己跑通的时候少遇到点半夜刷 NaN 的痛苦。本文还有配套的精品资源点击获取