拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

LSTM财务因子选股模型Python实现与回测要点解析

LSTM财务因子选股模型Python实现与回测要点解析

简介:这是一份面向高校计算机、人工智能及相关专业学生的毕业设计级源码包,聚焦LSTM财务因子预测选股模型的完整实现。项目包含7个Python脚本,覆盖LSTM分类模型、BP分类模型、MACD/RSI技术指标计算、MindGo平台模型接入与单模型预测等核心模块;同时附有checkpoint模型权重、meta/index/data文件与JSON配置,便于直接加载训练结果继续调试。压缩包共14个文件,包含py源码、模型文件、md/txt说明文档等类型,整体仅1.19MB,轻量易复现。目前已有40人学习下载。借助运行说明和项目介绍,读者可快速理解数据流向与训练流程,在此基础上修改因子组合、调整网络结构或接入自有数据,即可适配毕业设计、课程设计及科研演示;初学者也可通过源码重点掌握LSTM因子建模、BP对比模型、技术指标合成与模型持久化的典型实践。

1. LSTM财务因子预测选股模型源码(Python实现):拆开这个高分项目前你需要知道的事

如果你想找一份能直接跑、能写进毕业论文的量化选股源码,这个标题确实是常见检索里命中率很高的一个方向。它的核心不是“预测股价”,而是用财务因子(PE、ROE、营收增速这类基本面数据)构建特征,再用LSTM捕捉因子在时间序列上的变化规律,最终输出一只股票的“未来收益预测值”,按预测值排序选股。整个过程从数据拉取、因子计算、样本构造、模型训练到回测对比,都是一条完整可复现的技术路线。

这套东西能解决什么问题?最直接的是给毕业设计提供一个“深度学习+金融数据”的落地框架:不用玄学调参,不用手工打分,模型自己从因子历史里学非线性关系。它适合已经会Python基础语法、懂一点pandas和机器学习、但没系统做过时序预测项目的本科生或硕士生。源码包拿到手后,你的任务不是改几行代码就交差,而是搞清楚每一层数据是怎么流动的,否则答辩时一句“标准化为什么在截面上做”就能让你翻车。

2. 财务因子选股为什么需要LSTM:从打分逻辑到序列预测

2.1 财务因子到底指什么:四个维度与计算口径

财务因子不是随便选几个指标堆在一起。常见做法是把基本面数据分成四类:盈利能力(ROE、毛利率、净利率)、成长能力(营收同比增速、净利润同比增速)、偿债能力(资产负债率、流动比率)和营运能力(总资产周转率、存货周转天数)。每一类选两到三个指标,总共十个左右构成因子池。

这里有一个容易忽略的口径问题:财务数据是季度披露的,而且年报、季报的发布时间有滞后。用Tushare或AkShare拉数据时,接口返回的end_date是报告期截止日,不是数据真正可用的日期。比如2022年年报的end_date是2022-12-31,但公告日ann_date可能是2023年4月底。如果直接用报告期做特征,等于让模型在2022年底就“知道”了2023年4月才公布的数据,这就是典型的前视偏差,后文会专门讲。

import pandas as pd # 假设已经从数据接口拉到了财务指标宽表 fin_df # 字段包括:ts_code(股票代码), end_date(报告期), ann_date(公告日期), roe, gross_profit_margin, revenue_yoy fin_df = pd.read_csv("financial_factors.csv", parse_dates=["end_date", "ann_date"]) # 关键一步:用公告日期而不是报告期日期对齐到行情 fin_df["factor_date"] = fin_df["ann_date"] fin_df = fin_df.sort_values(["ts_code", "factor_date"]).drop_duplicates( subset=["ts_code", "end_date"], keep="last" )

这段代码的核心逻辑是建立“因子可用时间”的概念。字段用ann_date是因为只有在公告发布之后,这个因子值才真实可用;drop_duplicates的目的是防止同一报告期被重复计算。如果你在原始项目里看到直接用end_date做对齐,建议改成公告日期,这个改动能让整个实验的可信度上一个台阶。

2.2 量化选股里如何构建“未来收益标签”

有监督学习必须有标签。在选股模型里,标签通常不是“涨/跌”这种分类,而是未来N个交易日的收益率。N取多少取决于策略调仓频率:做月度调仓就用未来20个交易日,做季度调仓就按交易日换算成大约60个交易日。

构造标签时有一个细节决定模型成败:标签必须用“未来收盘价 / 当前收盘价 - 1”,如果当前特征里包含了当天收盘价,那模型其实在拿当天的信息预测当天的涨跌,这在回测里会表现为结果特别好,实盘却一塌糊涂。

# 行情数据:每只股票的日线 # 字段:ts_code, trade_date, close price_df = pd.read_csv("daily_price.csv", parse_dates=["trade_date"]) price_df = price_df.sort_values(["ts_code", "trade_date"]) # 未来20个交易日收益(月度调仓标签) price_df["ret_future"] = ( price_df.groupby("ts_code")["close"].shift(-20) / price_df["close"] - 1 ) # 合并因子与标签时,按股票代码和时间对齐 # 注意:合并必须在“因子日期”之后、且未到“未来收益截止日”之前完成

shift(-20)表示把未来第20天的收盘价搬到今天这一行,这是构造未来收益最直接的方式。做回归任务时,标签就是连续收益率;如果想做成三分类(涨、平、跌),把收益率映射到区间即可。但实践中回归任务更常用,因为模型输出的连续值可以直接用于排序选股,而不是硬切分类边界。

2.3 从因子池初筛到相关性去重

因子不是越多越好。财务因子之间高度相关,比如ROE和ROA都反映盈利能力,一起放进去会让LSTM学到重复模式,还会让模型对冗余特征过拟合。常见做法是先做相关性矩阵,把相关系数超过0.7的因子只留一个,同时剔除缺失率超过30%的因子。

# 因子初筛:计算相关系数矩阵并剔除高相关因子 corr_matrix = factor_df.corr().abs() upper_tri = corr_matrix.where( np.triu(np.ones(corr_matrix.shape), k=1).astype(bool) ) # 找出相关系数大于0.7的因子对,保留每个因子对里缺失率更低的一个 high_corr_pairs = [ (col, row) for row in upper_tri.index for col in upper_tri.columns if upper_tri.loc[row, col] > 0.7 ] drop_cols = [] for col, row in high_corr_pairs: if row not in drop_cols: drop_cols.append(col) factor_df = factor_df.drop(columns=drop_cols)

这个筛选逻辑虽然用列表推导式写的,但思路很清晰,适用于任何因子池。筛选后还需要做截面标准化和极值处理:每个交易日按所有股票的因子值做Z-score,然后用分位数截断法把极端值拉回来。不做这一步的话,市值大的银行股会因为绝对数值大而长期霸榜,模型学到的不是选股逻辑,而是市值偏好。

交叉验证的切分最重要,否则“验证集loss低、测试集loss高”这种现象会折磨你好几个晚上。

# 按时间切分,而不是随机切分 train_end = "2022-12-31" val_end = "2023-12-31" train_mask = df["date"] <= train_end val_mask = (df["date"] > train_end) & (df["date"] <= val_end) test_mask = df["date"] > val_end # 标准化参数只在训练集上拟合 from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_train_scaled = scaler.fit_transform(df.loc[train_mask, feature_cols]) X_val_scaled = scaler.transform(df.loc[val_mask, feature_cols]) X_test_scaled = scaler.transform(df.loc[test_mask, feature_cols])

这段逻辑的任何一步都不能错。fit_transform用在训练集,transform用在验证集和测试集,这是一个“时序预测里最常见的错误”,如果你在项目源码里看到fit_transform对全量数据执行,建议立刻改掉。随机打乱数据会让模型记住时间顺序里的偶然模式,而真实场景里你永远在预测未来。

3.2 滑动窗口样本是“截面的”,不是“整段的”

很多初学LSTM的人会把选股数据做成类似股价预测那种单条长序列:一只股票连续600天的行情,前500天训练后100天验证。这在选股场景里不对。选股模型需要处理的是“当前时刻所有股票的截面”,然后从中挑出未来表现最好的那一批。

正确的样本构造方式:每个样本代表“某只股票在某个时间点,过去K期的因子序列”,形状是(seq_len, num_features)。如果按月调仓,seq_len取12,含义是过去12个月的月度因子值。把每只股票的样本按时间排列,然后所有股票在同一个时间段内的样本合并成一个“截面样本集”。

class FactorSequenceDataset(Dataset): def __init__(self, X, y): self.X = torch.tensor(X, dtype=torch.float32) self.y = torch.tensor(y, dtype=torch.float32) def __len__(self): return len(self.X) def __getitem__(self, idx): return self.X[idx], self.y[idx] # 构造三维数组:样本数 × 时间步 × 特征数 # df 已按 ss_code、date 排序,feature_cols 是因子列名 def build_sequences(df, feature_cols, seq_len=12): X, y = [], [] for code, group in df.groupby("ts_code"): group = group.sort_values("date") values = group[feature_cols].values targets = group["ret_future"].values for i in range(len(group) - seq_len): X.append(values[i : i + seq_len]) y.append(targets[i + seq_len - 1]) return np.array(X), np.array(y)

这里seq_len取12,代表用12个月的因子序列预测下月收益。每个样本的标签是序列末期的未来收益,而不是序列第一天的。遍历顺序是先按股票分组再按时间排列,构建后每个样本仍然是“一只股票的一段历史”,但训练时所有股票的所有片段合并在一起参与梯度计算。

3.3 缺失值和退市股票怎么处理:两个影响结果的细节

财务因子天然有缺失:次新股没有足够历史季报,部分公司会延迟披露。处理缺失值不能直接dropna(),因为这会砍掉大量样本,而且被留下的都是老牌大公司,样本选择偏差很严重。常见做法是前向填充(用上一期值补),再用行业均值兜底。

退市股票的问题更隐蔽。如果你只拿当前还在交易的股票列表去拉历史数据,模型只见过“活下来”的公司,这在金融领域叫幸存者偏差。体现在实验里就是回测很好、实盘一买就跌。处理办法是尽量使用“上市至今全部股票列表”作为股票池,即使某只股票后来退市了,也要把它退市前的数据保留在训练集里。在答辩时主动说清楚这一点,会是个明显的加分项。

4. LSTM模型搭建:用PyTorch实现最小可跑的选股模型

4.1 模型结构:两层LSTM加回归头,为什么不在LSTM后接Softmax

选股模型的输出层应该是一个连续数值,代表预测的未来收益,而不是一个分类概率。所以这里用的是回归头,损失函数是MSE或者Huber Loss。如果用Softmax输出“涨/跌”概率,等于把问题简化成分类,会丢失收益幅度信息,排序选股时不灵敏。

import torch import torch.nn as nn class LSTMValueModel(nn.Module): def __init__(self, n_features, hidden_size=32, num_layers=2, dropout=0.2): super().__init__() self.lstm = nn.LSTM( input_size=n_features, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout, ) self.head = nn.Sequential( nn.Linear(hidden_size, 16), nn.ReLU(), nn.Linear(16, 1), ) def forward(self, x): # x shape: [batch, seq_len, n_features] out, _ = self.lstm(x) # 取最后一个时间步的隐藏状态 last_out = out[:, -1, :] return self.head(last_out).squeeze(-1)

batch_first=True这个参数经常被忽略,它决定输入张量的维度顺序是(batch, seq_len, features)而不是(seq_len, batch, features)。如果不设这个参数,后续所有数据构造都要反过来,非常容易出错。out[:, -1, :]取的是序列最后一步的隐藏状态,因为LSTM的循环结构已经把整个序列的信息压缩到了最后一步。

4.2 训练配置:学习率、早停与正则化

LSTM训练最常见的坑是梯度爆炸,尤其当seq_len较长、网络层数较多时。解决方案是梯度裁剪(gradient clipping),把梯度的L2范数截断到某个阈值内。还有学习率不能太大,1e-3起步比较安全,训练到loss平台期可以降到1e-4。

import torch.optim as optim from torch.utils.data import DataLoader model = LSTMValueModel(n_features=X_train.shape[-1]) optimizer = optim.Adam(model.parameters(), lr=1e-3) criterion = nn.MSELoss() train_loader = DataLoader(train_dataset, batch_size=256, shuffle=True) epochs = 80 for epoch in range(epochs): model.train() epoch_loss = 0.0 for xb, yb in train_loader: optimizer.zero_grad() pred = model(xb) loss = criterion(pred, yb.view(-1)) loss.backward() # 梯度裁剪:防止梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() epoch_loss += loss.item() if (epoch + 1) % 10 == 0: print(f"Epoch {epoch+1}/{epochs}, Loss: {epoch_loss / len(train_loader):.4f}")

训练时loss不下降,先别急着加层数或调学习率,检查数据是否标准化、标签是否有极端值。一个比调参更常见的问题:因子数据里如果混入了一个单位是“亿”和一个单位是“元”的字段,LSTM会优先去拟合数值大的那个维度。截面标准化能解决这个,但很多人只对训练集做了标准化,验证集却忘了。

4.3 参数边界:hidden_size、num_layers、seq_len怎么定

LSTM参数不是越大越好。财务因子数据通常样本量只有几千条,模型容量过大会直接记忆训练集,验证集表现反而更差。我给一个自己常用的起点:hidden_size=32,num_layers=2,dropout=0.2,seq_len=12。这个组合参数量不大,在小样本上不容易过拟合,跑一轮训练也很快。

如果验证集loss一直降不下来,再按顺序调整:先加大hidden_size到64看看,不行再加到128;层数加到3层通常已经是上限,4层的LSTM在财务数据上几乎必然过拟合;dropout加到0.5可以缓解过拟合,但太高会让模型欠拟合,表现为loss在训练集上也不下降。seq_len的取值跟调仓周期强相关,如果按月调仓,12个月的因子序列已经足够,没必要用60个月去追求“历史更长”,序列过长模型会学到很多久远且可能失效的模式。

# 一个稳定的小模型配置,通常不需要再往上加复杂度 model = LSTMValueModel( n_features=len(feature_cols), hidden_size=32, num_layers=2, dropout=0.2, )

批大小256在全连接网络里不算大,但LSTM的隐层状态需要跨时间步传递,batch太大反而会让收敛变慢。如果显存或内存紧张,降到128;如果样本量很大,也可以试着提到512。这些参数之间没有绝对最优,重要的是固定其他变量、一次只改一个,然后记录验证集表现。

5. 回测口径、数据泄漏排查与提分方向

5.1 未来函数是高分项目的头号杀手

现象:回测曲线漂亮得惊人,年化收益超过100%,最大回撤不到5%,但你知道真实世界里这几乎不可能。原因:代码里某个环节用了未来数据。最常见的是因子对齐用了报告期而不是公告日期,或者回测买入价用的是当天收盘价而不是次日开盘价。解决:逐行检查三个关键点——因子是否在公告日后才可用、标签是否真的对应未来、回测撮合时是否用了当日无法获取的价格。

提示:回测时买入价用“次日开盘价”是最安全的假设。用当日收盘价成交等于让模型知道了当天全部信息,这在实盘里做不到。

5.2 选股回测和组合回测是两回事

很多人把“模型预测Top20股票”直接当成回测结果展示,这不够。选股模型回测应该是一个滚动过程:在每个月末,用截至当时可用的数据预测下个月所有股票的收益,取预测值最高的前20只等权买入,持有一个月后卖出,计算这一期的组合收益。把每个月组合收益连起来,才是策略净值曲线。

# 滚动回测的核心逻辑(伪代码) monthly_dates = sorted(df["date"].unique()) results = [] for i in range(len(monthly_dates) - 1): current_date = monthly_dates[i] # 训练数据只用到 current_date 之前(且因子已公告) model.fit(X_train, y_train) # 对当前截面所有股票预测 preds = model.predict(X_current) # 取预测值最高的20只,按等权持有到下个月 top20 = get_top_n(preds, n=20) next_month_return = compute_return(top20, current_date, monthly_dates[i + 1]) results.append(next_month_return)

这段伪代码强调了一个思想:模型必须是“滚动重新训练的”,而不是训练一次然后直接用在所有未来月份上。财务因子的分布会随时间变化,固定模型在一个月后可能已经失效。滚动训练会慢很多,但它反映真实使用场景。

5.3 数据泄漏排查:标准化、标签、删除缺失值三个方向

第一个排查点是标准化。整个数据集统一fit_transform会让验证集包含训练集的统计信息,这种泄漏很难察觉但影响很大。正确做法只有训练集fit,验证集和测试集transform。

第二个点是标签构造。shift(-20)之后,最后一行的标签是NaN,如果用dropna()清除,要注意别把该保留的特征一起删掉;更隐蔽的是未来收益的计算跨越了停牌期,除权除息导致的跳空也会扭曲收益率,需要用复权价计算。

第三个点是缺失值填充顺序。如果先填充缺失值再做时间切分,填充时会用到未来信息(比如用未来某期的值前向填充到前面)。正确做法是先按时间切分,再在训练集内部做填充,验证集用训练集学到的填充值去补。

5.4 提升质量指数:分层回测代替单一TopK

单一TopK只能说明模型选的股票不错,不能证明模型具备稳定排序能力。更具说服力的做法是把全部股票按预测值从高到低分成5层(Q1到Q5),每层作为一个等权组合,分别计算下月平均收益。如果预测真有效,Q1到Q5的收益应该单调递减或递增。

df["pred_rank"] = df.groupby("date")["pred"].rank(pct=True) df["quantile"] = pd.qcut(df["pred_rank"], 5, labels=["Q1", "Q2", "Q3", "Q4", "Q5"]) # 每层月度收益 monthly_layer_return = ( df.groupby(["date", "quantile"])["ret_future"].mean().unstack() ) # 观察每层累计净值曲线,判断是否有单调性

这个检验方法的优势在于不需要换模型,只把输出从“选20只”改成“分5层”,论文里呈现出来的效果直接提升一个量级。如果分层结果完全没有单调性,说明模型学到的东西和未来的收益关系不大,需要回到因子或标签上找原因。

5.5 把“预测收益率”改成“预测排名”:更稳的优化目标

回归模型直接拟合收益率,会被极端值带偏方向。某些股票因为突发利好单月上涨50%,MSE会把大量梯度分配给这些样本,导致其他股票预测失真。一个常见替代方案是转化成排序问题:不预测具体收益率,而是预测“这只股票下个月在所有股票中的排名分位”。

这个方向有专门对应的损失函数。不展开数学细节的话,最简单的做法是把标签从收益率变换成截面排名百分位(0到1),然后依然用回归头去拟合。这样模型学到的就不是“上涨多少”,而是“相对其他股票谁更可能涨”,更贴合选股的本质目标。

# 把标签从收益率改成截面排名分位 df["ret_future_rank"] = ( df.groupby("date")["ret_future"].rank(pct=True) )

这个改动很小,但训练结果的稳定性能明显提升,尤其是当你的财务因子数据中包含少数异常暴涨暴跌的股票时。答辩时如果被问到“为什么用排名不用收益率”,这是一个很好的讨论切入点:收益率的绝对值受市场整体行情影响大,而排名是截面相对概念,天然剥离了市场Beta。

5.6 高分项目的最后一公里:写清楚边界

拿到任何一套源码之后,复现只是开始。建议按这个顺序验证项目完整性:先跑通原始代码,记录训练loss和回测指标;再独立写一个最小验证脚本,只保留10个因子和1层LSTM,确认核心链路没问题;然后逐步把代码恢复到完整状态,同时记下每一步的影响。

我的习惯是每跑一个版本就记实验表格:因子数量、seq_len、hidden_size、层数、验证集MSE、分层单调性、Top20年化、最大回撤。这组数据既是论文的实验章节素材,也能帮自己发现哪个环节最敏感。最后再针对复现过程中发现的问题,比如未来函数或幸存者偏差,特意做一次“修正前后对比”,把对比内容写进论文的讨论部分,这就是高分项目区别于普通课程设计的核心差距。

说到底,LSTM选股模型的分数不在模型结构多复杂,而在数据流水线是否干净、回测口径是否可信、边界条件是否说清楚。把这几件事做实了,哪怕模型本身是两层LSTM加一个线性头,也足够撑起一篇优秀毕业设计。如果你能在学习率、seq_len和分层回测这几个细节上多花两天时间反复验证,收获会比单纯换一个更大规模的模型多得多。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表