拓冰建站拓冰建站
首页 / 资讯中心 / 正文

强化学习股市择时策略实验:从DQN到回测验证全解析

简介面向量化投资与强化学习入门者的实验项目围绕股市择时策略展开演示如何用智能体在模拟股票市场环境中学习买入、持有、卖出决策。项目完整覆盖数据预处理、环境建模、深度Q网络策略定义、训练测试与评估流程并配套可视化模块和变分自编码器等辅助工具适合想将强化学习应用到金融时序预测的开发者参考。压缩包共82个文件、约3.53MB以14个Python脚本为主体涵盖数据预处理、模型构建、训练、评估、预测等环节另有依赖环境说明、文本配置、数据库文件及模型权重整体保留了完整工程目录结构便于直接运行和二次开发。已有210人学习下载。通过该资源可掌握市场状态表示、奖励计算、深度Q网络调参思路同时了解夏普比率、最大回撤等策略评价方式为后续尝试演员-评论家等进阶算法打下基础。1. 拿“强化学习股市择时策略实验项目”当坐标系先看清这个实验要回答什么问题如果你在人工智能大作业或者量化研究的资料堆里见过“强化学习股市择时策略实验项目”这个压缩包标题大概率会先问这里面的强化学习到底是真能用还是换个名词把传统均线策略包装一遍股市择时是个很直接的决策问题——给定当前市场状态你决定持有现金还是持有股票甚至决定仓位压在几成。把这个问题转写成强化学习就不再有“一把梭”的静态规则而是让智能体在历史行情的一段段截面上反复试错自己学出一套“什么时候该出手”的条件判断。这个实验项目能落地的关键不取决于是不是用了最强算法而取决于把股市数据改写成强化学习接口时状态、动作、奖励三件套定义得是否忠实。拿着这份思路去跑一个最小实验你会比背概念更快理解 replay buffer、epsilon 贪心这些强化学习入门术语在金融数据上到底在做什么。这篇博文就顺着“环境怎么搭 → DQN 怎么跑通 → 回测怎么验证 → 项目怎么归档”的顺序把这条路捋直。2. 把行情数据翻译成强化学习语言MDP 设计与数据准备强化学习处理时间序列问题第一步永远是把领域问题塞进 Markov Decision Process 的框架里智能体在每个时间步观察一个状态从动作集合里选一个动作环境返回下一状态和一个奖励。股市择时最自然的抽象是在每个交易日收盘前决定“持有”还是“空仓”执行后得到隔夜或未来一段区间的收益作为奖励。越早接受“这个问题本质上不是预测涨跌而是决策仓位”这个转变后面代码就不会写歪。2.1 状态空间用 OHLCV 和衍生特征构造截面观察原始 k 线数据是一个五列矩阵open、high、low、close、volume。直接把收盘价裸着喂给神经网络几乎必死因为价格水平在不同股票间差两个数量级网络只能记住绝对价位而不是形态。常见做法是先算收益率再做标准化再拼入技术指标。我会把状态设计成三部分拼接近 N 日对数收益率log(close_t / close_{t-1})这是平稳性最好的原始信号近 N 日均线偏离度(close_t - MA_N_t) / MA_N_t表征价格在短期趋势中的位置近 N 日成交量相对均值倍数volume_t / mean(volume_{t-N:t})。外层叠加一层全连接即可。import numpy as np import pandas as pd def build_observation(df, lookback20): # 对数收益率 df[ret] np.log(df[close] / df[close].shift(1)) # 均线偏离度均线窗口取 5/10/20 三条 for w in [5, 10, 20]: df[fmom_{w}] (df[close] - df[close].rolling(w).mean()) / df[close].rolling(w).mean() # 量比当日成交量 / 过去ma_w日平均量 df[vol_ratio] df[volume] / df[volume].rolling(10).mean() features [ret, mom_5, mom_10, mom_20, vol_ratio] data df[features].fillna(0.0).values obs [] for i in range(lookback, len(data)): # 窗口内特征做 z-score 标准化后再塞进状态 window data[i - lookback:i] mu window.mean(axis0) std window.std(axis0) 1e-8 obs.append(window[-1] / std) # 只用窗口内最后一行的标准化特征 return np.array(obs), data注意以上代码里我把状态简化成了窗口最后一行的标准化特征这是为了让你先跑通接口如果你希望智能体看到整段窗口把整块window拉平作为状态会更接近原始 DQN 的做法维度会从 5 变成5 * lookback。训练时要是状态一维接全连接推荐用后一种。标准化必须按窗口内统计量做不能用全量数据的均值和方差否则训练时偷偷看到了未来统计量属于隐形未来函数。2.2 动作空间的设计直接决定策略的行为边界择时策略常见的动作空间有两类。第一类是离散动作{0, 1}0 表示空仓、1 表示满仓这是最简做法也是很多强化学习股票的入门项目首选。第二类是把仓位分成 0%、25%、50%、75%、100% 五档甚至用连续动作输出一个 0 到 1 的仓位系数。离散档位在探索阶段更容易稳定连续仓位在边际改善上更精细但训练难度也更高。动作编号含义动作后的仓位适用阶段0清仓0%初始调试1半仓50%已有收益曲线想做平滑时2满仓100%看多趋势明确风险偏好高用三档动作跑 DQN 的时候神经网络输出层就是三个神经元对应三个动作的 Q 值。这里有个外界容易忽略的点动作空间不要对称设计成“买入、卖出、持有”的调仓指令而要直接设计成“目标仓位”。因为“卖出”在不持仓时无法执行环境会频繁给无效动作拖慢训练。2.3 奖励函数收益率、交易成本与风险惩罚的平衡强化学习在股市择时里学不动一半以上是奖励函数写坏了。最直观的奖励是策略调仓后到下一次调仓之间的收益reward_t position_t * (next_close / close_t - 1)其中position_t是动作给出的目标仓位比如 1.0 代表满仓、0.0 代表空仓。这个公式假定你在close_t收盘时按动作调仓持有到t1时刻收盘再结算。不加任何惩罚时智能体很容易发现“满仓一把梭”在牛市回测里收益最高于是动作永远输出满仓完全退化成 buy-and-hold择时能力为零。为了让策略学会规避回撤我会在奖励里加三项。第一是交易成本惩罚换手率越高扣除越多用-cost * |position_t - position_{t-1}|cost 设为双边手续费加滑点约 0.001。第二是风险惩罚可选地减去收益方差的指数滑动估计但窗口太短容易让智能体变得过度保守。第三是“踏空惩罚”加减仓动作本来就受奖励驱动一般不需要额外加。fee_rate 0.001 prev_pos 0.0 rewards [] for t in range(len(close_prices) - 1): reward position[t] * (close_prices[t 1] / close_prices[t] - 1) trade_cost fee_rate * abs(position[t] - prev_pos) rewards.append(reward - trade_cost) prev_pos position[t]上面这段要放在训练环境里从缓冲区取position和close_prices逐行结算。每个 step 只计算单步收益和交易惩罚不加风险项时环境只有两份输入对新手更友好把trade_cost加大到 0.003 后策略会明显变得更不爱频繁进出。2.4 数据切分与剔除未来函数训练集、验证集、测试集必须按时间顺序切这一点我想单独拉出来说。很多强化学习入门项目为了偷懒用train_test_split随机打乱数据切分这在监督学习里没问题但在时序问题里是严重的未来函数因为训练窗口里含有测试日期的前后信息回测结果会被系统性高估。我一般的做法是把整段行情按 6:2:2 时间比例切分训练段、验证段、测试段之间留 30 天隔离带避免计算滚动指标时跨段借数据。验证段用来挑超参数和提前停止测试段只跑一次跑完就归档。隔离带这段数据不参与训练也不参与评测因为 20 日均线在分段边界会用到前段数据直接掐断会让每一段开头 N 天的特征退化成 0。叮嘱一句切分后的标准化参数包括 2.1 节窗口里的 mean 和 std都只从训练段统计验证段和测试段直接套用。3. 用 DQN 在本地跑通股市择时的最简训练循环强化学习框架成熟之后训练一个 DQN 智能体不再是几百行工程量。这里用 PyTorch 手动实现一个轻量 DQN避开高层框架带来的封装黑盒。目标网络、经验回放、epsilon 贪心是 DQN 启动的三大件缺一个都会让训练曲线难以收敛。3.1 一个能复现的 DQN 训练脚本下面这个脚本省略了数据读取细节直接假设你已经用第 2 章的build_observation拿到了obs数组、rewards对应的环境过渡表。训练循环的核心是从环境采样一条轨迹存进 replay buffer再从中随机采样小批量更新 Q 网络每隔若干步把 Q 网络参数软拷贝到 target 网络。import torch import torch.nn as nn import torch.optim as optim import random import numpy as np from collections import deque class QNet(nn.Module): def __init__(self, state_dim, n_actions): super().__init__() self.net nn.Sequential( nn.Linear(state_dim, 128), nn.ReLU(), nn.Linear(128, 64), nn.ReLU(), nn.Linear(64, n_actions) ) def forward(self, x): return self.net(x) class ReplayBuffer: def __init__(self, capacity10000): self.buf deque(maxlencapacity) def push(self, transition): self.buf.append(transition) def sample(self, batch_size): return random.sample(self.buf, batch_size) # 超参 state_dim obs.shape[1] n_actions 3 q QNet(state_dim, n_actions) target QNet(state_dim, n_actions) target.load_state_dict(q.state_dict()) opt optim.Adam(q.parameters(), lr1e-4) buf ReplayBuffer(20000) batch_size 64 gamma 0.99 epsilon 1.0 eps_min 0.05 eps_decay 0.995 update_steps 0 for episode in range(200): # 每轮随机从训练段抽一个起始点重新开始 start np.random.randint(0, len(obs) - 200) state obs[start] done False t start while not done: # epsilon 贪心 if random.random() epsilon: action random.randint(0, n_actions - 1) else: with torch.no_grad(): state_t torch.FloatTensor(state).unsqueeze(0) action q(state_t).argmax().item() next_state obs[t 1] # 奖励从这里取用收盘价序列提前计算好的单步收益 reward reward_seq[t] # 长度与 obs 一致 done (t 1 len(obs) - 1) buf.push((state, action, reward, next_state, done)) state next_state t 1 epsilon max(eps_min, epsilon * eps_decay) if len(buf.buf) batch_size: transitions buf.sample(batch_size) states torch.FloatTensor([s for s, a, r, ns, d in transitions]) actions torch.LongTensor([a for s, a, r, ns, d in transitions]) rewards torch.FloatTensor([r for s, a, r, ns, d in transitions]) next_states torch.FloatTensor([ns for s, a, r, ns, d in transitions]) dones torch.FloatTensor([d for s, a, r, ns, d in transitions]) q_vals q(states).gather(1, actions.unsqueeze(1)).squeeze(1) with torch.no_grad(): next_vals target(next_states).max(1)[0] target_vals rewards gamma * next_vals * (1 - dones) loss nn.MSELoss()(q_vals, target_vals) opt.zero_grad() loss.backward() opt.step() update_steps 1 if update_steps % 200 0: target.load_state_dict(q.state_dict())逻辑展开reward_seq是预先算好的单步收益序列obs[t]与obs[t1]之间的 reward 存放在单独数组里。每 200 次更新同步一次 target 网络能避免 Q 值在训练中震荡发散。这个逻辑每行都能直接看明白调参只需要动超参区那几行常量和 reward 构造部分。3.2 DQN 训练的关键参数表与调优顺序把参数调对比换模型重要得多。下面是我在实验项目里常用的初始参数表和调整顺序。参数初始值调节方向说明lr1e-4loss 不降时降低到 3e-5过高会震荡gamma0.99调小到 0.95 让模型更关注短期收益epsilon1.0 → 0.05衰减太快要减少探索容易早熟batch_size64数据量少可降到 32但梯度噪声更大target_update200 步步数太短目标网络更新太快太长则训练慢调优顺序建议固定成一条流水线先确保 reward 曲线上升再用验证段观察最大回撤最后才动epsilon_decay和gamma。很多人一上来就调网络层数结果奖励曲线根本不涨说明问题出在状态或奖励设计上而不是网络容量。3.3 训练不收敛的快速排查清单训练曲线要么长期不涨要么突然崩溃先别怀疑算法检查三处。第一处是reward_seq的值域是否过大我见过有人把单日收益直接当奖励结果都是千分之一量级Q 值初始在 0 附近完全学不到东西——把 reward 乘 100 再做训练曲线立刻正常。第二处是状态特征是否出现全 0 列fillna(0)会把没有交易的前几天变成零向量如果数据头部很大一块是 0智能体会学到“状态为零就空仓”。第三处是 track 训练过程时别只看最终回测收益要看训练期每个 episode 的平均 Q 值和平均 reward如果平均 reward 在上升但回测收益下降十有八九是交易成本惩罚太小模型在研究怎么高频刷收益。4. 回测与评估怎么证明强化学习择时策略不是过拟合噪声人工智能够不够格跑出实验结论最终要看回测。但回测代码比训练代码更容易写错因为环境接口需要忠实模拟真实交易行为。常见的坑包括回测时没有考虑涨跌停导致买不进卖不出、委托价格用了后续交易日信息、结算时忽略了交易成本这些每一项都足以让回测曲线看起来漂亮得可疑。4.1 回测器要模拟持仓、买卖点与交易成本不管训练时用的是 DQN 还是 PPO回测器可以共用同一套骨架。核心逻辑是按模型输出的仓位信号在每日收盘价调仓逐日结算净值。def backtest(close_prices, signals, fee0.001): # signals: 0/1/2 转成仓位 0/0.5/1.0 positions np.array(signals) * 0.5 # 简化映射 equity [1.0] pos 0.0 for i in range(len(close_prices) - 1): ret close_prices[i 1] / close_prices[i] - 1 # 先结算当日持仓收益再扣调仓费 daily_pnl pos * ret new_pos positions[i 1] cost fee * abs(new_pos - pos) equity.append(equity[-1] * (1 daily_pnl - cost)) pos new_pos return np.array(equity)这个回测器的假设是每日收盘调仓且成本按调仓比例收取。对比强化学习训练环境时你会发现回测器和训练环境的奖励计算应该使用同一套 cost 系数否则策略在训练时认为调仓不花钱回测时突然扣钱绩效会打折。我建议把训练环境和回测器抽象成同一个execute_action函数这样从训练到验证不存在语义漂移。4.2 指标表只盯总收益会让你做出一份好看但没用的实验策略实验项目里你最后要交给老师或评审的通常是下面这样一张对比表。指标强化学习策略买入持有沪深300 基准年化收益率18.2%12.4%10.1%最大回撤14.5%22.8%25.3%夏普比率1.210.760.52换手率年化148%0%—年化收益率和最大回撤是必须看的夏普比率衡量风险调整后收益换手率则用来验证策略是否在靠频繁交易刷收益。如果换手率显著高于 300%即使收益高实盘滑点也会把利润吃光这份策略大概率只在回测环境里成立。4.3 三个常见的隐性错误状态泄漏、样本偏差与 reward 漂移第一状态泄漏。训练时把当日涨跌幅也放进了状态但当日涨跌幅在收盘前并不知道这就是状态泄漏。解决方式是只保留t-1及更早的信息用pandas.shift(1)把所有特征滞后一天。第二样本偏差。只用一段牛市行情训练和回测策略永远学不到熊市应该空仓。数据段至少要覆盖一轮完整的牛熊周期或者用不同年份的多个数据集分别实验最后取跨时段的表现对比。第三reward 漂移。训练过程中奖励尺度变化过大比如 2008 年和 2015 年波动率差三倍前一段学好的策略在后一段会突然失效。常见做法是使用波动率归一化的收益作为奖励ret / realized_vol它会强迫智能体在风险大的时候更谨慎。4.4 进阶扩展从在线训练走向离线强化学习既然实验项目的目的是对比验证肯定有人会质疑训练时智能体是边试探边学习的这在金融里并不现实——你不可能拿真金白银做探索。离线强化学习offline RL是这个方向真正的落地路径典型算法像 IQL、CQL它们要求只用固定数据集训练不与环境交互天然契合股市历史回放场景。如果这个实验跑通后你想继续深挖把第 3 节的 DQN 替换成 CQL 并复用同一个回测器是最顺滑的扩展路线换模型后主要调的是expectile或cql_weight这类离线算法独有参数回测代码一行不用改。5. 让这个强化学习股市择时策略实验项目真正可复现的 4 个收尾技巧实验项目做到能跑只是开始能被别人复现才有价值。以下四件事如果不做三个月后回看代码很可能自己都解释不了当初为什么调成这个参数。5.1 固定随机种子多种子跑统计PyTorch 和 Python 的随机性都需要固定只设一个np.random.seed没用。训练前在脚本入口加random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.backends.cudnn.deterministic True强化学习训练方差很大单次运行的结果不能说明算法好坏。我习惯准备一个seeds [1, 2, 3, 5, 7]列表对每个种子跑完整训练加回测最后汇报指标的均值和标准差这样表格里写“夏普 1.21 ± 0.15”比写“夏普 1.21”有说服力得多。5.2 把离散动作映射成连续仓位提高结果可用性三个离散档位很容易让策略在 0% 和 100% 之间反复横跳。一个轻量改进是训练结束后不再取 argmax而是用 softmax 输出概率进行加权得到 0 到 1 的连续仓位信号prob torch.softmax(q(state_t), dim1).squeeze(0) # 仓位 动作概率与每档仓位的加权和 weights torch.tensor([0.0, 0.5, 1.0]) position (prob * weights).sum().item()这不算额外训练成本只是在策略部署时换一种动作解析方式能显著降低换手率。你的实验报告里可以加一行对比离散动作 vs 连续映射后的回测结果很多评审会喜欢这个细节。5.3 用命令行参数管理实验配置每次修改超参数都去改代码里的常量是一场灾难。把 lr、gamma、epsilon、seed 全部抽到命令行参数python train_dqn.py --lr 1e-4 --gamma 0.99 --seed 42 --fee 0.001配合 5.1 节的种子列表外层用 Shell 循环一键跑完多组实验并输出结果文件。这样不仅能复现别人的结果还能把每次实验的超参哈希值存进文件名避免结果文件被覆盖后想不起来哪份配置对应哪个曲线。5.4 把数据、代码和回测结果归档成一个干净的项目压缩包这类以 zip 形式流传的实验项目最容易出问题的是数据文件路径写死。代码里如果用了C:\\Users\\xxx\\Desktop\\data.csv别人拿到的包根本跑不了。正确做法是项目内用相对路径读取把训练数据放在data/目录、回测结果输出到results/目录最后用zip -r stock_rl_project.zip data/ src/ results/ config.yaml打包。加上一条明文 README写清楚 Python 依赖版本和启动命令这个 zip 才配叫“强化学习股市择时策略实验项目”。以后自己离线解压这个包第一件事就是检查config.yaml里的 seed、fee、reward_scale 三项是否和论文表格一致。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门