用TensorFlow和CNN预测股票走势:从数据准备到回测的完整指南
简介一份以TensorFlow实现CNN预测股票走势的Python实战资料面向金融量化初学者与机器学习爱好者。内容系统讲解从股票数据预处理、滑动窗口构造样本到CNN卷积/池化/全连接层搭建、模型训练与评估的完整流程并额外包含DQN强化学习、KD指标等扩展实验帮助读者理解深度模型在金融时序预测中的应用边界。压缩包共34个文件约5.16MB以Python脚本、Jupyter Notebook、PDF技术文档和结果图表为主配有模型权重与检查点方便直接运行和比对输出。已有1440人学习可用于快速复现CNN股票预测项目也可作为课程设计或量化研究的参考资料。通过本项目可掌握TensorFlow Sequential API使用、数据标准化与二维结构转换、损失与优化器配置等关键技能并获取多套可复用代码与可视化分析结果便于进一步改进预测策略或迁移到其他金融时间序列任务。1. 用 TensorFlow 和 CNN 预测股票走势这件事到底值不值得做很多人第一次听说CNN 预测股票时第一反应是质疑卷积神经网络不是用来做图像识别的吗拿来预测 K 线是不是玄学我的答案是它不预测明天是涨还是跌这个答案它做的是把最近一段时间的走势形态和历史上涨/下跌前的形态做匹配。股票价格序列本身是一维信号而 CNN 卷积神经网络最擅长的就是从局部信号里提取模式——这正好对上了。这篇文章面向已经装好 Python 环境、想正经跑一个深度学习量化项目的从业者目标是让你从数据准备、模型搭建、回测到避坑完整走通一个可复现的 TensorFlow CNN 股票预测方案。先说明白这方案做的是策略信号辅助不是全自动实盘印钞机。2. 从 K 线到张量滑窗序列化与标签设计2.1 数据粒度怎么选日线是最稳的起点做 CNN 股票预测第一步不是建模而是决定用哪种 K 线粒度。常见做法是从日线入手因为日线噪声相对小单日涨跌幅分布比较接近正态模型能学到的形态更稳定。分钟线比如 5 分钟、30 分钟对日内交易更敏感但序列长度和样本量会爆炸且手续费对短周期策略影响巨大。tick 级数据不推荐入门者碰——行情源、拼接、去重都是额外工程。我习惯用 yfinance 或本地导出的 CSV 作为数据源字段至少包含 open/high/low/close/volume 五列另外建议把 date 转成 datetime 索引并升序排列。拿到数据后先不要急着算特征先看一眼有没有停牌导致的缺失行和重复日期这俩坑在后续模型里会变成莫名其妙的 NaN loss。2.2 特征设计不要喂裸价格要喂变化CNN 对输入的尺度极其敏感。如果你直接把收盘价比如 3000 元和成交量比如 50000 手塞进网络卷积核算出来的梯度会完全被价格数值主导训练几乎必然发散。正确做法是把价格序列转成无量纲变化率close 的涨跌幅close.pct_change()成交量变化率volume.pct_change()最高最低价摆动(high - low) / close收盘价相对开盘价的位置(close - open) / open下面这段代码把原始 OHLCV 转成 CNN 可用的特征矩阵import pandas as pd import numpy as np def make_features(df: pd.DataFrame) - pd.DataFrame: 输入: 包含 open/high/low/close/volume 的 DataFrame, 按日期升序 输出: 6 列特征, 全部是变化率/摆动, 不是绝对价格 out pd.DataFrame(indexdf.index) out[ret] df[close].pct_change() # 收盘涨跌幅 out[vol_chg] df[volume].pct_change() # 成交量变化率 out[range] (df[high] - df[low]) / df[close] # 日内振幅 out[body] (df[close] - df[open]) / df[open] # 实体占比 out[high_pos] (df[high] - df[close]) / df[close] # 上影线 out[low_pos] (df[close] - df[low]) / df[close] # 下影线 return out.replace([np.inf, -np.inf], np.nan).fillna(0.0)这段代码的逻辑说明所有列都是比率或变化率量纲统一在 -1 到 1 附近极端行情会超出但不至于让梯度爆炸fillna(0.0)处理的是停牌后复牌的第一根K线pct_change 会产生 NaN这里直接补 0 表示无变化。参数说明fillna(0)是一个值得商榷的决定——如果你有大量连续停牌数据补 0 会引入假平稳信号更稳妥的做法是直接丢弃停牌前后那根 K 线我一般会在dropna()和fillna(0)之间看数据量再决定。2.3 滑窗与标签预测未来 N 日方向而不是直接预测价格CNN 的输入是最近 L 天的特征输出是未来 T 天的方向。L 通常取 20 到 60 个交易日对应一个月到一个季度的形态记忆。T 取 1 到 5 日T 太小噪声大T 太大信号太滞后。我常用 T3也就是未来三天收益为正则标为 1否则为 0。标签设计有一个反直觉的细节不要用未来一天收盘价 今天收盘价作为正例因为隔夜跳空和 T1 交易制度会带来严重的前视偏差。更稳的标签是未来 T 日收益率 滑窗滚动收益率均值这样过滤掉单日噪声。def make_windows(features: np.ndarray, labels: np.ndarray, window: int 30, horizon: int 3): 将特征矩阵和标签切成 (样本数, window, 特征数) 的张量 window: 回看天数, horizon: 未来预测天数 X, y [], [] for i in range(len(features) - window - horizon): X.append(features[i : i window]) # 未来 horizon 天收益率 future_ret labels[i window : i window horizon].sum() y.append(1.0 if future_ret 0 else 0.0) return np.array(X, dtypenp.float32), np.array(y, dtypenp.float32)参数说明window30表示模型只看最近 30 天horizon3表示判定未来 3 天累计收益的符号。这里 labels 传入的是每单日收益序列比如df[ret].valuessum()把未来 3 日收益累加大于 0 就标记为正类。这个做法的好处是标签和特征的生成不依赖未来数据——切样本时i只用到iwindow-1及之前的信息。坏处是样本量会少掉window horizon条对 2000 个交易日的数据来说实际得到约 1900 个样本这对 CNN 来说偏少后面我会讲怎么用数据增强和心理预期调整。2.4 归一化放在滑窗之后防止验证集信息泄漏这是整个股票预测里最容易翻车的一步。很多人习惯先对全量特征做StandardScaler再切训练/验证集——这个动作会让验证集和测试集的均值、标准差混入训练过程等于把未来信息偷看了一遍。正确的时序做法是先切分再对训练段拟合并 transform 训练段最后用同一套参数 transform 验证/测试段。from sklearn.preprocessing import StandardScaler # 先按时间顺序切分 split_idx int(len(feature_df) * 0.8) train_df feature_df.iloc[:split_idx] valid_df feature_df.iloc[split_idx:] scaler StandardScaler() train_scaled scaler.fit_transform(train_df) # 拟合只能用训练段 valid_scaled scaler.transform(valid_df) # 验证段只 transform # 再喂给 make_windows X_train, y_train make_windows(train_scaled, train_df[ret].values) X_valid, y_valid make_windows(valid_scaled, valid_df[ret].values)逻辑说明scaler.fit_transform只接触训练集验证集用的是训练集的均值和方差。这个顺序一旦反过来模型在验证集上的表现会虚高 10%~20%——这是股票预测里最典型的前向泄漏look-ahead bias。这里还有一个很少人注意的细节make_windows内部对train_scaled和train_df[ret]的索引必须一致如果你在两个 DataFrame 上分别做过dropna()行数对不上会导致标签错位肉眼很难发现但模型 AUC 会永远在 0.5 附近。3. 搭建 1D-CNN从输入张量到预测输出的完整代码3.1 为什么是 1D-CNN 而不是 LSTM 或 2D-CNN股票序列是典型的一维时间序列所以第一选择是 Conv1D。Conv1D 的卷积核沿时间方向滑动每次覆盖kernel_size天提取的是连续几天的组合形态比如三连阳后的缩量十字星。相比 LSTMConv1D 的参数量小一个量级训练速度快而且在短序列30~60 天窗口上CNN 的局部特征提取能力不比 LSTM 差。LSTM 的优势在极长依赖但股票数据信噪比极低长依赖大概率学到的不是规律而是噪声。2D-CNN 是另一种思路把 K 线图渲染成图片喂给图像分类模型。这个方案视觉上很性感但落地时有两个硬伤一是 K 线图的渲染参数颜色、宽度、缩放都会变成影响结果的隐藏超参数二是图像化的过程丢失了精确数值信息模型无法区分涨了 0.1%和涨了 5%。所以我的建议是宁愿在 1D-CNN 上下功夫也别急着上 2D-CNN 去做 K 线图像识别。3.2 一个最小可跑的 Conv1D 模型使用 TensorFlow2.x 版本均可运行搭建模型结构是Conv1D MaxPooling1D 交替两次然后接全局平均池化最后是带 sigmoid 的单输出二分类头。import tensorflow as tf def build_cnn(input_shape: tuple, kernel_size: int 5) - tf.keras.Model: input_shape: (window, n_features), 例如 (30, 6) kernel_size: 卷积核覆盖的天数, 5 表示看连续 5 天的组合形态 model tf.keras.Sequential([ tf.keras.layers.Input(shapeinput_shape), tf.keras.layers.Conv1D(filters32, kernel_sizekernel_size, activationrelu, paddingsame), tf.keras.layers.MaxPooling1D(pool_size2), tf.keras.layers.Conv1D(filters64, kernel_sizekernel_size, activationrelu, paddingsame), tf.keras.layers.GlobalAveragePooling1D(), tf.keras.layers.Dense(units16, activationrelu), tf.keras.layers.Dropout(0.3), tf.keras.layers.Dense(units1, activationsigmoid) ]) return model model build_cnn(input_shape(30, 6)) model.compile(optimizertf.keras.optimizers.Adam(learning_rate1e-3), lossbinary_crossentropy, metrics[AUC]) model.summary()逻辑说明第一层 Conv1D 的 filters32意味着用 32 个不同的卷积核去扫描 30 天序列每个卷积核学到一种形态响应MaxPooling1D 降维一半帮助模型关注更粗粒度的趋势第二层升到 64 个卷积核意图是让高层特征更抽象。GlobalAveragePooling1D 把最后一层特征图压缩成一个 64 维向量相比 Flatten 参数量更小泛化性更好。Dropout 0.3 是防止全连接层过拟合的常规设定。参数说明kernel_size5对应一周五个交易日的形态窗口这是一个经验起点。paddingsame保证卷积不缩短序列长度避免窗口边缘信息被丢弃。学习率1e-3是 Adam 的默认值但股票数据噪声大我建议后面配ReduceLROnPlateau在 loss 平台期自动降学习率。3.3 三个必调参数kernel_size、dilation_rate、batch_size第一个必调参数是kernel_size。它决定模型一次能看多长时间的局部形态。取 3 太短等于只看三天涨跌取 15 太长接近整个窗口的一半模型容易过拟合。我一般从 5 起步然后试 7 和 9用验证集 AUC 决定。第二个参数是dilation_rate。在 Conv1D 里设置 dilation_rate2 可以在不增大 kernel_size 的情况下让卷积核覆盖更长时间范围比如 kernel_size5、dilation_rate2 实际覆盖 9 天。对股票这种有周期性的序列dilated convolution 常比单纯加 kernel_size 效果好因为感受野变大但参数不增加。第三个参数是batch_size。很多人会忽略但它直接影响训练稳定性和内存占用batch_size32: 默认值, 对 2000 样本来説梯度最稳, 推荐 batch_size64: 训练更快, 但容易收敛到尖锐极小值, AUC 波动大 batch_size8: 梯度噪声大, 配合低学习率有时能跳出局部最优, 但训练极慢我固定用 32 起步如果验证集 AUC 波动剧烈同一份数据两次训练的差异超过 0.03再降到 16。3.4 训练回调早停和动态学习率是后悔药股票数据训练要配回调函数理由很现实你不知道模型在第几个 epoch 开始过拟合。EarlyStopping 监视验证集 loss连续 10 个 epoch 不下降就回滚到最佳权重ReduceLROnPlateau 在 loss 平台期把学习率降到原来的 1/5能在训练后期帮模型越过尖锐极小值。callbacks [ tf.keras.callbacks.EarlyStopping(monitorval_loss, patience10, restore_best_weightsTrue), tf.keras.callbacks.ReduceLROnPlateau(monitorval_loss, factor0.2, patience5, min_lr1e-5) ] history model.fit(X_train, y_train, validation_data(X_valid, y_valid), epochs100, batch_size32, callbackscallbacks)逻辑说明restore_best_weightsTrue必须开否则一旦过拟合开始最后一轮保存的权重是最差状态而不是最好状态。epochs100只是上限实际训练通常在第 20~40 个 epoch 触发早停。min_lr1e-5防止学习率降到零导致训练再也无法恢复。如果你训练时发现第 1 个 epoch 之后 val_loss 就一路反弹那大概率不是模型问题而是前面的归一化泄漏了回到第 2.4 节检查切分顺序。4. 回测与评估别只看准确率先看收益曲线4.1 分类目标比回归目标更稳从预测价格改为预测方向很多入门项目会直接把 CNN 的最后一层改成线性输出MSE 预测明天的收盘价。这个方案在股票数据上几乎必败价格序列近似随机游走MSE 模型学到的最优解就是预测昨天的收盘价——因为今天和昨天差个噪声平均来看预测昨天能最小化平方误差。这就是经典的 constant prediction 陷阱模型输出的曲线看起来和真实价格走势重叠但你永远不可能据此交易。正确的做法是把问题定义成二分类未来 3 日累计收益为正或为负。损失函数用binary_crossentropy输出层用sigmoid。这样模型学习的是形态→方向的条件概率而不是在拟合一条随机游走的曲线。4.2 评估指标AUC 是主指标但还要看预测分布的稳定性二分类股票模型的评估不能用准确率。因为涨跌样本通常接近 55:45略偏上涨一个全猜涨的傻瓜模型准确率也有 55%。AUC 能反映模型是否真的能把正类和负类分开0.5 以下说明模型学到的是反向规律0.55~0.6 之间才是一个值得继续打磨的信号。除了 AUC我还会看两个细节第一正类预测概率的分布是否过于集中在 0.5 附近——如果 90% 的样本输出都在 0.48~0.52说明模型对任何样本都没有把握这个模型没用第二按年份分组的 AUC 是否稳定如果 2020 年 AUC 0.632021 年 0.51说明模型学到的是特定行情下的形态而不是普适模式。4.3 一个能跑的回测骨架把预测概率转成多空信号回测的意义在于验证预测结果能不能转化为收益。最简单也最常见的做法是当 P(涨)0.6 时全仓做多P(涨)0.4 时空仓或做空取决于你的交易品种和做空成本中间区间不交易。这里我给出一个简化但逻辑完整的回测代码只计算每日持仓收益不含税费和滑点用来做模型相对比较足够了。def backtest(close_price: np.ndarray, pred_prob: np.ndarray, long_thresh: float 0.6, short_thresh: float 0.4, fee_rate: float 0.001): 简化回测: 按预测概率决定次日持仓, T1 买卖 pred_prob[i] 是根据第 i 天之前的数据做出的预测, 用于第 i1 天持仓 # 持仓信号: 1 多头, 0 空仓, -1 空头 (如果有做空条件) position np.zeros(len(pred_prob)) position[pred_prob long_thresh] 1.0 position[pred_prob short_thresh] -1.0 # 日收益: position 在 i1 天才生效, 所以位移一位 daily_ret np.zeros(len(close_price) - 1) for i in range(len(daily_ret)): if position[i] ! 0: # 当日收益, 减去换仓手续费 daily_ret[i] position[i] * (close_price[i1] / close_price[i] - 1) if position[i] ! position[i-1]: # 换仓才扣手续费 daily_ret[i] - fee_rate nav np.cumprod(1 daily_ret) # 净值曲线 return nav, position逻辑说明信号在第 i 天收盘后生成作用于第 i1 天的实际价格变化这是避免未来函数的基本要求。fee_rate0.001代表双边手续费加滑点约 10 个基点对日频策略是可接受的假设。position[i] ! position[i-1]的条件确保只有换仓当天扣费长期持有不重复扣费。参数说明阈值 0.6/0.4 不是固定值。如果你的模型 AUC 只有 0.55概率输出普遍在 0.45~0.55 之间这两个阈值会让回测大多数时间空仓——这本身就是一个信号模型不够强策略不可用。我通常会把long_thresh从 0.5 到 0.7 扫一遍看回测夏普比率在哪个阈值附近最稳定。4.4 过拟合的三个信号训练中途分叉、回测夏普虚高、样本外衰减第一个信号最简单训练 loss 持续下降验证 loss 在第 15 个 epoch 开始反弹这是教科书式的过拟合EarlyStopping 能兜住。第二个信号比较隐蔽回测夏普比率超过 3。对日频策略夏普通常在 1~2 之间就算优秀超过 3 基本可以断定回测逻辑有漏洞——最常见的是用了当日收盘价作为信号又假设当天可以按收盘价成交T0 幽灵交易。第三个信号考验耐心把测试集再按时间切成前后两半分别算 AUC。如果前一半 AUC 0.62、后一半 0.51说明模型学到的是前半段的特殊行情不是稳定规律。5. 避坑清单TensorFlow 股票预测里最容易翻车的 6 个细节5.1 预测很准但实盘亏钱查查未来函数现象回测净值曲线完美上涨一旦用真实盘数据跑收益和回测差距巨大。原因回测代码里存在未来函数。最常见的隐蔽写法是用当天的收盘价计算技术指标同时假设当天就能以收盘价成交。在 A 股 T1 制度下当天收盘价买入实际上是做不到的。解决把所有信号计算和交易成交分成两个时间节点。保守做法是用第 i 天收盘后的数据生成信号第 i1 天开盘价成交第 i2 天开盘价卖出。这样虽然会损失一部分收益但策略的真实可执行性大幅提升。5.2 模型永远输出上涨类别不平衡被忽略了现象训练完成后预测概率几乎全在 0.5 以上负样本的正确率极低。原因股票上涨的天数多于下跌天数正负样本比例大约 55:45。CNN 在训练中发现全预测正类能把 loss 降到 0.55 附近于是选择了这个偷懒解。解决在model.fit里设置class_weight{0: 0.55/0.45, 1: 1.0}或者干脆在make_windows里做下采样让正负样本 50:50。我更喜欢 class_weight因为它不减少数据量而且只影响 loss 的加权不改分布。5.3 归一化泄漏验证集结果虚高 10%现象训练时验证集 AUC 高达 0.65但换到新数据上测试 AUC 只有 0.52。原因StandardScaler.fit_transform在切分之前就对全量数据做了拟合验证集的均值、方差混入训练信息。这属于轻微的前向泄漏不会立即暴露但会让模型依赖验证集分布这种伪特征。解决严格执行第 2.4 节的顺序——先切分再 fit 训练段transform 验证段。检查方法把验证集单独拿出来跑一遍缩放对比之前的验证特征数值完全一致才算通过。5.4 用随机切分代替时间切分相邻窗口信息重叠现象模型 AUC 0.66看起来很好但回测收益为负。原因使用了train_test_split(shuffleTrue)。随机切分会让第 29 天到第 35 天的窗口同时出现在训练集和验证集验证集里有大量和训练样本重叠的邻居模型靠记忆而非规律得分。解决必须按时间顺序切分且验证集必须在训练集之后。如果担心训练集和验证集边界处有风格突变可以去掉两者交界处各window个样本避免重叠窗口跨边界。5.5 kernel_size 设得太大把噪声当模式现象训练 AUC 很高验证 AUC 从 0.60 跌到 0.53且每次训练结果波动大。原因kernel_size 设成 30等同于让卷积核覆盖整个 30 天窗口。CNN 退化成全序列加权求和失去了局部特征提取的意义模型把所有噪声都当成形态记了下来。解决把kernel_size限制在 3~9 之间配合 dilation_rate 扩展感受野。如果还想看长期趋势可以加一层步长为 2 的 Conv1D 或直接用 LSTM 分支不要指望一个超大卷积核解决所有问题。5.6 手续费设成 0回测夏普 5实盘亏光手续费现象回测年化收益 80%加入手续费后变成 15%加入滑点后变成 -8%。原因日频策略每天换仓一次双边手续费加上滑点大约 0.1%~0.3%。如果你在回测里忽略这些成本相当于凭空获得每天 0.1% 的收益——一年累计是 28% 的虚假收益。解决回测代码里必须包含fee_rate参数。日频策略用 0.001~0.002 是合理区间如果你用分钟线策略这个数字要提高到 0.003 以上否则回测结果毫无参考价值。6. 进阶验证用概率校准和特征归因确认模型学到了什么当你把模型调到一个 AUC 稳定在 0.58~0.62 的状态不要急着上实盘。先做两件事概率校准和特征归因。第一件事是概率校准。CNN 输出的 sigmoid 概率不代表真实涨跌概率——模型可能过度自信输出 0.7 但实际涨的概率只有 0.55。常见做法是温度缩放在验证集上找一个温度参数 T把 logits 除以 T 后再过 sigmoid使得预测概率和实际频率一致。这么做的好处是你设定的long_thresh0.6才能真正对应60% 概率上涨而不是模型自己的幻觉。from scipy.optimize import minimize_scalar def temperature_scale(logits, y_true): # logits: 模型在验证集上的原始输出 (未过 sigmoid) def nll(temp): probs 1 / (1 np.exp(-logits / temp)) eps 1e-7 return -np.mean(y_true * np.log(probs eps) (1 - y_true) * np.log(1 - probs eps)) result minimize_scalar(nll, bounds(0.1, 10.0), methodbounded) return result.x逻辑说明温度缩放不改变模型的排序能力AUC 不变只改变概率的绝对值。校准后你可以把预测概率直接当作该形态上涨的可能性回测阈值的选择就有了真实含义。第二件事是特征归因。用梯度乘以输入算出每个时间点和每个特征对最终预测的贡献tf.function def grad_cam_like(model, x_sample): # 对单个样本, 计算输入对输出的梯度 with tf.GradientTape() as tape: tape.watch(x_sample) probs model(x_sample[None, ...], trainingFalse)[0] grads tape.gradient(probs, x_sample) return grads.numpy() * x_sample.numpy() # 梯度 x 输入 (输入为变化率, 可解释)这段代码会让你看到模型到底押注了过去的哪几天如果归因显著集中在最近 3 天说明模型学的是短期动量如果集中在第 20~25 天说明它找到了一种周期性规律。我做过一次归因分析后发现模型对成交量变化率的依赖远大于价格涨跌这提醒我需要重新审视模式是否健壮。最后的最后我给自己立了一个规矩任何 CNN 股票模型必须先和昨日价格方向这个傻瓜基线比。如果模型 AUC 在 0.56 以上但收益曲线跑不过昨天涨就买昨天跌就空仓的基线那说明模型学到的东西没有增量价值直接删掉重来。这条路走了很多年唯一能确认的是股票预测没有银弹但凡是能持续稳定的模型都是靠大量前向泄漏排雷和严谨的时间序列评估堆出来的。希望这篇笔记能帮你少走几段弯路。本文还有配套的精品资源点击获取