简介:一套基于tushare与TensorFlow 2.0的股票预测实战资源,以贵州茅台历史行情为样例,从tushare接口获取数据,完整展示RNN与LSTM模型对开盘价的预测流程。面向金融数据分析、量化交易入门及深度学习初学者,帮助解决从金融数据获取到模型训练评估的全链路问题。压缩包为zip格式,共5个文件,包含3个Python脚本(分别用于数据下载、RNN构建与LSTM构建)、1个Markdown说明文档和1个CSV历史数据文件,整体仅56KB,轻量便携。已有1912人学习下载。资源提供可直接运行的示例代码,涵盖日期整数编码、开盘价归一化、训练集与验证集划分、Adam优化及均方误差评估等关键步骤,并配有README讲解思路,还给出了模型在历史数据上的预测对比思路,方便读者观察RNN与LSTM的差异,适合作为课程设计、毕业设计或量化策略研究的参考起点。
1. 用tushare和TensorFlow 2.0预测茅台开盘价:先搞清楚这套方案值不值得做
如果你刚接触tushare和TensorFlow 2.0,想拿真实股票数据练手RNN和LSTM,贵州茅台几乎是最常见的标的:行情连续、成交活跃、数据质量好,不需要处理太多停牌和异常。但很多人卡在两步:一是不知道tushare怎么取到干净的日线数据,二是把时间序列预测当成普通回归,模型输入形状都没搞对,最后预测值一条直线,还以为是LSTM不行。这篇文章我会从tushare下载茅台日线,一步步清洗、滑动窗口、归一化,再实现SimpleRNN和LSTM两个模型,最后用滚动预测验证模型真实水平。适合有Python基础、想搞懂循环神经网络落地细节的读者。我会把参数和踩坑都摆出来,你可以直接照着复现。
2. tushare下载贵州茅台日线数据:从注册到DataFrame的完整链路
2.1 为什么选tushare而不是其他免费数据源
做股票预测,数据是第一道坎。tushare的pro接口返回的是规整的DataFrame,字段名稳定,还提供复权因子,这比手动抓网页或者用一些社区维护的数据源省心。我一般优先用它是因为它把交易日历、复权、停牌这些事项都处理好了,你只需要关心模型。当然,这并不意味着tushare没有门槛:pro接口需要注册并设置token,部分接口有积分要求,但日线行情这种基础接口通常够用。如果你只是练手RNN和LSTM,不想在数据清洗上花费太多时间,tushare是合适的起点。另外,它返回的列名是统一的小写英文,比如open、close、high、low、vol,后续直接进pandas,不需要做列名映射。
有人会问,为什么不直接用akshare或者baostock?akshare免费但接口返回格式经常变,baostock没有前复权这么方便。tushare在数据规范性上胜出,代价是token和积分机制。我的建议是:想快速跑通就用tushare,遇到权限报错再换方案。这篇文章的核心在于后面的特征工程和模型,数据源只要稳定可复现就行。
2.2 用pro_bar接口下载茅台日线:参数和复权选项
先确保安装并导入tushare,然后设置token。注意token是你在官网个人页面申请的一串字符,不要把它硬编码在公开脚本里,环境变量或配置文件中读取更安全。下面这段代码会下载2015年到2023年的茅台日线,并使用前复权。
import tushare as ts import pandas as pd ts.set_token('你的token') pro = ts.pro_api() df = ts.pro_bar( ts_code='600519.SH', adj='qfq', start_date='20150101', end_date='20231231' ) print(df.head())这里解释参数:ts_code是股票代码,贵州茅台在上海交易所,代码是600519.SH;adj='qfq'表示前复权,这样2015年的价格不会因为后续分红除权而产生跳变,对训练RNN和LSTM很重要;start_date和end_date用'YYYYMMDD'字符串格式。pro_bar会返回历史日线,列包括trade_date、open、high、low、close、pre_close、change、pct_chg、vol、amount。其中open是当天开盘价,vol是成交量,不是volume。这一点后面做特征时要特别注意。如果你不传adj,默认是未复权,直接用未复权数据预测开盘价会受到除权缺口影响,模型会把“除权日”误学成暴涨暴跌,所以强烈建议用复权。
前复权的含义是“以当前价格为基准,把历史价格做修正”,所以下载后所有价格都在同一价格尺度下,序列里不会出现因为分红造成的断崖式下跌。做RNN这类对数值敏感的模型,这一步比想象中更重要。你可以打印df.dtypes确认vol是float64,并且没有空值。
2.3 清洗与排序:漏掉停牌和重复交易日会怎样
下载下来的数据默认是按日期倒序的,也就是最新一天在最上面。很多初学者不排序就开始切片,导致训练集和测试集时间顺序错乱。我们需要做几件事:把trade_date转成datetime类型,按日期升序排列,重置索引,然后检查有没有重复日期或缺失值。
df['trade_date'] = pd.to_datetime(df['trade_date']) df.sort_values('trade_date', inplace=True) df.reset_index(drop=True, inplace=True) print(df.isnull().sum()) print(df.duplicated(subset='trade_date').sum())这一步逻辑很简单,但有两个细节值得说。第一,排序必须用升序,因为时间序列模型要求过去在前、未来在后。第二,tushare返回的是实际交易日,周末和节假日没有记录,这是正常的,不要试图填充这些空余日期。如果你用weekday做周期性特征,另说;但RNN窗口天然是按交易日滑动的,缺了周末就让它缺。真正需要担心的是停牌日,比如某一天因为临时停牌没有记录,你的连续窗口就会出现“跳空”。只有极少数情况下需要你根据交易日历补一行NaN,然后填充前值。就茅台而言,这些年停牌很少,但代码里保留merge交易日历的逻辑会更稳健。这里先不展开,后续避坑章节再细说。
清洗后,数据就可以进入特征工程了。这里顺手提一句:如果你想检查数据年份范围,可以打印df['trade_date'].min()和max(),确保切片没有丢失头尾。另外,df仍然保留着pre_close、change、pct_chg这些列,但在后续特征选择中我通常只保留open、high、low、close和vol,其他列和明天开盘价的直接关系不大,留着反而增加噪音和训练开销。
3. 特征工程与窗口化:RNN/LSTM输入到底是什么形状
3.1 用过去N天预测明天:滑动窗口的意义
RNN和LSTM处理的是序列,单条数据进入模型时必须带有“步长”维度。预测开盘价这个任务,最自然的设定是:用过去N个交易日的特征序列,预测下一个交易日的开盘价。也就是说,如果今天是第t天,我们要预测第t+1天的open,输入是[t-N, t]这一段特征,而不是像普通回归那样只用t这一天的特征。滑动窗口就是干这个的。
窗口大小N怎么定?这没有标准答案。N太小,模型看不到趋势;N太大,训练样本数量下降,而且远期的信息可能是噪音。我做实验一般先试N=10或者20,对应两周到一个月左右交易日。你可以把它做成超参数,后面批量搜索。注意,这里的“预测开盘价”必须严格使用开盘前的已知信息:如果[t-N, t]包含了第t+1天的任何数据,那就构成未来函数。后面会专门说这个坑。
为什么要用连续窗口?因为循环神经网络的记忆机制需要一个时间跨度的上下文,单独拿某一天的行情去预测次日,模型学不到“沿趋势运动”这类被市场参与者反复提及的规律,尽管这些规律在统计上很弱。窗口让模型有机会捕捉短期的动量或均值回归行为。对茅台这种流动性极好的股票,窗口里的成交量变化其实比价格本身更值得模型观察,所以后续特征里我把vol也放进去。
3.2 归一化:MinMaxScaler的fit与transform切分陷阱
我们先准备特征矩阵和目标向量。特征里不仅有open,还有high、low、close、vol。目标就是下一日的open。我习惯把特征和目标分开归一化,这样预测后能单独还原成真实股价。
下面是正确做法:先把数据按时间切成训练集和测试集,再分别fit和transform。如果先在全量数据上fit MinMaxScaler,测试集的最大最小值会渗透进归一化参数,这种泄漏会让验证分数虚高,实盘预测却“翻车”。
from sklearn.preprocessing import MinMaxScaler feature_cols = ['open', 'high', 'low', 'close', 'vol'] target_col = 'open' data_x = df[feature_cols].values.astype('float32') data_y = df[target_col].values.astype('float32') split_idx = int(len(df) * 0.8) train_x = data_x[:split_idx] test_x = data_x[split_idx:] train_y = data_y[:split_idx] test_y = data_y[split_idx:] scaler_x = MinMaxScaler() scaler_y = MinMaxScaler() train_x_scaled = scaler_x.fit_transform(train_x) test_x_scaled = scaler_x.transform(test_x) train_y_scaled = scaler_y.fit_transform(train_y.reshape(-1, 1)).ravel() test_y_scaled = scaler_y.transform(test_y.reshape(-1, 1)).ravel()这里把输入特征和目标分开归一化,原因是后续预测,我们只对目标做反变换。注意,train_y.reshape(-1,1)是为了让MinMaxScaler接受列向量,ravel()再转回一维。train_x_scaled和test_x_scaled是二维矩阵,行数是交易日数,列数是5个特征。这时候还没有形成窗口,下一步再切。
为什么目标要单独归一化?因为你需要还原预测值为真实价格。如果和特征混在一起用一个scaler,inverse_transform时必须构造一条完整的特征向量,但这向量里的其他特征你不知道,还原就会卡住。单独scaler_y就简单得多:预测出的归一化值直接传进去,出来的就是茅台股价。
3.3 构造三维张量:samples, timesteps, features
TensorFlow 2.0的RNN和LSTM层输入要求三维:(batch_size, timesteps, features)。我们的timesteps就是窗口大小,features就是5。构造序列的函数可以这样写:
import numpy as np def make_sequences(x_scaled, y_scaled, window_size): X, Y = [], [] for i in range(window_size, len(x_scaled)): X.append(x_scaled[i - window_size:i, :]) Y.append(y_scaled[i]) return np.array(X), np.array(Y)注意这里Y取的是第i个目标,而X取的是[i-window_size, i-1]这一段,正好错开,没有用到第i天的特征。这样构造出的每个样本形状是(window_size, 5),模型就知道“用过去window_size个交易日预测这个交易日”。但这里有个细节:当我们分别对train_x_scaled和test_x_scaled调用make_sequences时,测试集第一个样本的输入窗口完全在测试集内部,损失了测试集最前面的历史信息,而且训练集和测试集之间没有过渡的缝隙。这是常见的简陋做法,会导致测试集起始几天的预测不准确。更好的做法是让测试集从训练集末尾的窗口开始滚动,这就是最后一章要讲的滚动预测。
调用方式:
window_size = 10 X_train, Y_train = make_sequences(train_x_scaled, train_y_scaled, window_size) X_test, Y_test = make_sequences(test_x_scaled, test_y_scaled, window_size) print(X_train.shape, Y_train.shape) # (?, 10, 5), (?,)参数说明:window_size=10意味着输入10个交易日的特征;X_train的第一个维度是样本数,约等于训练集长度减去窗口大小。Y_train是一维数组,每个值对应一个目标开盘价的归一化值。到这里,模型输入才算准备好。你可以把这里的打印结果和后面的LSTM模型对照,输入层shape必须严格踩住(10, 5),否则后续fit会直接报维度错误。
4. TensorFlow 2.0搭建RNN和LSTM:两个模型的代码与参数差别
4.1 用tf.data把数据喂给模型,别让CPU拖后腿
数据准备好了,接下来就是模型。TensorFlow 2.0下,训练这种小规模时序模型,很多人感觉训练速度比PyTorch慢,其实大多数时候不是框架本身的锅,而是数据管道没做对。直接用X_train去model.fit也可以,但每次喂数据都要经过Python层,CPU和GPU之间来回等待。我一般会把训练数据包成tf.data.Dataset,加上shuffle、batch和prefetch。
import tensorflow as tf dataset = tf.data.Dataset.from_tensor_slices((X_train, Y_train)) dataset = dataset.shuffle(buffer_size=1000).batch(32).prefetch(tf.data.AUTOTUNE)shuffle的buffer_size不要太大,时间序列本来就有顺序,太大的buffer会把序列顺序彻底打乱,反而破坏局部依赖。batch_size=32是经验值,你可以试16或64。prefetch(tf.data.AUTOTUNE)让数据在后台准备,这是提速的关键。如果你用的是GPU或者苹果M系列芯片,这一步差距很明显;即使CPU训练,也会有可观收益。注意,验证集和测试集不要shuffle,保持时间顺序,否则评估结果没有意义。这里顺带说一句,很多人拿这个数据集和PyTorch做对比,其实在数据量只有几千条的场景下,框架差异几乎可以忽略,真正决定训练时间的是你如何处理数据管道。
4.2 SimpleRNN模型:一个能跑通的基准线
先搭建一个最简的SimpleRNN作为基准。它结构最简单,适合排查数据流程。
model_rnn = tf.keras.Sequential([ tf.keras.layers.SimpleRNN(64, activation='tanh', input_shape=(window_size, len(feature_cols))), tf.keras.layers.Dense(1) ]) model_rnn.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=0.001), loss='mse', metrics=['mae'] ) history_rnn = model_rnn.fit( dataset, epochs=30, validation_data=(X_test, Y_test), verbose=1 )这里SimpleRNN(64)表示隐层单元数64,activation默认就是tanh,输入形状是(window_size, 5)。Dense(1)输出一个标量,就是下一日开盘价。损失选了mse,同时用mae做监控。epochs先设30,观察loss曲线。SimpleRNN的问题是对长序列记忆能力差,参数少,训练快,但很容易出现梯度消失。如果从它开始跑,能快速验证你的数据管道和窗口构造是否正确。
我习惯把history里的loss打印出来,看训练集和验证集是否在收敛。如果训练集loss下降但验证集loss走高,就是典型的过拟合。对开盘价预测这种低信噪比任务,过拟合非常容易发生,所以后面会有专门的避坑讨论。SimpleRNN在我做的实验里通常比LSTM差不了太多,但稳定性略逊,如果你只是想快速验证流程,它足够。
4.3 LSTM模型代码:遗忘门带来的差异和训练速度对比
接下来换成LSTM。LSTM引入了遗忘门,能保留长期状态,参数量大约是SimpleRNN的四倍。在同样的窗口和数据集上,LSTM收敛更慢,训练时间也更长。如果你在TensorFlow 2.0上跑,会发现用了tf.data之后,LSTM训练速度比直接用numpy数组要快不少;但和PyTorch比,并没有绝对差距,关键还是看数据管道的写法。
model_lstm = tf.keras.Sequential([ tf.keras.layers.LSTM(64, activation='tanh', return_sequences=False, input_shape=(window_size, len(feature_cols))), tf.keras.layers.Dense(1) ]) model_lstm.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=0.001), loss='mse', metrics=['mae'] ) history_lstm = model_lstm.fit( X_train, Y_train, epochs=50, batch_size=32, validation_data=(X_test, Y_test), verbose=1 )我这段直接用numpy数组fit,方便你对比上一节的dataset写法。实际项目里建议统一用tf.data,尤其当数据量大了以后。LSTM有两个常见参数需要调:units和return_sequences。我们最后要接全连接层,所以return_sequences=False,只输出最后一个时间步的隐状态。如果你在后面还要堆LSTM层,第一层要设return_sequences=True。激活函数保持tanh,遗忘门默认sigmoid,不需要改。
训练完成后,可以简单对比model_rnn和model_lstm在测试集上的mae。通常LSTM会略好,但不一定,因为开盘价预测信噪比很低。如果两者差不多,不要意外,这是常态。真正有价值的是后面的滚动预测评估。在调参时,我一般会先从units=32和epochs=50开始,观察曲线再决定加不加层。对茅台这种单序列数据,堆太多的LSTM层容易过拟合,一层到两层足够。
5. 训练与预测常见问题排查:5个能让模型翻车的坑
这一章我会把实际踩过的坑和排查路径写出来。这些问题在基于tushare数据做RNN/LSTM预测时几乎必然遇到。
5.1 在归一化时用了全量数据,导致未来数据泄漏
现象:训练集和测试集mae都很低,但一旦换成新数据,预测曲线严重偏离真实价格。
原因:预处理时先在全量数据上调用MinMaxScaler.fit_transform,测试集的最大值和最小值已经参与计算缩放参数。模型在训练时“偷看”了未来的统计信息,测试集不再是真正的未来数据。
解决:先按时间切分,再scaler.fit(train_x)和scaler.transform(test_x)。另外,目标值的归一化也要同样处理,只对训练目标fit,测试目标用同一个scaler。你在第3章看到的就是正确写法。如果是从别的教程拷来的代码,第一件事就是检查有没有在切分前全量fit。其实这个坑我见过很多次,尤其网上一些为了演示效果的教程,会用整个数据集的min/max把两条曲线画得很漂亮,但你上线后就会发现完全不是一回事。
5.2 时间序列不能用随机shuffle,验证集必须按时间切
现象:LSTM在验证集mae上表现很好,但实际使用中连续预测几天后误差越来越大。
原因:keras的validation_split默认在尾部切,但如果你在fit中传了shuffle=True,训练样本的顺序被打乱,验证集也受影响。更重要的是,如果你用train_test_split(random_state=42)随机切分时间序列,模型看到的是跨时间的样本,验证集里可能包含训练集的“未来”,评估结果虚高。
解决:对时间序列要做“按时间顺序”的训练/验证/测试切分。训练shuffle可以,但保证shuffle只发生在训练集内部。验证集和测试集永远保持时间顺序,不要调用shuffle。这一点在金融数据里是底线,否则模型到底有没有泛化能力,根本看不出来。如果你用keras自带的validation_split,确认它按尾部切分,不要在shuffle=True时同时使用它,否则验证集可能来自时间靠前的部分,模型相当于“穿越”回过去预测,这种评估毫无意义。
5.3 预测值几乎一条直线,问题大多在目标构造和未来函数
现象:模型预测的开盘价曲线在训练集和测试集上都接近水平线,只在末端有轻微波动。
原因:常见有三种。第一,训练时不小心把当天open作为特征,模型学到“恒等映射”,但到测试集上会直接崩;第二,窗口构造时索引错位,把y_target取成了当前窗口的最后一天而不是下一天,导致输入和目标高度线性相关,预测退化成均值;第三,损失函数用MSE,模型为了最小化误差,选择预测条件均值,开盘价走势本身接近随机游走,条件均值就是一条水平线。
解决:先打印X_train[0]和Y_train[0],人工核对窗口最后一行的日期是否早于目标日期。再用滚动预测看整体趋势,如果还是水平线,检查有没有对连续价格做差分。把目标换成“涨跌幅”或者“差分后的开盘价”,往往能缓解水平线问题。不过,预测开盘价的绝对值本来就难,一条水平线不代表代码错了,可能只是模型没学到可预测性。
5.4 损失函数选错:MSE会把极端波动“平滑”掉
现象:模型在正常行情预测尚可,但遇到大阳线或大阴线时,预测值总是偏向中间区域,追不上一两根大K线。
原因:MSE对误差平方加权,极端样本的误差被放大,模型为了避免惩罚,倾向于输出更保守的预测。这在金融序列里尤其明显,因为极端值的方差很大。
解决:可以改用Huber损失,即tf.keras.losses.Huber(delta=1.0),它对离群点不那么敏感。同时,在metrics里保留mae方便对比。如果你非要看方向准确率,可以自定义准确率,比如预测值与真实值符号一致(涨或跌)的比例。这里不展开,但记住损失函数不是越精确越好,而是要和你的目标一致。方向准确率对交易更实际,但评估代码要绕一些,我一般会在训练后用np.sign比较预测值和真实值的方向一致性。
5.5 模型保存和加载问题:TensorFlow 2.0的隐藏细节
现象:用model.save保存,再用load_model加载,预测时shape报错,提示输入需要三维,但你明明传了三维数组。
原因:TensorFlow 2.0不同版本对SavedModel处理方式不一样,有时保存模型时输入形状带None批次维度,需要传入(batch, timesteps, features)才能让模型build。另外,如果你在训练前没有调用model.build或者没有给model传过一次真实输入,保存的模型可能缺少输入形状。
解决:在fit之后,随便拿一个样本调用model.predict(X_test[0:1]),让模型完成build,然后再保存。保存用model.save('lstm_model.keras')更稳,加载用tf.keras.models.load_model。加载后再次predict。还有一个容易被忽略的点:训练时用了tf.data,但predict时直接用numpy,没问题;如果你在模型里用了自定义预处理,加载后要确保输入已经经过同样的缩放,否则预测值完全错误。就这个场景而言,最稳妥的方式是保存前把scaler_x和scaler_y也存成npy文件,加载模型时一并读回。
6. 用滚动预测把模型逼到墙角:更真实的评估技巧
很多人跑完上面的代码,直接看测试集mae就觉得模型调好了。但test set里的每个样本都是独立窗口,模型并没有经历“连续预测”的情景。更贴近实际的方法是滚动预测:从训练集末尾的一个窗口出发,每一步用真实的历史特征(不包括未来)预测下一个交易日的开盘价,然后把真实数据滑动一步,继续预测。注意,因为我们只预测开盘价,而且输入特征都是前一日收盘前已知的,所以滚动预测时不需要用模型自己的输出回填,这避免了误差累积。
def rolling_predict(model, full_x_scaled, scaler_y, window_size, start_idx): preds = [] n = len(full_x_scaled) - start_idx - window_size for i in range(n): window = full_x_scaled[start_idx + i : start_idx + i + window_size] pred = model.predict(window[np.newaxis, :, :], verbose=0)[0, 0] preds.append(pred) return np.array(preds) # 将训练和测试拼接,从训练集末尾窗口开始滚动 full_x_scaled = np.concatenate([train_x_scaled, test_x_scaled], axis=0) start_idx = len(train_x_scaled) - window_size pred_scaled = rolling_predict(model_lstm, full_x_scaled, scaler_y, window_size, start_idx) pred_original = scaler_y.inverse_transform(pred_scaled.reshape(-1, 1)).ravel()这里的start_idx指向训练集末尾的前一个窗口。这个函数从训练集末尾的真实数据开始滚动,每一步使用真实特征,所以它模拟的是“开盘前已知历史,预测今日开盘价”的流程。注意,model.predict时输入要加np.newaxis变成(1, window_size, 5),否则会因shape不匹配报错。预测输出是归一化的,需要scaler_y.inverse_transform还原成真实股价。
为什么强调滚动预测?因为直接对测试集X_test做predict时,每个样本的窗口都是从测试集内部随机取的,缺少对训练集末尾状态的延续。滚动预测让模型在一个连续的时间轴上工作,早期预测偏差会通过窗口自然传导,因此滚动mae通常比直接测试集预测更高。这个数字更接近真实上线后的表现。我自己的血泪经验是:如果滚动预测曲线整体滞后真实曲线一天,说明模型实际上只是在重复昨天的状态,并没有学到预测能力;如果滚动预测在转折点处有明显的钝化,说明窗口太短,可以考虑把window_size从10改成20,再试试给模型堆一个Dropout层。开盘价序列的噪声极大,滚动预测能帮你把模型的真实下限看清楚。希望这个技巧能帮到你在类似预测任务里少走弯路。希望帮到你。
本文还有配套的精品资源,点击获取