尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用DQN构建沪深300择时智能体:Gym环境搭建与回测全解析

用DQN构建沪深300择时智能体:Gym环境搭建与回测全解析 简介面向沪深300指数量化择时任务基于 DQN 与 OpenAI Gym 的完整实现方案适合金融工程、量化投资方向的开发者与研究人员参考。压缩包内包含强化学习训练与评估所需的智能体、环境交互、记忆回放、模型结构等 Python 模块并附带历史行情数据表、运行说明和 notebook帮助理解交易环境构建、奖励设定与策略迭代流程。资源共 10 个文件以 py、ipynb、xlsx、md 等类型为主大小约 386KB代码与数据分离便于按需调用。目前已有 127 人学习。通过这套方案读者既能掌握 DQN 算法与 Gym 环境对接的关键细节也能基于沪深300数据直接开展策略实验代码模块划分明确适合改造为其他指数或标的的择时研究配套说明对关键参数和数据预处理做了梳理可有效降低二次开发门槛为后续优化量化交易系统提供实践基础。1. 把沪深300日K交给智能体DQN择时策略为什么值得跑一遍把沪深300的日K数据交给一个强化学习Agent让它自己学习“什么时候该持有、什么时候该空仓”第一次跑通这个流程的人大多不是感叹预测有多准而是发现“它真的学会了空仓”。传统择时策略本质是写死的规则MACD金叉买入、跌破20日均线卖出。规则能沉淀经验但市场波动模式一变参数就要重新调。DQN把择时重新表达为序列决策问题每个交易日收盘后Agent观察当前市场状态选择一个动作持有或空仓从环境拿到收益作为奖励训练目标是累计收益最大化。沪深300流动性好、成分相对稳定、数据完整很适合做这个范式验证。这篇内容适合量化研究员做策略原型也适合想系统搞懂强化学习如何落地到金融开发的工程师。下文基于一份包含Agent、Environment、Functions模块的工程把Gym环境构建、DQN训练、回测评估的完整链路逐段拆开。2. 交易环境落地把日K序列改写成Gym Env的三个关键模块在这份工程里Environment.py是全流程的地基。OpenAI Gym框架的价值在于把“状态-动作-奖励”的交互协议固定下来后面的Agent、Memory、回测代码都围绕同一套接口写。构建时三件事的顺序不能乱先定义状态空间能看到什么再确定动作空间最后才设计奖励函数。顺序反了后面改代码的成本会指数上升。下面按这个顺序拆同时把环境代码里最容易埋“未来函数”的时序对齐问题单独说透。2.1 状态空间为什么不能直接把收盘价喂给Q网络绝对价格是非平稳序列2015年的4000点和2024年的4000点在市场结构上完全不是一回事。直接用收盘价作为状态Q网络会被绝对点位干扰学不出跨时间段的规律。常见做法是构造一组归一化特征让Agent看到的是“当前价格相对历史的位置”而不是价格本身。特征名计算方式选择理由ret_5ln(close[t] / close[t-5])短期动量数值平稳bias_ma20close[t] / MA20(t) - 1价格对中期均线的偏离度贴合均线择时直觉vol_20过去20日对数收益率的std风险度量波动率聚集时影响持仓意愿rsi_1414日RSI标准化到0-1区间超买超卖识别vol_ratiovolume[t] / MA5(volume[t]) - 1放量缩量辅助识别突破或诱多这些特征在Functions.py里一次性算好存入DataFrameEnvironment.py只消费加工后的列。状态不是某一个时刻的特征而是过去20个交易日的特征窗口展平后是20×5100维向量。这里有个容易被忽略的细节状态里不能混入t1日的数据否则模型在训练时偷看了未来回测曲线会很漂亮实盘立刻崩掉。2.2 动作空间与奖励函数离散二分与调仓成本惩罚动作空间用Gym的Discrete(2)0表示空仓1表示持有。沪深300现货做空受到融券约束实盘落地时做空成本高、可执行性差所以工程版本先做“满仓/空仓”的二分决策后续要引入做空再单独处理融券成本不能简单把动作扩展成3。奖励函数设计是整个环境里最影响训练结果的部分。我一般用“持仓收益减调仓成本”reward action × 当日收益 − cost_rate × (action ≠ 上一日动作)也就是说只有仓位状态发生切换时才扣一次成本。这样设计的好处是Agent必须判断“切换带来的收益增量能不能覆盖交易成本”否则就会陷入高频换手的陷阱。cost_rate取2e-4对应万二左右的单边费用和滑点这个量级对日频ETF级别流动性比较合理。如果交易的是个股或流动性一般的品种要上调到5e-4甚至1e-3。2.3 Environment.py核心代码与状态时序说明import gym import numpy as np import pandas as pd from gym import spaces class HS300TradingEnv(gym.Env): 沪深300日频择时环境0空仓1持有 def __init__(self, df, state_window20, cost_rate2e-4): super().__init__() self.df df.reset_index(dropTrue) self.window state_window self.cost_rate cost_rate self.action_space spaces.Discrete(2) self.feature_size self._get_features(0).shape[0] self.observation_space spaces.Box( low-np.inf, highnp.inf, shape(self.window * self.feature_size,), dtypenp.float32 ) def _get_features(self, t): # 返回单个交易日的归一化特征向量特征列已由Functions.py预处理 return np.array([ self.df.loc[t, ret_5], self.df.loc[t, bias_ma20], self.df.loc[t, vol_20], self.df.loc[t, rsi_14], self.df.loc[t, vol_ratio], ], dtypenp.float32) def _build_state(self, t): # 取[t-window1, t]区间特征并展平作为Q网络的输入 state self.df.iloc[t - self.window 1: t 1][ [ret_5, bias_ma20, vol_20, rsi_14, vol_ratio] ].to_numpy() return state.reshape(-1).astype(np.float32) def reset(self): self.current_step self.window self.prev_action 0 return self._build_state(self.current_step) def step(self, action): # 先推进时间再结算收益当日收盘决策次日收益生效 self.current_step 1 daily_ret self.df.loc[self.current_step, ret] reward float(action) * daily_ret if action ! self.prev_action: reward - self.cost_rate self.prev_action action done self.current_step len(self.df) - 1 return self._build_state(self.current_step), reward, done, {}这段代码的核心在step函数里先把current_step加1再取那一天的收益。reset返回的是第20天收盘后的状态第一次调用step时current_step变成21reward用第21天的收益结算动作却在第20天收盘时已经决定。这个“决策在前、收益在后”的顺序保证了没有未来函数。每次切换仓位只扣一次cost_rate相当于把开仓成本和平仓成本合并成了单边成本简化处理但量级上要留足余量。还要注意做空策略需要单独设计成本结构不能直接复用这个二分动作环境。2.4 奖励函数的三种变体对比很多初学者会把奖励写成“绝对收益”或者“当日收益×仓位”不带任何成本惩罚训练出来的策略换手率极高回测看着收益不错加上手续费后直接变成亏损。下面这张表对比三种常见方案奖励方案计算公式典型表现纯持仓收益action × daily_ret换手极高忽略成本后收益虚高切换扣单边成本action × daily_ret − cost×切换策略学会低频切换接近实盘空仓附加惩罚方案2 − 0.0001×空仓天数Agent被迫持仓牛市好用震荡市亏损工程包里给出的实现默认是第二种也是我最推荐的版本。第一种适合验证模型能不能学习不适合做回测报告第三种需要额外调惩罚系数多一个超参数就多一层过拟合风险日频场景下没有必要。3. DQN智能体实现Model、Memory、Agent三者的协作循环DQN不是单个神经网络而是一套由经验回放缓冲池、在线Q网络、目标Q网络和训练循环组成的系统。工程里Memory.py管经验回放Model.py定义Q网络结构Agent.py把训练逻辑串起来。这一章把三个文件的职责边界讲清楚然后给出可以抄走的训练循环代码和超参数调试经验。3.1 经验回放打破金融数据的时间相关性金融序列相邻样本之间强相关如果每来一条样本立即做梯度更新模型会反复在局部模式上震荡甚至把噪声学进去。经验回放的思路是把(s, a, r, s_, done)存进一个固定容量的缓冲区训练时随机采样一批打断时间相关性让模型每次更新都能看到不同市场阶段的样本。这相当于监督学习里的iid假设在强化学习里的替代方案。from collections import deque import random import numpy as np class ReplayBuffer: def __init__(self, capacity100000): self.buffer deque(maxlencapacity) def push(self, state, action, reward, next_state, done): # 每条经验保存完整五元组供后续随机采样 self.buffer.append((state, action, reward, next_state, done)) def sample(self, batch_size): batch random.sample(self.buffer, batch_size) states np.array([b[0] for b in batch], dtypenp.float32) actions np.array([b[1] for b in batch], dtypenp.int64) rewards np.array([b[2] for b in batch], dtypenp.float32) next_states np.array([b[3] for b in batch], dtypenp.float32) dones np.array([b[4] for b in batch], dtypenp.float32) return states, actions, rewards, next_states, donescapacity参数决定缓冲区能存多少条历史经验。100000这个量级在日频交易场景下非常大因为一年只有250个交易日50年数据才12500条100000的容量基本能覆盖全部历史样本。deque的maxlen机制会自动丢弃最老的样本不需要手动清理。3.2 目标网络为什么DQN不能单网络自我更新DQN的Q值更新公式是target r γ × max(Q(s_, a))如果在线网络同时负责“预测当前Q值”和“生成学习目标”每一步更新都在移动目标模型容易发散。工程里用了一个target_net它的参数不参与梯度更新只负责生成回归目标每隔固定步数把在线网络的参数拷贝给它。# 硬拷贝更新每隔target_update步在Agent.py中执行一次 target_net.load_state_dict(online_net.state_dict()) # 或者采用软更新每一步都小幅靠近在线网络 # tau0.01时target_net tau * online_net (1-tau) * target_net for target_param, online_param in zip( target_net.parameters(), online_net.parameters()): target_param.data.copy_( tau * online_param.data (1 - tau) * target_param.data)硬拷贝实现简单但更新间隔需要调太频繁目标网络不稳定太久则目标网络跟不上在线网络。我一般先试每1000步硬拷贝一次如果训练曲线震荡明显就改软更新。软更新的τ取0.01每一步都小幅逼近在线网络稳定性更好代价是训练时间稍长。3.3 Agent.py训练循环与超参数表# 单episode训练循环实际调用时由run.ipynb驱动 state env.reset() done False episode_loss [] while not done: action agent.choose_action(state, epsilon) # epsilon-greedy策略 next_state, reward, done, _ env.step(action) buffer.push(state, action, reward, next_state, done) # 缓冲区样本足够后才开始训练 if len(buffer) batch_size: states, actions, rewards, next_states, dones buffer.sample(batch_size) q_next target_net(next_states).detach() # 目标网络不反传梯度 q_target rewards gamma * q_next.max(dim1).values * (1 - dones) q_current online_net(states).gather(1, actions.unsqueeze(1)).squeeze() loss mse_loss(q_current, q_target) optimizer.zero_grad() loss.backward() optimizer.step() state next_state epsilon max(epsilon * epsilon_decay, epsilon_min) if global_step % target_update 0: target_net.load_state_dict(online_net.state_dict())这段代码里有几个参数直接影响训练结果。gamma决定模型看多远0.95偏向短期收益0.99更重视长期累计日频择时我通常取0.99因为持有逻辑需要前瞻更长的收益窗口。epsilon从1.0按每步0.998倍衰减到0.01意思是前几百个step先充分随机探索市场后期再让模型专注最优动作。参数推荐范围调试经验gamma0.95 - 0.990.99时策略更愿意持仓扛波动learning_rate1e-4 - 3e-4超过1e-3loss曲线容易直接爆掉batch_size64 - 256日频数据样本量小128足够epsilon_min0.01 - 0.05后期保留少量探索防策略固化replay容量50000 - 200000足够覆盖一个完整牛熊周期target_update500 - 2000步硬拷贝间隔震荡大就调大3.4 稳定训练的两个小手段训练初期如果reward曲线一直不上涨先别急着调学习率看看epsilon是否已经衰减到0.3以下。很多时候是模型还在大量随机探索表现自然不好等到探索率降下来曲线会开始分化。另一个常用技巧是reward归一化把reward除以最近一段时间的滚动标准差防止涨跌波动大的年份让Q值尺度过大。比如2015年波动率是2017年的好几倍不做归一化的话模型会盲目追逐高波动交易忽略平稳年份的收益机会。4. 策略验证滚动训练与回测里的过拟合防线DQN训练收敛之后第一件事不是欣喜而是验证。金融数据时间序列的验证和常规机器学习有本质区别不能打乱不能全局shuffle不能把有效数据用在训练段。这一章给出滚动训练的实现方式和一整套过拟合排查手段。4.1 金融数据不能打乱时间序列切分随机打乱样本在股票数据上等价于引入未来信息。第300天的交易决策被第301天的样本训练过实盘不可能有这种条件。正确做法是按时间顺序切分前段训练、中段验证、后段测试。数据量不够时至少保证训练段覆盖一个完整的震荡区间否则模型只见过上涨行情验证段立刻暴露问题。# 按时间顺序滚动划分训练/验证集fold_num决定切几段 import pandas as pd def rolling_split(df, train_years3, val_years1, fold_num3): days_per_year 252 train_len train_years * days_per_year val_len val_years * days_per_year for fold in range(fold_num): start fold * val_len train_end start train_len val_end train_end val_len train_df df.iloc[start:train_end] val_df df.iloc[train_end:val_end] yield train_df, val_dffold_num等于3时三组验证集覆盖了不同市场阶段可能一组是牛市尾段一组是震荡阴跌一组是缓慢抬升。每组都从完全未见过的数据上评估策略比单次回测可信得多。我一般把训练段保留3年因为DQN需要足够多的“涨跌切换”来控制探索和利用的平衡训练段太短容易学成空仓躺平。4.2 成本敏感度测试过拟合到无摩擦市场的典型特征验证策略的第一步是看它对交易成本是否过于敏感。在Environment.py里把cost_rate改成0、1e-4、3e-4、8e-4分别跑一遍观察总收益和交易次数变化。如果cost_rate从0涨到3e-4后收益从年化30%变成年化5%说明策略本质上是在靠高频换手赚收益实盘摩擦成本会吃掉这些利润这种策略没有落地价值。cost_rate年化收益最大回撤交易次数/年031.2%18.5%861e-422.7%17.9%543e-418.4%16.8%388e-48.9%19.3%21上面这种表格形态很有代表性成本上升导致交易次数下降年化收益随之回落最大回撤可能反而改善。如果交易次数在成本上升时不下降说明奖励函数里的成本惩罚没有生效需要回Environment.py检查是否在动作切换时才扣成本而不是在每一步都扣。4.3 回测指标不要只看累计收益曲线回测报告至少包含年化收益率、最大回撤、夏普比率、Calmar比率和交易次数五个指标。最大回撤衡量策略最坏情况下的亏损深度夏普比率衡量单位波动获得的超额收益Calmar比率是年化收益除以最大回撤反映收益质量。指标计算公式判定参考年化收益(1总收益)^(252/N)−1超过沪深300同期基准才有意义最大回撤max(峰 - 谷) / 峰日频策略控制在15%-25%以内夏普比率(年化收益 - 无风险利率) / 年化波动大于1才值得继续调Calmar比率年化收益 / 最大回撤大于1说明风险调整后收益较优交易次数动作切换次数年化20-50次较合理训练集reward和验证集reward差异过大的时候比如训练集年化40%验证集年化-5%优先怀疑特征里混入了未来信息而不是模型欠拟合。去Environment.py里重新检查_build_state的切片边界以及预处理阶段是否用了rolling窗口中心对齐。5. 把DQN输出接到实盘信号前的三个细节模型在验证集上达标后距离能用的交易信号还差最后几步。直接拿Q网络预测结果做信号会产生大量毛刺这里给出三个我常用的工程化处理成本低但效果立竿见影。5.1 Q值差置信度过滤DQN输出的Q值不是概率但Q(持有)与Q(空仓)的差值方向可以当作信号强度。差值接近0时模型并不确定此时强行开仓只会增加无效交易。给差值设置一个最小阈值只有确信度足够才允许改变当前仓位状态。q_hold online_net(state)[0][1] q_empty online_net(state)[0][0] conf q_hold - q_empty if conf 0.05: signal 1 elif conf -0.05: signal 0 else: signal None # 保持当前仓位不变不产生新信号阈值0.05根据Q值的实际分布调整如果模型训练完成后Q值普遍偏小降到0.02。阈值过大会导致信号稀疏错过趋势过小又回到频繁换手的老问题。5.2 滞回开关让仓位状态有个迟滞带实盘里最怕的是持仓和空仓状态每天跳来跳去。一个简单有效的办法是引入滞回逻辑空仓状态时只有买入信号强度超过较高阈值才开仓持仓状态时只有卖出信号强度跌破较低阈值才平仓。中间地带保持原状和施密特触发器一个思路。我一般把买入阈值设为Q值差的0.05分位卖出阈值设为-0.05分位两个阈值不等宽天然形成迟滞区间交易次数会下降30%以上。5.3 交易日历对齐与次日撮合假设最后检查数据对齐决策时间戳必须严格早于执行时间戳。Environment.py里已经体现了“当日收盘决策、次日收益结算”但替换数据源时要重新确认特别是停牌日。如果DataFrame里缺了停牌行直接用shift(1)会把相隔三天的价格当成相邻交易日。稳妥做法是先用完整交易日历做outer join缺失价格用前向填充再计算所有特征。另外实盘执行要假设次日开盘价成交回测里只有收盘价时也要在收益结算日中做相应说明否则微秒级别的数据错位就可能导致回测虚高。这些细节全部处理完模型在验证集上的曲线才有参考价值盘中触发的动作变化也会明显减少回测到实盘的迁移成本会低很多。本文还有配套的精品资源点击获取
返回列表