简介:面向金融时序预测与深度学习入门人群,这份资源以贵州茅台历史行情为例,演示如何通过tushare获取真实A股数据,并基于TensorFlow 2.0搭建RNN和LSTM模型预测开盘价,完整覆盖数据抓取、清洗归一化、模型构建、训练评估与预测应用,适合具备一定Python基础、希望用真实股票数据快速上手序列建模的开发者,也可作为金融科技实践教学的项目参考。压缩包共5个文件,包含3个Python脚本(对应tushare数据下载、RNN预测、LSTM预测)、1个Markdown说明文档和1个CSV股票数据集,整体仅56KB,轻量易读;脚本分离清晰,便于按环节阅读修改。已有1912人学习下载。配套代码可直接运行复现实验,README梳理了环境准备与实现思路,CSV数据免去自行采集的麻烦,方便读者对比标准RNN与LSTM在相同任务上的预测表现,是入门时空序列预测的实用范例。
1. 用tushare拉取贵州茅台历史行情,再用tensorflow2.0搭RNN和LSTM预测开盘价:链路能走通,但瓶颈不在模型
把tushare拉到的贵州茅台日线数据,按时间窗口切成样本,喂给tensorflow2.0里搭的RNN和LSTM,用过去N天的开盘价预测第N+1天的开盘价,这是很多人在股票预测上迈出的第一步。反直觉的结论是:模型本身十几行就能跑通,真正决定成败的是数据口径、窗口构造和验证方式。比如tushare的daily接口返回的是不复权原始价,贵州茅台每年分红除权会产生虚假跳空;又比如归一化如果直接fit全量数据,测试集就相当于开卷考试。这篇笔记按数据获取、样本构造、RNN基线、LSTM替换、踩坑排查到回测验证的顺序展开,适合有Python和TensorFlow基础、想用真实金融数据做时间序列预测的从业者,把RNN和LSTM从概念落到可复现的代码上。
2. 用tushare拉取贵州茅台日线数据:token、前复权与倒序清洗三个关键点
2.1 申请token并用pro_bar接口:拉取600519.SH日线数据的最小配置
tushare现在主推的是pro接口,旧版的ts.get_k_data虽然还能用,但字段不规范、复权处理也很粗糙,做预测建模我不建议碰。标准做法是在tushare主页拿到一个token,用它初始化pro api,然后按股票代码拉日线。下面是拉取贵州茅台最近五年日线数据的最小代码。
import tushare as ts ts.set_token('把你在tushare主页申请的token填到这里') pro = ts.pro_api() # 贵州茅台在上交所,代码后缀是SH df = pro.daily(ts_code='600519.SH', start_date='20190101', end_date='20231231') print(df.shape) print(df.head())set_token把token写进全局配置,换成你自己的字符串再运行。600519.SH是贵州茅台在tushare里的标准代码,SH后缀不能省,深交所股票是.SZ。daily接口返回的是不复权的原始日线,包含ts_code、trade_date、open、high、low、close、pre_close、change、pct_chg、vol、amount这些字段。单次调用tushare有行数上限,日线数据五年大概1200行,不会触顶;如果拉十年以上的分钟线或复权因子,要注意分批。token对应的积分决定你能调哪些接口,daily和pro_bar基础积分就够,不需要额外付费接口。如果调接口时报权限错误,先去tushare的控制台确认积分是否够用。
如果不想手动处理复权,tushare提供了pro_bar接口,代码只需要把daily换成pro_bar并传一个adj参数。
df = ts.pro_bar(ts_code='600519.SH', adj='qfq', start_date='20190101', end_date='20231231') print(df.head())pro_bar返回的字段和daily基本一致,区别是价格会按复权因子调整。后面所有建模都用pro_bar的输出,就能绕开分红除权这个大坑。
2.2 前复权还是不复权:分红除权让开盘价序列出现虚假跳空
贵州茅台每年都有现金分红,股价在除权除息日会有一个向下的跳空。这个跳空反映的是股本和现金分红导致的价格调整,不是市场真实的下跌。但如果直接用不复权价格建模,RNN和LSTM会把某一天的突然低开当成真实特征,训练出来的模型会在每年分红日附近产生明显的尖峰误判。常见的做法是统一用前复权qfq,它把历史价格按最新复权因子整体调整,序列连续,不同年份之间的价格可对比。后复权hfq虽然也让序列连续,但早期价格数值特别大,对MinMaxScaler和模型训练都不够友好,我一般只在算收益率时才考虑它。
对比一下不复权和前复权的差异:把两套数据按trade_date对齐,打印除权除息日附近的open,能看到不复权价格出现一个台阶。为了让你在本地能快速验证,可以跑这样一段代码。
df_raw = pro.daily(ts_code='600519.SH', start_date='20220101', end_date='20231231') df_qfq = ts.pro_bar(ts_code='600519.SH', adj='qfq', start_date='20220101', end_date='20231231') raw_open = df_raw.sort_values('trade_date')['open'].reset_index(drop=True) qfq_open = df_qfq.sort_values('trade_date')['open'].reset_index(drop=True) # 对齐长度后,肉眼找一下每年分红除权日前后的open跳变 print((qfq_open - raw_open).describe())选定qfq之后,整个训练和评估过程都要保持qfq,不要在中间切换口径。如果项目最终要求预测真实成交价,再单独研究复权因子的还原计算,但模型训练阶段统一用前复权是最省事的做法。
2.3 排序、去重与缺失交易日:tushare返回倒序,RNN需要严格的升序
tushare的daily和pro_bar返回结果都是按日期倒序排列,最新一天在最上面。RNN和LSTM是按时间顺序学习序列依赖的,必须先排序,否则模型学到的是一条倒着走的价格曲线。另一个坑是重复行,多次调用或合并数据时可能产生重复记录,直接去重。下面是我每次拉完数据都会做的标准清洗。
import pandas as pd # trade_date在tushare里是YYYYMMDD的字符串或整数 df['trade_date'] = pd.to_datetime(df['trade_date'], format='%Y%m%d') df = df.sort_values('trade_date').reset_index(drop=True) df = df.drop_duplicates(subset='trade_date', keep='last').reset_index(drop=True) print(df.head())sort_values按交易日期升序排列,reset_index确保后续窗口切片的索引从0开始连续。drop_duplicates按trade_date去重,keep='last'保留最后拉取到的那一条记录。A股周五到周一之间有三天空档,遇到国庆春节更长,这是正常现象,不要用resample把缺失日期填平。填平之后的平开和零成交量会让模型误以为市场每天都在交易,反而引入噪声。这一步做完,df里的open就是一列按时间升序的前复权开盘价序列,可以直接进入下一章的样本构造。
3. tensorflow2.0构造序列样本:把贵州茅台开盘价变成RNN需要的时间窗口
3.1 窗口样本构造:用过去N个交易日预测第N+1天的开盘价
RNN和LSTM拿到的不是一条单独的价格,而是一段连续的历史。如果把每一天的开盘价当成独立样本喂给全连接网络,时间顺序信息就完全丢了。标准做法是滑窗采样:用一个长度为window_size的窗口在开盘价序列上滑动,窗口内的价格作为输入,窗口后一天的价格作为标签。下面是我常用的窗口构造函数。
import numpy as np def make_window(data, window_size=10): X, y = [], [] for i in range(len(data) - window_size): X.append(data[i:i + window_size]) y.append(data[i + window_size]) return np.array(X), np.array(y) # data是归一化后的开盘价一维数组,长度设为L X, y = make_window(data, window_size=10) print(X.shape, y.shape)i从0取到len(data) - window_size - 1,一个窗口取data[i:i + window_size]共10个价格,标签取窗口之后那一天的data[i + window_size]。这里最容易写错的是边界:如果range写成len(data) - window_size + 1,最后一个样本的y就超出序列长度,训练时会报索引越界。X的初始shape是(样本数, window_size),对RNN来说还需要扩展一个维度变成(样本数, window_size, 1),这一步一般在喂给模型前用X.reshape(-1, window_size, 1)完成。窗口大小是后面最值得调的超参数:10代表用两周交易日的开盘价预测第11天,20代表一个月。窗口太小模型看不到趋势,窗口太大又引入与当前关系不大的历史噪声,建议以5、10、20、30做一组对比实验。
3.2 归一化与切分:先切分再fit MinMaxScaler,防止测试集泄漏
开盘价的绝对值从几百到一千七,量级对tanh激活函数不友好,必须先归一化。常见的错误是先对全量序列fit一遍MinMaxScaler再切分,这样测试集的min和max参与了训练时所用的缩放参数,测试集信息泄漏进了模型,验证结果会虚高。正确顺序是:先按时间切分,只对训练集fit,再用同一个scaler去transform测试集。
from sklearn.preprocessing import MinMaxScaler scaler = MinMaxScaler(feature_range=(0, 1)) train_size = int(len(open_qfq) * 0.8) train_raw = open_qfq[:train_size].reshape(-1, 1) test_raw = open_qfq[train_size:].reshape(-1, 1) scaler.fit(train_raw) train_scaled = scaler.transform(train_raw).flatten() test_scaled = scaler.transform(test_raw).flatten() print(train_scaled.min(), train_scaled.max())MinMaxScaler要求输入是二维列向量,所以reshape成(-1, 1)。fit只计算训练集的min和max,transform对测试集套用同一组参数。这样测试集虽然也落在[0, 1]区间,但缩放边界完全来自训练集,不包含未来信息。打印train_scaled的最小值和最大值,确认训练集被压到[0, 1]。预测完成后要做inverse_transform还原,用的也必须是这一个scaler对象,不能重新fit。
3.3 搭建第一个SimpleRNN:tf.keras里跑通最小模型
数据准备好后,先搭一个最简单的RNN做baseline,网络不要复杂,目的是验证数据链路是否通。tensorflow2.0把RNN封装在tf.keras.layers里,SimpleRNN是最直接的循环层。在3.1的make_window和3.2的scaler基础上,把训练段和测试段分别做窗口化,再扩展成三维输入。
import tensorflow as tf from tensorflow.keras.models import Sequential from tensorflow.keras.layers import SimpleRNN, Dense X_train, y_train = make_window(train_scaled, window_size) X_test, y_test = make_window(test_scaled, window_size) X_train = X_train.reshape(-1, window_size, 1) X_test = X_test.reshape(-1, window_size, 1) model = Sequential([ SimpleRNN(units=32, activation='tanh', input_shape=(window_size, 1)), Dense(1) ]) model.compile(optimizer='adam', loss='mse', metrics=['mae']) model.summary()units是循环层隐藏状态维度,32是起步值。activation默认就是tanh,不要换成sigmoid,RNN的梯度在sigmoid下衰减更快,序列稍长就学不动。input_shape=(window_size, 1)表示每个样本有window_size个时间步、每步1个特征。Dense(1)输出一个标量,作为下一个交易日的开盘价。损失用mse,回归任务默认口径。训练时加一个EarlyStopping,避免epoch设太大导致过拟合。
from tensorflow.keras.callbacks import EarlyStopping # 手动从训练集尾部切出最近10%作为验证集,保持时间顺序 val_size = int(len(X_train) * 0.1) X_val, y_val = X_train[-val_size:], y_train[-val_size:] X_train_fit, y_train_fit = X_train[:-val_size], y_train[:-val_size] early_stop = EarlyStopping(monitor='val_loss', patience=20, restore_best_weights=True) history = model.fit( X_train_fit, y_train_fit, epochs=200, batch_size=32, validation_data=(X_val, y_val), callbacks=[early_stop], verbose=1 )这里没有用validation_split,而是手动按时间从训练集尾部切出最近10%作为验证集,避免随机抽样破坏时间顺序。validation_data直接传(X_val, y_val),训练时EarlyStopping盯验证集loss,连续20个epoch不下降就停,并恢复到验证集最好的权重。batch_size=32对几千个样本够用。跑完后用model.evaluate看测试集mae,再把预测值inverse_transform回去画图。这个最简单的RNN预测曲线通常有明显滞后,不要慌,这是标量回归里常见的naive现象,下一章换上LSTM之后再做对比。
4. 从RNN到LSTM:用tensorflow2.0的门控机制解决梯度衰减和记忆不足
4.1 LSTM的三个门:遗忘、输入、输出在开盘价预测中的角色
SimpleRNN在时间步上做的是一个带tanh激活的全连接变换,权重连乘导致梯度指数衰减,序列一长,前面的开盘价对当前预测的影响就越来越弱。LSTM也就是长短期记忆网络,它在隐藏状态之外增加了一条细胞状态通道,用三个门控制信息的写入、保留和输出。用开盘价预测的场景来理解这三个门,比背公式直观得多。
| 门 | 控制的对象 | 在开盘价预测里的作用 |
|---|---|---|
| 遗忘门 | 上一时刻细胞状态保留多少 | 决定10天前的暴涨暴跌是否还要影响明天的预测 |
| 输入门 | 当前时刻新信息写入多少 | 决定今天的价格突变是否值得记入长期记忆 |
| 输出门 | 当前细胞状态输出多少给隐藏层 | 决定当前记忆中有多少用来产生明天的开盘价输出 |
把公式压缩成两行的话:C_t = f_t × C_{t-1} + i_t × C~_t,h_t = o_t × tanh(C_t)。f、i、o分别是遗忘门、输入门、输出门。LSTM给循环网络配了一个可学习的记忆读写器,这也是它处理时间序列比普通RNN更稳的核心原因。代价是参数量和计算量上去了,训练时间变长。对贵州茅台这种千元价位的股票,开盘价序列里既有长期趋势,又有分红除权留下的短期扰动,LSTM的记忆机制理论上比SimpleRNN更适合吃下这种混合信号。
4.2 一层LSTM替换SimpleRNN:代码差异与参数量变化
在tf.keras里把SimpleRNN换成LSTM只改一行。下面是和3.3对应的完整模型定义。
from tensorflow.keras.layers import LSTM model = Sequential([ LSTM(units=32, input_shape=(window_size, 1)), Dense(1) ]) model.compile(optimizer='adam', loss='mse', metrics=['mae']) model.summary()LSTM默认activation='tanh',recurrent_activation='sigmoid',一般不用改。参数量相比SimpleRNN大约变成四倍:32单元的SimpleRNN参数量是32×(32+1+1)=1088,LSTM因为内部有三组门加一个候选状态共四套权重,是32×(32+1+1)×4=4352。训练代码和3.3完全一样,EarlyStopping照用。跑完之后把SimpleRNN和LSTM在同一份测试集上的MAE放在一起对比,重点不是看谁训练loss低,而是看谁对测试集末尾的滞后更小。窗口小于10时LSTM的优势通常不明显,窗口拉到20甚至30时,LSTM的长期记忆优势才会真正体现出来。
4.3 堆叠LSTM与Dropout:两层模型的标准配法
一层LSTM的表示能力有限,很多场景下堆两层效果更稳。两层LSTM和一层的关键差异是:第一层要为第二层输出每个时间步的隐藏状态,所以必须return_sequences=True;最后一层LSTM只输出最后一个时间步的结果给Dense。标准配法如下。
from tensorflow.keras.layers import LSTM, Dropout model = Sequential([ LSTM(units=64, return_sequences=True, input_shape=(window_size, 1)), Dropout(0.2), LSTM(units=32), Dropout(0.2), Dense(1) ])第一层LSTM返回完整的时间步序列,shape是(batch_size, window_size, 64);第二层LSTM默认只返回最后一个时间步的输出,直接接到Dense(1)。两个Dropout加在LSTM输出之后,ratio取0.2作为起步。如果数据量只有几百个交易日、窗口又不大,两层LSTM很容易在训练集上记住噪声,验证集和测试集反而变差。我一般会先跑一层32单元,看训练曲线是否过拟合,再决定要不要加层和加dropout。LSTM层也自带dropout参数,比如LSTM(units=32, dropout=0.2, recurrent_dropout=0.2),效果类似,但recurrent_dropout会让训练明显变慢,数据量不大时优先用Dropout层更省时间。
5. 避坑:tushare与tensorflow2.0组合下预测开盘价的5个常见翻车点
5.1 归一化泄漏:scaler.fit全量数据导致测试集变成开卷考试
现象:训练集loss正常下降,测试集预测曲线紧贴真实曲线,但一换到新的时间区间就崩。
原因:对全量序列先fit MinMaxScaler再切分,测试集的min和max已经写进缩放参数,模型等于提前见过测试集的价格区间,验证结果虚高。
解决:先按时间切出训练集和测试集,只在训练集上fit,测试集只transform。第3.2节的代码顺序就是为这个服务的,切分和fit两步的顺序不能调换。
5.2 除权跳空:tushare的daily返回不复权价格,分红日附近出现虚假下跌
现象:预测序列每年固定某个时间段出现一个明显向下的大误差,过了这个时间段又恢复正常。
原因:贵州茅台每年分红除权,不复权价格在除权日出现跳空,RNN把除权当成了真实暴跌,学习到错误的下跌模式。
解决:用ts.pro_bar(adj='qfq')拉前复权数据,训练、验证、测试全链路统一用qfq口径,中途不要混用不复权价格。如果项目要求预测真实成交价,再单独研究复权因子的还原计算。
5.3 验证集切分随意:validation_split与shuffle结合破坏时间顺序
现象:模型在验证集上loss很低,换成自己留的测试集后指标明显变差。
原因:model.fit里用validation_split并保留默认shuffle,验证集可能来自被打乱后的随机位置,时间顺序被破坏,验证分数不可信。
解决:手动按时间顺序切分验证集,比如把训练集最后10%的样本作为验证集,通过validation_data传入,同时保持训练数据本身按时间升序。
5.4 用MAE当唯一指标:股价上千元时,20元的误差看起来很大其实很准
现象:LSTM的测试MAE一直在20到30元之间,感觉模型完全没用。
原因:贵州茅台价格在千元以上,20元误差对应的MAPE只有2%左右,而股价正常波动一天就能有20元,绝对误差不能直接反映模型质量。
解决:同时记录MAE、MAPE和RMSE,并加一个naive baseline:用t-1的开盘价直接当t时刻开盘价的预测值。LSTM如果连这个zero-shot baseline都打不过,先别调网络结构,回到数据口径和窗口构造上查问题。
5.5 预测阶段窗口不够长:在线预测时报维度错误
现象:模型训练完,拿最新一天的真实数据去predict,输入维度报错。
原因:预测明天开盘价时,需要用截至今天为止的最近window_size个开盘价作为输入,如果只取了不到window_size个样本,reshape出来的维度就不对。
解决:写一个取最近窗口的函数,输入完整序列和当前position,从position - window_size取到position作为输入。预测目标永远是窗口之后那一天的标签,不要把当天的开盘价提前塞进输入。
6. 用滑窗回测验证LSTM预测贵州茅台开盘价:把准不准变成可对比的数字
模型训练完,只看最后一次测试集loss没有说服力。我的习惯是做滑窗回测:在测试期从第一天开始,每次只用截止到当前时刻之前的数据构造窗口,预测下一天,然后向前滚动一步,把整段测试期的预测值和真实值拼起来,再统一算指标。这一步能把模型大概能跑变成每个位置预测偏差多少。
def rolling_predict(model, scaler, series_scaled, window_size): preds = [] for i in range(window_size, len(series_scaled)): win = series_scaled[i - window_size:i].reshape(1, window_size, 1) pred = model.predict(win, verbose=0)[0, 0] preds.append(pred) return scaler.inverse_transform(np.array(preds).reshape(-1, 1)).flatten()series_scaled是归一化后的序列,窗口从i - window_size取到i,i对应真实标签的位置。predict用verbose=0避免刷屏,最后把预测值统一还原成元。拿到pred之后再和真实测试段对齐,rolling_predict返回的第一个值对应测试期第window_size个位置之后的第一个点。接着算MAE、MAPE和naive baseline。
from sklearn.metrics import mean_absolute_error, mean_squared_error mae = mean_absolute_error(actual, pred) mape = np.mean(np.abs((actual - pred) / actual)) * 100 # naive baseline:用t-1的开盘价直接当t时刻的预测 naive_mae = mean_absolute_error(actual[:-1], actual[1:]) print(f"LSTM MAE={mae:.2f} MAPE={mape:.2f}% | naive MAE={naive_mae:.2f}")naive_mae用t-1时刻的真实值当t时刻的预测,这一步能立刻看出LSTM是否只是学到了跟着前一天走。建议把不同参数组合的结果记录成一张表,比如:
| window_size | 网络结构 | test_mae | naive_mae |
|---|---|---|---|
| 10 | SimpleRNN-32 | ||
| 10 | LSTM-32 | ||
| 20 | LSTM-32 | ||
| 20 | LSTM-64-Dropout |
只要记录过一次,你会发现很多调参决策不需要凭感觉。这个流程跑通之后,我对预测类项目的态度变了很多。最开始我用tushare拉数据、tf.keras搭LSTM,训练曲线漂亮得不行,拿到新时间段一滚回来就翻车,问题全出在数据泄漏和验证集切分上,网络结构反而是最后才需要动的地方。现在我的标准动作是:先固定前复权口径,再按时间切分,跑naive baseline,最后才轮到RNN和LSTM之间的对比。窗口、层数、dropout都放在回测结果后面去说明,结论才站得住。这套方法论对开盘价有效,对收盘价、最高价等其他序列同样适用,希望帮到你。
本文还有配套的精品资源,点击获取