ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LSTM股票预测实战:时序特征处理决定模型上限

LSTM股票预测实战:时序特征处理决定模型上限

做股票预测的人,十个里有八个在第一步就栽了跟头——不是模型不够强,而是喂给模型的时序数据根本没处理好。我去年做了一个项目,任务是从雅虎财经接口拉取美股历史行情,用LSTM预测未来几个交易日的走势,项目标题就叫“基于时序特征处理与LSTM结合的雅虎美股数据预测”。做完之后最大的体感是:LSTM本身不神秘,真正决定预测质量上限的,是数据落地前那套时序特征处理有没有做到位。这篇文章我把整个项目的设计思路、数据管线、模型搭建和踩坑记录全部摊开讲,适合想入门时间序列深度学习,或者正在做金融数据预测但效果一直不理想的人直接照着复现。

1. 项目整体设计与思路拆解

1.1 这到底是个什么问题:不只是“预测股价”

表面上看,任务很清晰:给一段历史行情,预测明天的收盘价。但一旦真正动手,你就会发现“预测股价”这个表述充满了陷阱。股价序列是非平稳的,长期趋势、周期性波动、突发事件产生的跳空缺口,全部混在一起;更麻烦的是,金融序列的信噪比极低,大部分“规律”可能只是噪声的随机排列。所以这个项目的核心难点从来不是“跑通一个LSTM”,而是怎么把原始行情转换成模型能真正学出模式的时序特征。

我还想强调一点:这个项目适合作为算法研究和工程实操样例,不是说照着做就能稳定盈利。目标应该放在“能不能建立一套可复现的预测流水线”“能不能正确评估模型效果”上,而不是指望一个模型成为摇钱树。带着这个预期去看技术细节,会理性很多。

1.2 为什么是LSTM:序列建模的选型逻辑

处理时间序列的经典方法很多,ARIMA、GARCH、状态空间模型都在传统金融分析里占据过重要位置。但这些模型通常对数据有较强的假设,比如线性关系、平稳性、特定分布,而股价数据恰恰在大部分时间不满足这些假设。传统机器学习模型如果直接拿“过去几天的数值”当特征,特征之间高度相关,而且完全丢失了“时间顺序”信息,模型无法区分“三天前的高点”和“昨天的高点”对今天的影响有什么不同。

LSTM(长短期记忆网络)是RNN的一种变体,它的核心优势在于门控机制。简单说,细胞状态负责记住长期信息,遗忘门决定哪些历史信息可以丢弃,输入门决定哪些新信息值得写入,输出门控制当前状态输出多少。这种设计让模型能在几十个时间步的窗口内保留有效信息,同时避免普通RNN训练时常见的梯度消失问题。用个生活化的类比:LSTM就像一个有记忆管理习惯的人,重要的信息长期不遗忘,无效信息自动清理,从而在长序列上不“断片”。

当然,LSTM也不是银弹。它对时间步长的长度敏感,对特征的量纲和分布敏感,如果直接把原始收盘价一股脑塞进去,模型通常只会学到“预测值约等于上一个时间步的值”这种偷懒的映射。因此在LSTM之前,必须做时序特征处理。

1.3 时序特征处理在整个流程中的角色

时序特征处理在这个项目里包含几层意思:第一是让数据平稳化,比如差分、计算收益率;第二是让量纲统一,比如归一化;第三是构造更有信息量的特征,比如滚动统计量、技术指标、日历特征;第四是把“过去一段时间”组织成监督学习的样本,也就是滑动窗口。

这一系列步骤决定了模型看到的是什么。如果输入是原始价格,网络很容易学到“明天的价格约等于今天的价格”,因为相邻交易日价格变化往往很小,这个“复制上一天”的预测在MSE指标上已经足够低。但这样的模型没有实际价值,它只是在拟合一个近乎恒等的映射。反过来,如果用收益率作为输入和预测目标,模型被迫去学习“涨跌的趋势和幅度”,信号的辨识度更高,虽然预测难度更大,但至少方向是对的。

我个人的经验是:时序预测项目里,特征工程的收益往往比换模型结构更明显。这也是我把“时序特征处理”和“LSTM”放在同等位置的原因。

1.4 项目整体技术路线

整个项目分为五个环节:数据获取、数据清洗与特征构造、数据集生成、LSTM建模与训练、结果评估与可视化。数据层用雅虎财经接口拉取日线行情;处理层用pandas完成清洗、差分、滚动统计和归一化;建模层用Keras搭建LSTM;评估层同时看回归误差和方向准确率。下文我会按这个链路逐步展开,并提供完整代码和参数选择依据。

2. 数据获取与时序特征工程实操

2.1 从雅虎财经拉取历史行情数据

雅虎财经的公共历史数据接口非常方便,Python里一般直接用yfinance封装。安装很简单:

pip install yfinance pandas numpy matplotlib scikit-learn tensorflow

拉取数据的代码如下:

import yfinance as yf import pandas as pd # 示例标的代码,实际运行时请替换为你要研究的美股代码 df = yf.download("DEMO", start="2015-01-01", end="2024-12-31", auto_adjust=True) df = df[["Open", "High", "Low", "Close", "Volume"]].copy() df.dropna(inplace=True) print(df.head()) print(df.tail())

有几个细节值得注意。auto_adjust=True表示自动处理复权,也就是把分红、拆股造成的价格跳空修正掉。如果不做复权,模型会把一次拆股导致的“价格腰斩”当作文本上的真实下跌,明显是错误信号。收盘价这里用调整后价格。

拉数据时可能遇到两种情况:一是标的代码过期或拼写错误,会返回空数据;二是网络环境不稳定,导致下载失败。我的处理方案是先判断df是否为空,为空就直接退出,避免后续流程在一堆NaN上跑出可疑结果。不要迷信接口的完整性,拉下来之后必须做基础体检:检查行数、检查日期是否连续、检查有无NaN。

如果网络实在不稳定,还有一个稳妥的替代方案:直接把历史行情导出成CSV,放在本地读取,或者使用本地财经数据库。数据源不必死磕接口,关键是数据质量和时间跨度。

2.2 数据清洗与预处理:先做减法再做加法

行情数据本身是比较规整的,但仍然有几件必须做的事。

第一,检查并处理缺失值。美股偶尔会因为停牌、数据源异常产生空行。处理方式通常是删除,或者用前向填充。我个人倾向于删除,因为金融数据造假填充反而会引入虚假规律,尤其是连续停牌期间,前向填充会把“无交易”伪装成“价格不变”,这是错误的。

第二,删除极端离群值。某些异常交易日会出现远超正常波动幅度的价格跳动,可能是数据错误,也可能是真实的极端行情。这一块要谨慎,我的原则是:只有明显超出历史波动范围且无法合理解释的数据才删除,幅度在正常范围内的一律保留,因为模型需要见过各种市场状态。

第三,做平稳化转换。最常用的是对收盘价取差分或计算百分比收益率。差分公式是 diff_t = close_t - close_{t-1},百分比收益率是 return_t = (close_t - close_{t-1}) / close_{t-1}。收益率天然消除了绝对价格水平的影响,也更接近金融研究的常用口径。

第四,量纲归一化。LSTM对输入特征的尺度敏感,如果Close是几千美元,Volume是几千万股,模型训练时大数值特征会主导梯度。我用MinMaxScaler把所有特征压缩到0到1之间。

这里要特别强调一个很多教程都会犯的错误:缩放器必须在训练集上fit,再transform训练集和测试集,而不是先对全量数据fit再划分。如果先fit全量数据,均值和最小最大值里包含了未来信息,测试集信息在训练阶段就被模型间接看到了,这属于信息泄漏,会让评估结果虚高。

2.3 时序特征构造:滑动窗口、差分、技术指标

这一节是整个项目里最能体现“时序特征处理”价值的地方,也是我和只调LSTM的做法的核心区别。我构造了下面几类特征。

第一类,价格本身。收盘价是最基础的信号,但我不直接使用原始值,而是使用滚动均值、滚动标准差等方式描述它。滚动均值MA10代表近期趋势方向,滚动标准差Std10代表近期波动程度。

第二类,收益率相关特征。包括单日收益率、滚动收益率均值、滚动收益率标准差。收益率把绝对价格变成相对变化,能明显缓解LSTM“复制上一帧”的惰性问题。

第三类,交易量特征。成交量是价格之外最核心的市场参与度信息。量价配合往往能揭示市场情绪,比如放量突破、缩量回调等。我构造了成交量变化率,以及成交量的滚动均值。

第四类,趋势特征。可以用MACD、RSI等技术指标作为额外特征。技术上要注意的是,这些指标必须只能用历史数据计算,不能用未来数据,否则就引入了未来函数。我用pandas的rolling、ewm等方法计算,天然只使用当前及过去的数据,不会前视。

构造代码示意:

df["return_1"] = df["Close"].pct_change() df["diff_1"] = df["Close"].diff() df["ma10"] = df["Close"].rolling(10).mean() df["std10"] = df["Close"].rolling(10).std() df["vol_ret"] = df["Volume"].pct_change() df["vol_ma10"] = df["Volume"].rolling(10).mean() # 去掉因为滚动计算产生的NaN行 df.dropna(inplace=True)

这里的窗口10天是一个经验值,代表大约两周的交易周期。你可以用20、30做对比实验,但不要一次引入太多不同窗口的同类特征,会造成特征冗余。特征不是越多越好,LSTM吃进去的每一维特征都要承担信息,冗余特征只会增加过拟合风险。

2.4 数据切分:请务必不要随机打乱

时间序列监督学习的第一步,是把“过去N天”的数据拼成一个样本,把“未来第N+1天”作为标签。我用60天的窗口。为什么是60?因为美股一个季度大约有63个交易日,60天窗口能让模型同时看到两个月的趋势和一个季度的周期轮廓。窗口太短模型学不到周期,太长又会引入陈旧信息,60只是一个合理的起点,你可以用30、90做敏感性测试。

构建样本的代码:

import numpy as np def make_sequences(data, window=60): X = [] y = [] for i in range(window, len(data)): X.append(data[i-window:i]) # 默认预测下一个时间步的Close y.append(data[i, 0]) return np.array(X), np.array(y)

注意这个函数里data的列顺序很关键,我习惯把Close放在第一列,这样y取第0列就是Close。

然后是数据集划分。这里有一条铁律:绝对不能使用随机切分,必须按时间顺序切分。金融时序里有强烈的时间依赖性,如果随机打乱,训练集里会出现未来数据,测试集里会出现过去数据,模型相当于在“开着天眼考试”,评估分数再好看也没有意义。

train_size = int(len(X) * 0.8) X_train, X_test = X[:train_size], X[train_size:] y_train, y_test = y[:train_size], y[train_size:]

比例用80/20。80%作为训练,20%作为测试,这20%要是最近期的数据,因为股票预测的实际场景就是“用历史拟合,对最新时刻做推断”。

3. LSTM模型设计与训练细节

3.1 模型输入到底长什么样:三维张量

很多人第一次写LSTM都会在输入维度上卡住。LSTM的输入必须是三维张量,形状是(samples, timesteps, features)。在我们这个项目里,samples是样本数量,timesteps是窗口长度60,features是特征维度,比如5个。也就是说,每一个训练样本是一个60×5的矩阵,代表过去60天每天的5个特征值。

X_train.shape应该长这样:(样本数, 60, 特征数)。如果这个形状不对,模型根本跑不起来。常见错误是把二维数组直接丢给LSTM,Keras会直接报维度错误。构建完数据后先打印一下X_train.shape,这一步能省掉很多调试时间。

3.2 网络结构设计:从单层到多层

我采用两层LSTM加Dropout的结构。

from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout model = Sequential() model.add(LSTM(64, return_sequences=True, input_shape=(X_train.shape[1], X_train.shape[2]))) model.add(Dropout(0.2)) model.add(LSTM(32, return_sequences=False)) model.add(Dropout(0.2)) model.add(Dense(16, activation="relu")) model.add(Dense(1))

第一层LSTM有64个单元,return_sequences=True表示返回所有时间步的隐藏状态,这样第二层LSTM能继续处理完整的时间信息。第二层32个单元,return_sequences=False,只返回最后一个时间步的输出,接一个全连接层做最终回归。Dropout设置在0.2左右,防止过拟合。

这里有个经验:不要一上来就搭三层四层LSTM。金融时序样本量一般不大,层数加深之后参数量激增,很容易在训练集上表现很好、测试集上崩塌。我在这个项目里尝试过三层LSTM,结果是训练Loss降得很快,但测试集方向准确率反而下降。所以两层是更稳妥的起点。

3.3 训练参数:学习率、批次大小、早停

编译阶段我用Adam优化器,默认学习率0.001,损失函数用MSE,同时监控MAE。MSE对“大误差”敏感,能让模型优先拟合那些偏离明显的样本;MAE则便于直观理解平均误差水平。

训练时我设置了EarlyStopping,监控验证集Loss,连续20个epoch没有改善就停止,并且恢复最优权重。这样可以省掉很多无效训练时间,也避免过拟合。

from tensorflow.keras.callbacks import EarlyStopping early_stop = EarlyStopping( monitor="val_loss", patience=20, restore_best_weights=True ) history = model.fit( X_train, y_train, epochs=100, batch_size=32, validation_split=0.1, callbacks=[early_stop], verbose=1 )

batch_size=32是均衡稳定性和训练速度的选择。金融时序样本通常上万条,32到64之间都可以。学习率对收敛影响很大,如果发现Loss剧烈震荡,可以降到0.0005;如果收敛太慢,可以适当提升,但一般不建议超过0.01。

注意validation_split=0.1是在训练集内部再切出一部分做验证,它依然保持了时序顺序,Keras会取最后10%当验证集,不会乱序。

3.4 损失函数与评估指标的选择

股价预测的评估不能只看MSE。因为股价本身数值很大,MSE的绝对值动辄几十几百,看不出模型好坏。我同时看四个指标:

RMSE(均方根误差),单位跟股价一致,直观。MAE(平均绝对误差),同样直观。MAPE(平均绝对百分比误差),反映相对误差,便于在代码之间横向对比。DA(方向准确率),统计预测方向与真实方向一致的比例,这才是交易场景里更关心的指标。方向准确率计算需要先判断真实价格是涨还是跌,然后判断预测价格是涨还是跌,两者一致的比例就是DA。

方向准确率的计算代码有很多细节,我后续在完整代码里给一个可用的实现。很多LSTM股价预测项目只看RMSE,结果RMSE很低大家很高兴,实际看曲线发现预测值只是把真实线向右平移了一天,方向准确率可能不到50%,这种模型一点实战价值都没有。所以评估体系必须把方向准确率放进来。

4. 完整实现与关键代码解析

4.1 环境准备

在开始完整代码前,先列一下依赖:

pip install yfinance pandas numpy matplotlib scikit-learn tensorflow

版本方面,TensorFlow用2.x,Keras集成在TensorFlow里,不需要单独安装。yfinance建议用最新版,老版本偶尔有接口字段变化问题。

硬件方面,这个项目的数据量用CPU也能跑,但训练LSTM时如果有GPU会快很多。我用的是普通笔记本,训练100个epoch加早停,大约几分钟到十几分钟,完全可以接受。

4.2 数据获取与特征构建代码

完整特征构建代码:

import yfinance as yf import pandas as pd import numpy as np from sklearn.preprocessing import MinMaxScaler # 1. 获取数据 df = yf.download("DEMO", start="2015-01-01", end="2024-12-31", auto_adjust=True) df = df[["Open", "High", "Low", "Close", "Volume"]].copy() df.dropna(inplace=True) # 2. 时序特征工程 df["return_1"] = df["Close"].pct_change() df["diff_1"] = df["Close"].diff() df["ma10"] = df["Close"].rolling(10).mean() df["std10"] = df["Close"].rolling(10).std() df["vol_ret"] = df["Volume"].pct_change() df["vol_ma10"] = df["Volume"].rolling(10).mean() df.dropna(inplace=True) # 3. 特征列 feature_cols = ["Close", "return_1", "diff_1", "ma10", "std10", "vol_ret", "vol_ma10"] # 4. 先切分再归一化 train_size = int(len(df) * 0.8) df_train = df.iloc[:train_size] df_test = df.iloc[train_size:] scaler = MinMaxScaler() scaled_train = scaler.fit_transform(df_train[feature_cols]) scaled_test = scaler.transform(df_test[feature_cols])

这里的关键就是先切分再fit。scaled_test用的scaler是训练集fit出来的,而不是在全部数据上fit的。为什么这么重要?因为MinMaxScaler需要知道特征的最小值和最大值,如果测试集提前参与了计算,模型在训练阶段就“见过”测试集的范围分布,相当于考试时偷看了一眼答案。

4.3 数据集构建代码

构建序列数据集时,要处理好两个问题:第一,窗口长度;第二,训练集和测试集的构造不能交叉。

def make_sequences_from_scaled(scaled, window=60): X = [] y = [] for i in range(window, len(scaled)): X.append(scaled[i-window:i]) y.append(scaled[i, 0]) # 第0列是归一化后的Close return np.array(X), np.array(y) X_train, y_train = make_sequences_from_scaled(scaled_train, window=60) X_test, y_test = make_sequences_from_scaled(scaled_test, window=60) print(X_train.shape, y_train.shape) print(X_test.shape, y_test.shape)

有人会担心测试集的前60行因为没有完整的历史窗口而无法生成样本,这是正常的。测试集的有效样本是从第60天开始的,前面的数据作为窗口背景被消耗掉了。

这里还有一个细节:因为归一化是在切分后分别进行的,df_train和df_test被分别转换,但转换后都扔进了同一个滑动窗口函数,窗口内的特征值域都是0到1,一致性没有问题。

4.4 模型构建与训练代码

把前面的模型定义和训练代码整合在一起:

from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout from tensorflow.keras.callbacks import EarlyStopping model = Sequential() model.add(LSTM(64, return_sequences=True, input_shape=(X_train.shape[1], X_train.shape[2]))) model.add(Dropout(0.2)) model.add(LSTM(32, return_sequences=False)) model.add(Dropout(0.2)) model.add(Dense(16, activation="relu")) model.add(Dense(1)) model.compile(optimizer="adam", loss="mse", metrics=["mae"]) early_stop = EarlyStopping( monitor="val_loss", patience=20, restore_best_weights=True ) history = model.fit( X_train, y_train, epochs=100, batch_size=32, validation_split=0.1, callbacks=[early_stop], verbose=1 )

训练过程里我习惯观察两个东西:val_loss(验证集损失)和train_loss(训练集损失)。如果train_loss一路下降但val_loss先降后升,说明过拟合开始;如果两个都不降,先检查数据是否包含NaN,再尝试降低学习率。

训练完之后,可以画出Loss曲线:

import matplotlib.pyplot as plt plt.plot(history.history["loss"], label="train_loss") plt.plot(history.history["val_loss"], label="val_loss") plt.legend() plt.show()

这条曲线能直观反映模型的收敛状态。如果train_loss和val_loss都稳定下降并且最终贴近,说明训练基本健康。

4.5 预测、反归一化与可视化

模型预测得到的结果是归一化后的Close值,不能直接用来跟真实股价对比,必须先反归一化。这里我单独为Close建一个scaler更简洁。思路是:从原始df中取Close列,单独fit一个MinMaxScaler,然后用它完成y的逆变换。

close_scaler = MinMaxScaler() close_values = df["Close"].values.reshape(-1, 1) close_scaler.fit(close_values) # 注意:这里用训练集fit的scaler对y_train也做一遍逆变换,测试集同理 # 但更严谨的是用训练集Close单独fit的scaler

上面这版有一个隐患:close_scaler是全量数据的Close拟合的,测试集信息又泄漏了。如果要彻底严谨,应该只用训练集Close拟合。

train_close_values = df["Close"].values[:train_size].reshape(-1, 1) close_scaler = MinMaxScaler() close_scaler.fit(train_close_values) y_train_inv = close_scaler.inverse_transform(y_train.reshape(-1, 1)) y_pred_inv = close_scaler.inverse_transform(y_pred.reshape(-1, 1)) y_test_inv = close_scaler.inverse_transform(y_test.reshape(-1, 1))

这里我用了一个单独scaler而不是前面全部特征的scaler,是因为只关心Close的还原。如果用全特征scaler对y做逆变换需要把y向量还原成完整特征向量的形状,再取第一列,非常麻烦,也没必要。单独训练一个Close缩放器,代码更清晰,也不会影响特征缩放的效果。

最后可视化预测序列和真实序列:

plt.figure(figsize=(12, 5)) plt.plot(y_test_inv, label="Real Close", color="black") plt.plot(y_pred_inv, label="Predicted Close", color="red", linestyle="--") plt.legend() plt.title("DEMO Stock Close Price Prediction") plt.show()

看到这张图你就知道模型有没有出现“滞后预测”的问题。如果预测线和真实线看起来非常贴近,但仔细看预测线整体向右平移了一天,那不是预测成功,而是模型学会了“照抄上一天”,这是LSTM股价预测里最典型的假成功。

4.6 方向准确率计算

我加上一个方向准确率指标:

def direction_accuracy(y_true, y_pred): true_diff = np.diff(y_true.flatten()) pred_diff = np.diff(y_pred.flatten()) correct = np.sign(true_diff) == np.sign(pred_diff) return np.mean(correct) da = direction_accuracy(y_test_inv, y_pred_inv) print(f"Direction Accuracy: {da:.4f}")

这个指标比RMSE更贴近交易场景。我之前做过一个模型,RMSE低到以为自己要“起飞”了,结果方向准确率只有49.6%,等于抛硬币。后来我换成收益率特征加多步预测,方向准确率才勉强到53%左右。在金融预测里,能把方向准确率稳定做到52%以上已经算有信息量了。

5. 常见问题、踩坑记录与排查思路

5.1 预测结果“神预测”:滞后性问题

我在前面反复提的滞后问题,是LSTM股票预测项目里最普遍的假象。具体表现是:真实价格下跌时,预测线也在下跌,但总是慢一步;真实曲线出现拐点,预测线还在沿用前一天的方向。整体看起来“高度拟合”,实际模型什么都没学会,只是把上一时刻的价格稍微平滑了一下,输出等于输入的延迟版本。

造成这个现象的根本原因是:输入里有强自相关的原始价格或滞后特征,模型只要学到p_{t+1}=p_t,就能让MSE足够低。优化器很聪明,它发现这个“作弊解”能让损失快速下降,于是不再去学习更复杂的模式。

处理方法我试下来比较有效的有几招。第一,预测目标从“收盘价”改成“未来收益率”,避免模型直接回归绝对价格。第二,输入特征里尽可能使用差分、收益率、滚动统计量,减少原始价格的主导性。第三,预测多步而不是单步,比如预测未来3天收益的方向,模型无法靠简单滞后通过评估。第四,用方向准确率作为主评估指标,如果DA低于51%,直接判定模型无效。

5.2 Loss不下降或震荡严重

训练时Loss完全不下降,最常见的原因是数据里有NaN或inf,梯度计算结果变成NaN,模型直接罢工。解决办法很简单:检查数据,在特征构造后打印df.isnull().sum(),确认没有缺失值再进入训练。

Loss震荡严重通常是学习率设置不当。Adam默认0.001在多数情况下可以,但如果数据分布复杂,震荡明显,就把学习率降到0.0005或0.0001。还有一种情况是batch_size太小,单个batch的梯度噪声太大,可以尝试把batch_size调到64或128。

还有一个容易被忽略的问题:窗口长度太短。如果窗口只有5天,模型看到的信息太少,很难学到稳定模式。窗口长度影响训练稳定性,如果Loss一直不降,可以试试拉长窗口到60或90。

5.3 数据泄漏:归一化和切分顺序错了

我在网上看过很多LSTM教程,都在全量数据上做MinMaxScaler,然后切分训练集和测试集。这个做法在传统机器学习里影响可能小一些,但在时序预测里是明确的bug。测试集参与了scaler的fit,等于测试集的价格区间、趋势信息在训练阶段就被模型间接知道了。

正确顺序是:先按时间切分,再在训练集上fit缩放器,用同一个缩放器transform测试集。评估时,反归一化用的Close缩放器也只能在训练集上fit。这个原则同样适用于标准化、PCA等所有需要从数据中学习参数的预处理步骤。

5.4 模型过拟合与泛化问题

金融数据噪声高,LSTM很容易过拟合。我的经验是:如果训练集Loss持续下降而验证集Loss在某个epoch后开始反弹,说明模型开始背诵训练数据。这时优先降低模型复杂度,比如LSTM单元从64降到32,或者直接删掉一层;Dropout可以提高到0.3;训练epochs不要硬拉,靠早停机制兜底。

另外,不要只看一组数据的评估结果。市场状态会变化,同一个模型在上涨趋势测试集上可能DA很高,在横盘阶段可能直接失效。稳妥的做法是把时间窗口滚动起来,比如用过去3年训练,未来3个月测试;然后滚动到下一段,重复多次,取平均指标。这样得到的评估结论才比较可信。

5.5 接口拉取失败和数据质量问题

yfinance偶尔会拉不到数据,原因包括标的代码不存在、时间范围超出数据覆盖、网络环境受限等。我的排查步骤是:先打印原始返回数据,如果df为空,检查代码是否拼写正确,尝试更换时间范围;如果仍然失败,就改从本地CSV读取。数据源本身不是这个项目的核心,代码跑通和流程正确更重要。

数据质量方面还要留意一点:部分交易日会出现极低的成交量,或价格纹丝不动的情况。这些通常来自长期停牌或者流动性极差的标的。做预测项目时尽量选择成交活跃、历史长度足够的大票,样本质量直接决定模型能不能学到有效模式。

6. 一些后续可以尝试的方向

这次项目做完之后,我尝试过几个扩展方向,简单说一下结论。一个是加更多类型特征,比如把开盘价、最高价、最低价都纳入特征矩阵,并构造高低价差、振幅、日内波动率等。特征是增加了,但模型提升并不明显,因为原始OHLC之间本身就高度相关,增加维度主要是给模型更多“观察角度”而非新信息。

另一个方向是多步预测。单步预测只能看明天,实操价值有限。可以把模型改成输出未来5天的预测结果,或者用序列到序列的方式,用过去60天预测未来5天。这样做的难点是标签构造和输出层设计,但一旦跑通就能直接用于短周期决策参考。

还有一个方向是尝试Transformer或注意力机制。注意力机制可以显式建模“过去哪些时间步对当前预测更重要”,比LSTM的隐式记忆更直观。但Transformer对数据量要求更高,在中小规模数据集上不一定比LSTM强,而且训练更耗时。如果是为了学习,完全可以玩一玩;如果是求稳,LSTM加特征处理已经够用了。

我个人在实际操作中的体会是:时序预测项目的成败,模型结构最多决定上限的一小部分,数据质量、特征构造和评估方式才是真正的分水岭。尤其是“预测滞后”这个问题,十个LSTM股票预测项目里至少有五个会栽在这一步。如果你也在做类似项目,我建议先不要急着一上来堆算力堆模型,先把特征工程做扎实,把方向准确率指标摆到桌面上,再谈优化。另外所有金融模型的效果都必须经过滚动回测和严谨评估,千万不要因为单次测试集上的数字好看就过度乐观。最后再分享一个小技巧:实验过程中把每次的特征组合、超参数和评估指标记录在一个表格里,复现和调优会高效很多,这个习惯能帮你省掉大量重复劳动。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进