LSTM财务因子选股模型实战:数据防泄漏与回测验证全解析

发布时间:2026/10/12 12:56:20
LSTM财务因子选股模型实战:数据防泄漏与回测验证全解析 简介面向高校计算机、金融科技等相关专业学生的Python毕业设计/课程设计高分源码基于LSTM神经网络构建财务因子预测选股模型同时附带BP网络与MACD/RSI技术指标实现可完成从财务数据处理、模型训练到选股信号输出的完整流程。压缩包共14个文件包含7个Python脚本提供LSTM/BP模型类定义、训练及单模型预测脚本预训练好的TensorFlow checkpoint也随包附上另有项目介绍文档、运行说明文本等辅助材料整体大小仅1.19MB便于快速下载与本地复现。项目已有40人学习适合用于课程设计、毕业设计或量化选股入门研究。读者拿到后可直接加载现成模型观察LSTM对财务因子的预测效果也可在此基础上修改特征工程或网络结构扩展成自己的选股策略对运行配置不熟悉的初学者还可获得远程指导与技术支持。1. 拆开「毕业设计-LSTM财务因子预测选股模型源码」它在解决什么拿到这个压缩包的人多半不是想看论文摘要而是想让这份高分项目在自己机器上跑起来再改成能交差、能答辩的版本。标题里三个词正好对应三个环节LSTM 是预测模型“财务因子”是喂给模型的输入特征“预测选股”是最终要落地的动作。用一句话讲清楚项目用 Python 读入上市公司的季度财报数据把 ROE、毛利率、负债率这些结构化数据整理成特征让 LSTM 学习“过去几个季度的财务轨迹与未来收益的关系”最后按预测值给股票排序、选出持仓。它适合两类人一类是做金融、会计与计算机交叉方向毕设的学生需要一套完整可复现的数据清洗—模型训练—回测验证流程另一类是刚入门的量化从业者想看看 LSTM 在基本面数据上的真实效果。一个反直觉的结论先放在前面财务数据一年只有四个季度截面样本量喂不饱大网络LSTM 未必跑赢线性模型——但这个项目真正的价值不在收益率而在“整套能写进论文的流水线”。2. 数据先于模型财务因子口径、标签定义与时间切片拿到任何选股源码我不会急着跑训练而是先打开数据文件看三样东西因子有哪些、标签怎么算、时间怎么切。这三件事任意一件出错后面模型效果全是假象。不少看起来“高分”的源码恰恰是在数据这一层动了手脚最常见的手法是用未来信息构造标签让回测曲线异常漂亮。所以这一章先把数据链路钉死再谈模型。2.1 财务因子从哪里来三大报表字段映射与常见因子清单财务因子的原始来源是上市公司定期报告资产负债表、利润表、现金流量表。和分钟级量价因子不同这些数据是季度频率天然带有“披露滞后”——报表上的数字不是当天就能拿到的。下表是我在类似项目里默认保留的一组因子覆盖盈利、成长、杠杆、现金流四个维度。因子计算口径报表来源用于捕捉ROE净利润 / 股东权益资产负债表 利润表盈利质量毛利率(营收 - 营业成本) / 营收利润表盈利空间营收同比增速本季营收 / 上年同季 - 1利润表成长性净利同比增速本季净利 / 上年同季 - 1利润表成长质量资产负债率总负债 / 总资产资产负债表杠杆风险经营现金流/营收经营现金流净额 / 营收现金流量表利润含金量流动比率流动资产 / 流动负债资产负债表偿债压力实际操作里我不会把表里所有因子都塞进去一般留 8 到 15 个就够。原因很简单财务因子是季度频率公司之间差异大加再多因子也改变不了样本量上限。更值得花时间的是统一口径。同一个 ROE在不同数据源里可能是加权 ROE、摊薄 ROE 或扣非 ROE如果源码的数据文件换来换去一定要先确认口径一致否则模型学到的根本不是同一套语义。2.2 标签计算公式回归未来收益还是分类跑赢基准标签决定了模型学什么也决定了答辩时怎么讲。常见做法有三种我把它们的差异列在下面。标签类型标签定义输出层评价指标答辩友好度回归y 未来20日收益率Dense(1) 无激活IC / MSE中二分类y 未来20日是否跑赢等权基准Dense(1, sigmoid)准确率 / AUC高有序分类y 按未来收益降序分5档Dense(5, softmax)分组单调性中高毕设场景我强烈建议二分类。一是“跑赢还是跑输”这个问题导师最容易听懂二是二分类能绕开回归任务的极端值问题——万一某只股票 20 天翻倍回归损失会被这一个点拉爆分类只要它跑赢基准就算正样本。下面是最小可用的标签构造代码注意错位方向import pandas as pd # 构造「未来20日超额收益」标签 df df.sort_values([stock_code, report_date]) # 披露滞后统一按报告期后4个月作为因子可用日期 df[publish_date] df[report_date] pd.DateOffset(months4) # 行情对齐后取未来20个交易日的累计收益 df[future_ret] df.groupby(stock_code)[close].transform( lambda s: s.shift(-20) / s - 1 ) # 基准取同期全市场等权收益label1 代表跑赢 df[label] (df[future_ret] df[bench_ret]).astype(int)代码逻辑不复杂但 shift(-20) 的方向最容易被改错。翻车的源码经常写成 shift(-1) 或 shift(20)shift(-1) 把“下一天收益”当标签等于把预测周期改成了日内噪声shift(20) 则是把未来收益挪到了过去属于标准的未来函数。publish_date 这一列先算出来后面所有因子都按它对齐就能从源头挡住大部分泄漏。2.3 训练集、验证集、测试集怎么切滚动时间窗与防未来函数财务结构化数据不能用随机打乱切分。同一季度的样本高度相关随机切会让验证集和训练集互相“偷看”信息。正确做法是按时间滚动切分训练用前 5 到 6 年验证用紧接着的 1 年测试用再往后 1 年然后窗口逐年前移。这样做还有个好处——和实盘场景一致因为模型本来就是在用历史预测未来。# walk-forward 切分示意 years sorted(df[report_year].unique()) results [] for test_year in range(2017, 2024): train_years [y for y in years if (y test_year - 6) and (y test_year - 1)] val_year test_year - 1 train df[df[report_year].isin(train_years)] val df[df[report_year] val_year] test df[df[report_year] test_year] # build_sequences 和 scale_on_train 在下一章给出完整实现 X_train, y_train build_sequences(train, feature_cols, time_steps8) X_val, y_val build_sequences(val, feature_cols, time_steps8) X_test, y_test build_sequences(test, feature_cols, time_steps8)这里 report_year 取的是“报告期年份”而不是自然年份防止把尚未披露的数据混进特征。如果整体样本量偏少611 可以缩成 511但不要小于 41否则训练期覆盖不到一个完整的市场风格周期。提示报告期是财务数据的“截止时间”不是“可用时间”。统一滞后 4 个月是这类项目最常用的保守口径别省。3. LSTM读的不是K线是因子面板张量构造与最小可跑模型把“财务因子预测”和“LSTM 时间序列预测”放一起最常见的一个错误是把 LSTM 当黑匣子直接把二维表丢进去。LSTM 的输入必须是三维张量——样本数量、时间步数、特征数量。在 Python 里做这类 LSTM 时间序列预测TensorFlow/Keras 是最省事的实现方式下面从原理讲到代码。3.1 为什么把因子面板当序列门控机制与低频数据的边界LSTM 和普通神经网络最大的差别在记忆机制遗忘门决定要不要保留上个季度的 ROE 信息输入门决定这个季度净利增长有多少值得记下来输出门决定当前预测要看哪些历史特征。放到选股场景里它建模的是“同一只股票的财务轨迹”——连续 8 个季度毛利率下滑和本季度毛利率单季反弹对未来 20 日收益的含义完全不同只有把数据当序列读这个差异才进得了模型。但边界必须说清楚财务数据一年只有 4 个点5 年窗口也就 20 个点市场风格还在不断切换。这类 LSTM 本质上更接近“带记忆的逻辑回归”而不是图像任务里的深度模型。所以不要照搬大网络的配置hidden_size 从 16 或 32 起步就够了。序列长度也是一样——宁可短不要长8 个季度通常比 20 个季度更稳。3.2 把 DataFrame 切成三维张量build_sequences 与归一化坑点切序列时要按股票分组再对每只股票做滑窗。窗口长度 time_steps 表示“用过去几个季度预测本季度”8 就是两年。财务数据有缺失很常见比如新股没有同比指标处理时不要用全样本均值填充那会用到未来信息常见做法是用该股票自身的历史均值逐步填充。import numpy as np def build_sequences(df, feature_cols, time_steps8): 按股票分组把每条记录展开成 (time_steps, n_features) 的窗口。 xs, ys, metas [], [], [] for code, grp in df.groupby(stock_code): grp grp.sort_values(report_date) # 用该股截至当前的历史均值填充避免序列断裂 grp[feature_cols] grp[feature_cols].fillna( grp[feature_cols].expanding().mean() ) for i in range(time_steps, len(grp)): # 输入是 i-8 到 i-1 季度标签是第 i 季度 xs.append(grp[feature_cols].values[i - time_steps:i]) ys.append(grp[label].values[i]) metas.append((code, grp[report_date].values[i])) return np.array(xs), np.array(ys), metas返回的张量 shape 是 (样本数, 8, 因子数)。这里的样本数等于所有股票“(季度数 - 8)”之和。接下来是归一化这是本科毕业设计里翻车率最高的一步from sklearn.preprocessing import StandardScaler def scale_on_train(X_train, X_val, X_test): 只允许在训练集上 fit验证测试集只做 transform。 n_feat X_train.shape[-1] scaler StandardScaler() scaler.fit(X_train.reshape(-1, n_feat)) X_train_s scaler.transform(X_train.reshape(-1, n_feat)).reshape(X_train.shape) X_val_s scaler.transform(X_val.reshape(-1, n_feat)).reshape(X_val.shape) X_test_s scaler.transform(X_test.reshape(-1, n_feat)).reshape(X_test.shape) return X_train_s, X_val_s, X_test_sreshape 展开时是按行优先变回三维时保持原 shape 即可。要记住的是scaler 只能 fit 训练集。一旦对全样本 fit验证集和测试集的均值标准差就被训练期“看见”了后面所有评估指标都会虚高。注意三维张量的维度顺序是 (样本数, 时间步, 特征数)很多人习惯性把特征放第二维模型能跑但结果全错。3.3 最小可复现的 LSTM 模型25 行 Keras 核心代码有了三维张量模型本身其实很短。下面这个结构我一般作为起点两层 LSTM 加 Dropout最后接一个薄的全连接层。样本量小所以隐层单元压到 32 和 16。from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout from tensorflow.keras.callbacks import EarlyStopping model Sequential([ # return_sequencesTrue 才能继续堆第二层 LSTM LSTM(32, return_sequencesTrue, input_shape(time_steps, n_features)), Dropout(0.3), LSTM(16, return_sequencesFalse), Dropout(0.3), Dense(4, activationrelu), Dense(1) # 回归输出二分类时改成 Dense(1, activationsigmoid) ]) model.compile(optimizeradam, lossmse, metrics[mae]) early_stop EarlyStopping(monitorval_loss, patience5, restore_best_weightsTrue) model.fit(X_train_s, y_train, validation_data(X_val_s, y_val), epochs100, batch_size64, callbacks[early_stop], verbose1)参数里最值得调的是三个hidden_size 先从 32 看起过拟合就把两层都降到 16不要一上来就 128Dropout 从 0.3 起步验证集 loss 反弹就加到 0.5patience5 的意思是 val_loss 连续 5 个 epoch 不下降就停restore_best_weights 保证拿回最优权重而不是最后一轮。batch_size64 在几百个样本里看起来偏大但其实没问题因为每个样本本身是 8×n_features 的序列梯度估计足够稳。如果样本量特别少降到 16 也行。二分类场景下只需把 Dense(1) 换成 sigmoid 激活loss 换成 binary_crossentropy其余代码原样不动。4. 从训练到选股滚动训练、横截面排序与回测指标模型训出来只是第一步后面三个操作才决定这个项目是“能跑”还是“能用”滚动训练、截面排序、分组回测。很多源码只给到 model.fit 就结束恰恰把最有说服力的部分藏了起来。4.1 滚动训练而非一次训练walk-forward 的代码骨架财务因子的统计分布会漂移。2015 年高杠杆的股票到 2018 年跌得最惨2021 年高增长的逻辑到 2023 年也不再奏效。如果你只在 2010 到 2015 年训练一次直接预测 2023 年效果大概率是负的。所以要用 walk-forward训练集 5 年、验证集 1 年、测试集 1 年逐年滑动。# 在 2.3 的窗口循环里把模型训练收进一个函数 def run_one_window(train, val, test, feature_cols, time_steps8): X_train, y_train build_sequences(train, feature_cols, time_steps) X_val, y_val build_sequences(val, feature_cols, time_steps) X_test, y_test build_sequences(test, feature_cols, time_steps) X_train, X_val, X_test scale_on_train(X_train, X_val, X_test) # 每个窗口重新初始化模型避免上个窗口的权重泄漏 model build_lstm(time_steps, len(feature_cols)) model.fit(X_train, y_train, validation_data(X_val, y_val), epochs80, batch_size64, callbacks[EarlyStopping(monitorval_loss, patience5)], verbose0) return model.predict(X_test), y_test, X_test每一步的 scaler 都要重新 fit因为训练集变了均值和方差也跟着变。另一个容易踩的点是模型复用不要在一个循环里反复 model.fit 同一个实例每个窗口都重新 build 一个新模型否则上一个窗口学到的权重会影响下一个窗口的预测。4.2 预测值变成持仓横截面排序、ST过滤与固定只数拿到测试期的预测分数后不能直接在全局比大小。不同年份的财务因子量纲和分布都在变直接比绝对分数等于把某一年所有股票全部选出来。正确做法是在每个报告期截面内做百分位排名再取头部。# test_df 是测试期的原始特征、预测值、report_date 和辅助信息 test_df[pred] preds test_df[rank] test_df.groupby(report_date)[pred].rank(pctTrue) # 剔除明显不能买的标的 keep ( (~test_df[name].str.contains(ST)) (test_df[list_days] 250) # 上市满一年 (~test_df[is_suspended]) # 非停牌 ) test_df test_df[keep] # 每个截面取预测最强的 10 只固定只数便于回测和展示 positions (test_df.sort_values(rank, ascendingFalse) .groupby(report_date) .head(10))注意 test_df 的 pred 列要与原始行一一对应。build_sequences 返回的 metas 里有 (stock_code, report_date)真实项目中要按这两个字段把预测值回填到原表而不是直接按顺序赋值否则一旦某个股票被提前过滤掉顺序就错位了。换仓周期和标签周期要对齐。标签用的是未来 20 个交易日收益那么每 20 个交易日左右换一次仓如果按 report_date 分组生成持仓正好和季度财报披露节奏一致。实际操作还要加上交易成本假设双边按千分之三左右扣费不然回测收益普遍虚高。4.3 验证不看净值曲线先看 IC 与分层单调性很多毕设把回测净值曲线放在第一位这是典型误区。净值受市场 Beta 影响太大选股模型更应该看的是“预测排序和实际收益排序是否一致”也就是 IC 和 RankIC。from scipy.stats import spearmanr ic_by_date [] for date, grp in test_df.groupby(report_date): ic spearmanr(grp[pred], grp[future_ret]).correlation ic_by_date.append((date, ic)) ic_df pd.DataFrame(ic_by_date, columns[date, ic]) print(mean IC:, ic_df[ic].mean()) print(ICIR:, ic_df[ic].mean() / ic_df[ic].std()) print(IC0占比:, (ic_df[ic] 0).mean())在 A 股财务因子里mean IC 能到 0.03 到 0.05 就已经是可用的水平ICIR 大于 0.5 说明预测能力稳定。IC 只是一个数字答辩时更有说服力的是分组回测的单调性按预测值把股票分成 5 组看每组平均未来收益是否从 G1 到 G5 递减。test_df[group] pd.qcut(test_df[pred], 5, labels[G1, G2, G3, G4, G5], duplicatesdrop) group_ret (test_df.groupby([report_date, group], observedTrue) [future_ret].mean().unstack()) print(group_ret.mean())如果预测值大量重复qcut 会抛边界错误加 duplicatesdrop 可以避免。看结果时重点不是 G1 收益有多高而是 G1 到 G5 是否单调G1 最高、G5 最低、中间依次过渡这才说明模型真的学到了排序信息而不是靠运气押中一两波行情。5. 避坑复现「LSTM财务因子选股」源码最容易翻车的四个位置这一章我按翻车率从高到低列四个位置每个都是真实项目里反复出现的坑。源码能跑通不代表没踩坑很多时候坑藏在你看不见的数据拼接里。5.1 未来函数藏身点财报披露时滞没做因子“提前知道答案”现象训练集 AUC 高到 0.95验证期 IC 接近 0.1选出的股票总在财报发布前就开始拉升。原因源码直接拿 report_date 当因子可用日期没考虑财报实际公布时间。3 月 31 日的年报数据最早也要 4 月底才公开你用 3 月 31 日当天的价格预测收益等于让模型偷看了答案。解决统一按报告期推迟 4 个月作为可用日期一季报 5 月、半年报 9 月、三季报 11 月、年报次年 4 月再把持仓记录的交易日期改到可用日期之后。5.2 归一化的全样本泄漏scaler 在切分之前就 fit 了现象模型在训练集、验证集、测试集上表现都好一换新数据就崩。原因StandardScaler 在整个数据集上 fit 了验证期和测试期的均值方差被训练期“看过”标准化后的分布互相泄漏评估指标全是水分。解决先按时间切窗口再只在训练集上 fit验证和测试只做 transform。前面 3.2 的 scale_on_train 就是为这件事写的每次滚动窗口都要重新执行一遍。5.3 低频样本上的过拟合训练损失走低测试期选股跑输基准现象train loss 降到接近 0val loss 先降后弹选出的 10 只股票平均跑输基准 5 个点。原因财务因子一年只有 4 期样本量撑不起大网络很多人把卷积网络的那套配置直接搬过来模型容量远大于数据容量。解决LSTM 单元数降到 16/32Dropout 提到 0.4—0.5EarlyStopping 的 patience 取 3—5。再加一个对照组用逻辑回归或随机森林跑完全相同的因子和标签如果对照组的 IC 和你差不多说明 LSTM 并没有学到额外的时序结构论文里如实写反而显得严谨。5.4 源码里的“注水”标签shift(-1) 还是 shift(-20)现象某份源码回测年化 50% 以上但你顺着数据文件核对发现 future_ret 是用 shift(-1) 算的——标签周期根本不是 20 日而是 1 日。原因原始作者图省事把相邻日收益当成了标签周期也有个别项目故意错位让回测好看。解决拿到源码先打印一只股票在时间轴上的前 20 行人工核对 report_date、close、future_ret 三列的对齐关系。sample (df[df[stock_code] 000001] .sort_values(report_date).head(20)) print(sample[[report_date, close, future_ret, label]])如果 report_date 是 2023-09-30future_ret 对应的是该日期之后 20 个交易日的累计收益说明对齐正确如果第一行的 future_ret 非零且日期只差一天这份数据就可以直接丢掉了。这个检查只要两分钟但能劝退一大批“高分源码”。6. 让这个毕设真的能答辩从“能跑”到“能讲”的三步验证法模型跑通只是开始答辩时导师最常问的三个问题是为什么用 LSTM、结果怎么验证、参数怎么定的。提前准备好三步就能把“我会调参”变成“我验证过”。第一步做基线对照。用逻辑回归和随机森林跑同一份因子、同一个标签。如果 LSTM 的 IC 比基线高不到 0.01论文里就写“LSTM 的门控结构在同口径下并未显著优于线性模型但提供了时间维度的特征交互视角”。这不是辩解是陈述事实导师反而会觉得你做了扎实的消融。第二步打分组净值。把预测值分 5 档画一张“G1 到 G5 平均未来收益”的柱状图再画一条“G1 减 G5 多空组合”的累计净值曲线。答辩时直接指给导师看预测值最高的组收益最高最低的组收益最低单调性成立说明模型学到了排序信息净值高低反而是次要的。第三步做参数敏感性表格。把 time_steps 设成 4、8、12hidden_size 设成 16、32、64Dropout 设成 0.2、0.3、0.5各跑一遍验证期 IC结果列成小表格。最后标注你选的那组参数是在验证集上 IC 最高的一组。这个表格比“我调了很久的参”有说服力得多。答辩前一天再跑一遍这条自查命令把整条链路的时间戳头尾打印出来for col in [report_date, publish_date, future_ret]: print(col, df[col].min(), df[col].max())上面三列的最小值和最大值在时间上必须是递增衔接的如果 future_ret 的时间范围早于 publish_date就说明标签和特征错位了。我自己最早做类似项目时把披露滞后漏掉了回测夏普高得离谱答辩时被问了一句“你的持仓为什么总在季度末生效”当场答不上来。后来我养成了个习惯先把每一列的时间戳理顺再谈模型效果这个顺序帮我避开了大多数数据陷阱。希望这篇能帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询