
简介毕业设计专用的LSTM财务因子预测选股模型源码包基于Python实现适合计算机、AI、通信、自动化、电子信息等专业学生完成毕业设计、课程设计或课内作业也可作为量化金融与深度学习入门者的进阶练习。包内共14个文件主体为7个py脚本覆盖LSTM与BP两类模型、MACD/RSI技术指标及MindGo平台调用等模块另有项目介绍md、运行说明txt、模型权重ckpt(index/meta/data)和配置json结构清晰便于对照复现、修改拓展。压缩包仅1.19MB下载与部署成本低目前已有40人学习下载。通过完整源码、训练好的模型文件及说明文档读者可直观了解财务因子预测选股的建模流程快速跑通演示环境并可在现有基础上调整参数、增加因子或接入其他数据源延伸为更完整的毕业设计方案配合项目介绍与运行说明能帮助快速定位关键代码、减少环境配置与调试障碍。1. LSTM财务因子预测选股模型源码外行看模型内行先看数据“毕业设计-LSTM财务因子预测选股模型源码Python实现高分项目.zip”这个标题在量化毕设方向里几乎人手一份。但很多人解压后看两眼LSTM网络就觉得稳了一跑回测才发现选出来的股票根本买不进或者收益率高得像做梦。原因很统一数据对齐出了问题标签里混进了未来信息。LSTM在这里并不是黑匣子它读的是财务因子在过去一段时间的轨迹真正决定项目能不能拿高分的是数据怎么来、标签怎么造、回测怎么防作弊。这个方向适合两类人一是金融工程、计算机、应用统计方向做毕业设计的学生二是刚学完LSTM时间序列预测 python 就想写量化交易策略代码的开发者。前者需要一套能自圆其说的完整流程后者需要一套不虚高的验证方法。下面我按拿到这类源码项目后最常用的落地路径从数据、因子、滑窗、训练到回测把链路讲清楚。2. 财务因子数据先搞定数据源、字段对齐与缺失值处理一次完整的LSTM选股实验依赖两类数据日频行情和低频财务指标。行情数据的坑比较少财务指标才是重灾区。因为财务报告按季度披露而行情每天都有两者合并时只要日期口径错一天模型就可能“看到未来”。2.1 常见免费数据源与因子选择我一般会先用不依赖token的免费接口把主流程跑通等到正式回测再考虑更高频的数据。常见做法是用 akshare 获取财务摘要接口字段在不同版本里略有差异但核心返回值都包含公告日期、报告期和各项财务指标。安装命令是 pip install akshare。如果你手头有 tushare pro 的积分也可以直接拉取 income、balancesheet、cashflow 三类表逻辑完全一致只是多一步自己拼接因子。import akshare as ak import pandas as pd # 获取全部A股代码过滤掉ST和上市不足一年的股票 stock_info ak.stock_info_a_code_name() stock_info stock_info[~stock_info[name].str.contains(ST)] # 用第一个股票代码试探财务摘要的返回结构 sample ak.stock_financial_abstract(symbolstock_info.iloc[0][code], indicator按年度) print(sample.columns) print(sample.head())这段代码的作用是把数据源先摸一遍重点确认两列ann_date公告发布日期和 end_date报告期截止日。后面谈到的防未来函数完全围绕这两列展开所以第一步就要把它们区分清楚。stock_financial_abstract 返回的指标列通常是长篇中文名读进来之后最好手动重命名成 roe、gross_margin、debt_ratio 这类英文短名方便后续直接拼特征矩阵。因子选择上我不建议把几十个财务字段一股脑塞进LSTM。常见做法是覆盖盈利、杠杆、现金流、成长四个维度各取一到两个代表因子计算口径选入理由roe净利润 / 净资产盈利能力的核心指标gross_margin(营收 - 营业成本) / 营收盈利质量区分靠降价走量的公司debt_ratio总负债 / 总资产杠杆风险过高容易暴雷ocf_to_revenue经营现金流 / 营业收入利润含金量防止“有利润没现金”revenue_growth营收同比增速成长性是市场定价的重要锚这几个因子加起来的逻辑正好覆盖质量、价值和成长三类常见选股风格数量控制在五六个后期不容易过拟合。LSTM不是因子越多越好的模型维度翻倍后参数量跟着涨训练数据量不变的情况下过拟合风险会明显上升。2.2 用公告日对齐财报与行情别让未来数据穿进模型这里是我最想强调的一个点。很多毕设源码里写的是# 错误示范千万不要这么写 df df.merge(daily, left_onend_date, right_ontrade_date)end_date 是报告期截止日例如2024年3月31日的一季报可能拖到2024年4月28日才公布。如果按 end_date 对齐等于让模型在3月31日就看到了4月28日才公开的财报这就是教科书级别的未来函数。正确做法是用 merge_asof 按公告日向前合并# daily_factor最终生成的日频因子宽表 # daily日期、代码、收盘价必须用前复权价 # factorsann_date、代码、各财务因子 merged pd.merge_asof( daily.sort_values(trade_date), factors.sort_values(ann_date), left_ontrade_date, right_onann_date, bycode, directionbackward )merge_asof 的 directionbackward 表示对每一个 trade_date找到小于等于该日期的最近一条公告日数据。我习惯在合并后把因子再“缓一天”原因是A股财报很多在收盘后才披露你在当天收盘前不一定能按这份数据下单隔一天使用更贴近真实可交易状态。这个细节不会让源码看起来多高级但会让回测结果在答辩时经得起追问。合并后的表里一定还有缺失值。原因包括新股上市不足一个季度没有历史财报、停牌期间没有行情、部分公司财报披露延迟。处理方式用前向填充但要限制最大填充长度避免用一年前的因子给当前行情定价merged[roe] merged[roe].ffill(limit63) # 约一个季度的交易日数 merged[debt_ratio] merged[debt_ratio].ffill(limit63) merged merged.dropna(subset[roe, gross_margin, debt_ratio])ffill(limit63) 的含义是只允许因子在缺失后继续向后沿用最多63个交易日超过这个长度说明财报已经严重过期直接放弃这些样本。最后用 dropna 把上市初期没法算因子的样本剔除掉。处理完后一定要打印 merged 的日期范围、股票数量和缺失率这三个数字能看出数据通道是否健康。3. 把财务因子改造成LSTM的输入序列滑窗结构、标准化与样本划分财务因子表是截面数据LSTM吃的是时间序列。这一步要做的是把每一只股票的历史因子轨迹切成一帧一帧的样本并在划分训练集和验证集时避免时间顺序被破坏。3.1 滑窗结构为什么把预测目标设成未来20个交易日的收益率LSTM的输入形状是 (batch_size, seq_len, feature_dim)feature_dim 对应上面选的五六个财务因子seq_len 是回看多少个交易日。我常用的默认值是序列长度20、预测周期20也就是“看过去一个月的财务因子轨迹预测未来一个月的收益”。预测目标不建议用单日收益率。单日收益的噪声极大价格中的随机成分会淹没因子信号模型训练完基本上预测值全部贴在零附近。未来20日收益的计算方式是import numpy as np def create_lstm_samples(df, feature_cols, seq_len20, pred_horizon20): 把单只股票的因子序列切成滑窗样本 X, y, sample_dates [], [], [] feature_data df[feature_cols].to_numpy() close_data df[close].to_numpy() dates df[trade_date].to_numpy() for i in range(seq_len, len(df) - pred_horizon): X.append(feature_data[i - seq_len:i]) # 用未来第20个交易日的收盘价算持有收益 ret close_data[i pred_horizon] / close_data[i] - 1 y.append(ret) # 样本的“形成日”是当前交易日而不是未来标签日 sample_dates.append(dates[i]) return np.array(X), np.array(y), np.array(sample_dates)这段代码里我特别留了 sample_dates它是每个样本形成时对应的时间戳。后面划分训练集和验证集全靠它如果漏了这一步你就只能用行号硬切很容易把时间边界切乱。参数上seq_len 的选择和调仓频率直接相关。做月度调仓就选20个交易日做周度调仓可以缩短到5或10。pred_horizon 太长会导致每个样本的标签大量重叠训练集和验证集之间互相泄漏信息太短又会让标签噪声太大。初次尝试建议锁死20不要一上来就同时调多个参数。3.2 滚动标准化与时间顺序切分两个必做的动作财务因子的量纲完全不同roe 可能只有0.1营收增速却是0.5甚至1.2不标准化直接进LSTM会让数值大的特征主导梯度。但标准化不能对整个数据集一次性做。一个常见的翻车写法是from sklearn.preprocessing import StandardScaler # 错误示范整个数据集一起 fit验证集用了未来统计量 scaler StandardScaler() all_features scaler.fit_transform(all_features)这样做等于让训练阶段知道了验证集和测试集的均值、方差属于隐式信息泄漏。正确的做法是滚动标准化def rolling_zscore(df, cols, window120, min_periods60): 按每只股票的时间窗口做滚动标准化避免未来统计量泄漏 df df.copy() for col in cols: mean df.groupby(code)[col].transform( lambda x: x.rolling(window, min_periodsmin_periods).mean() ) std df.groupby(code)[col].transform( lambda x: x.rolling(window, min_periodsmin_periods).std() ) df[col _z] (df[col] - mean) / (std 1e-8) return df这里每只股票各自做120个交易日的滚动窗口只有至少60个有效值时才计算z-score。加 1e-8 是防止某只股票价格长期不动导致标准差为零除以零之后整列变NaN训练直接崩掉。样本划分也必须按时间而不是随机打散。LSTM滑窗样本之间高度重叠如果随机切分训练集最后一个样本和验证集第一个样本会共享大半段行情数据验证指标会虚高到失去参考意义。我一般按60%、20%、20%切分样本形成日train_cut np.quantile(sample_dates, 0.6) val_cut np.quantile(sample_dates, 0.8) train_mask sample_dates train_cut val_mask (sample_dates train_cut) (sample_dates val_cut) test_mask sample_dates val_cut还有一个容易忽略的细节训练集末尾和验证集开头之间要留出至少一个 pred_horizon 的空隙因为训练集最后一天的标签用到了第20天后的行情而这些行情数据恰好落在验证集开头。解决办法是切分时在边界两侧各丢掉20个样本工程上叫 embargo属于做时间序列预测的基本操作。4. 训练LSTM选股模型最小可复现配置网络结构、损失函数与训练循环LSTM选股模型本质上是一个回归模型输入一段历史因子序列输出一个对未来收益的预测值。网络结构不需要复杂复杂了反而训练不稳、解释不清答辩时容易被问倒。4.1 把因子序列喂进LSTM网络结构怎么选我用PyTorch实现一个两层的LSTM加一层全连接输出。之所以不用单层是因为财务因子的变化比较平滑单层LSTM学不到“短期趋势变化”的抽象特征三层以上则参数量变大训练数据只有几百只股票几年的日频样本很容易过拟合。import torch import torch.nn as nn class FactorLSTM(nn.Module): def __init__(self, feature_dim, hidden_size64, num_layers2, dropout0.3): super().__init__() self.lstm nn.LSTM( input_sizefeature_dim, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropoutdropout ) self.head nn.Sequential( nn.Linear(hidden_size, 32), nn.ReLU(), nn.Linear(32, 1) ) def forward(self, x): out, _ self.lstm(x) # 取最后一个时间步的隐状态作为整段序列的表示 return self.head(out[:, -1, :])hidden_size 为什么取64财务因子的维度只有六到八个信息量远小于图像或文本hidden_size 32也能跑但64在多数数据量下表现更稳。dropout取0.3是个折中0.5在因子数据上有时会把信号也丢掉。网络前向传播里取 out[:, -1, :]意思是只保留序列最后一个时间步的隐状态再经过全连接层输出预测值。这里不需要做softmax因为我们要预测的是连续收益率而不是分类概率。模型参数量很小总参数大约在 hidden_size 乘上四倍的特征维度和层数之间约两万个参数。如果训练集的样本数少于十万就要把 hidden_size 降到32或者加大 dropout。判断过拟合最简单的办法是看验证集IC和训练集loss的走势验证集IC连续多轮不升而训练loss还在降多半是模型容量给大了。4.2 损失函数、优化器与早停让训练不跑偏损失函数我不用纯MSE。股票收益率的分布有厚尾少数极端大涨大跌样本会把 MSE 拉爆模型为了拟合这些离群点会牺牲大部分普通样本的预测精度。常见做法是用 Huber Loss它对小误差按平方惩罚、大误差按线性惩罚比MSE稳健import torch.nn.functional as F def huber_loss(pred, target, delta1.0): return F.smooth_l1_loss(pred, target, betadelta)训练循环里除了常规的反向传播和参数更新我还会加上梯度裁剪。LSTM在时间步上展开后梯度很容易爆炸一次异常的大梯度会把训练好的权重全部冲掉这种“训练到一半突然变NaN”的情况在RNN系模型里非常常见epochs 50 lr 1e-3 weight_decay 1e-5 best_ic -1.0 patience 10 no_improve 0 optimizer torch.optim.Adam(model.parameters(), lrlr, weight_decayweight_decay) for epoch in range(epochs): model.train() train_losses [] for xb, yb in train_loader: # 丢掉标签为NaN的样本避免loss变成NaN mask torch.isfinite(yb) if mask.sum() 2: continue xb, yb xb[mask], yb[mask] optimizer.zero_grad() pred model(xb).squeeze() loss huber_loss(pred, yb) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() train_losses.append(loss.item()) val_ic evaluate_ic(model, val_loader) if val_ic best_ic: best_ic val_ic torch.save(model.state_dict(), best_factor_lstm.pt) no_improve 0 else: no_improve 1 if no_improve patience: print(fepoch {epoch}: early stop, best_ic{best_ic:.4f}) breakevaluate_ic 里使用信息系数 IC 作为验证指标。IC 是预测值与真实收益之间的秩相关系数常取 Spearman 相关系数。因为选股关注的是相对排名所以用秩相关比用皮尔逊相关更贴合实际from scipy.stats import spearmanr def evaluate_ic(model, loader): model.eval() preds, targets [], [] with torch.no_grad(): for xb, yb in loader: pred model(xb).squeeze() preds.append(pred.cpu()) targets.append(yb.cpu()) preds torch.cat(preds) targets torch.cat(targets) mask torch.isfinite(targets) torch.isfinite(preds) if mask.sum() 5: return -1.0 ic, _ spearmanr(preds[mask].numpy(), targets[mask].numpy()) return ic if np.isfinite(ic) else -1.0训练时我习惯每五轮打印一次训练loss和验证集IC。如果训练loss很低但IC在0附近震荡说明模型只记住了训练集的噪声分布回测成绩基本靠运气。正常情况下因子有效的模型IC在0.03到0.08之间别追求那种IC超过0.1的神话数字那多半是数据泄漏的产物。5. 避坑让LSTM财务因子模型集体翻车的五个典型问题这一章写我翻过车或者帮别人排查过的五个高频问题。每一个都按现象、原因、解决的顺序讲排查时照着顺序对一遍能省下一个星期的时间。5.1 财务数据日期穿越回测胜率接近100%现象训练完模型后在验证集上打分选出来的股票胜率高达80%甚至更高收益率曲线几乎单边向上。很多同学看到这种结果会以为是模型牛其实是数据日期穿越了。原因源码里用 end_date 和 trade_date 做合并模型在3月31日就看到了4月底才公布的财报。LSTM在重复实验中总能“提前”抓住财报利好每次都能赶在消息落地前进场实际用过去的数据做预测时不可能有这种效果。解决把所有合并逻辑统一改成按 ann_date 对齐并且在公告日基础上再延迟一天使用。修改后再跑一遍回测胜率如果掉到55%以下说明之前的数据确实在穿越现在的数字才是真实水平。5.2 全局标准化泄漏统一scaler导致测试集虚高现象验证集上预测分布非常漂亮换一批股票或者换一段新时间范围后模型几乎失效IC从0.06掉到接近0。原因对整个数据集一次性 fit StandardScaler测试集的均值和方差已经被训练阶段“看过”。更隐蔽的是LSTM在训练中会把标准化参数也编码进隐状态里导致模型依赖跟真实市场不一致的数据分布。解决改用按股票分组、按时间窗口滚动的 z-score或者至少保证 StandardScaler 只在训练集上 fit验证集和测试集用同样的参数做 transform。这个动作对回测结果的影响通常大于换网络结构排查优先级最高。5.3 涨跌停和停牌预测结果只是纸面收益现象回测排名 top10 的股票平均收益很高但实际下单时要么买不进要么买在涨停价上真实成交价比回测收益低一大截。原因标签计算用的是当日收盘价买入没有考虑涨停板和停牌。A股涨停板上封单排不到停牌股票根本无法交易这两种情况在标签里都给了“可以正常买入”的收益。解决生成标签前先把当天一字涨停、停牌、成交量过低的样本剔除。更贴近实际的做法是买入价用T1开盘价卖出价用持有期末收盘价中间遇到停牌则顺延卖出。在毕设回测里把这个逻辑写清楚答辩时反而是加分项。5.4 因子多而杂LSTM过拟合到你怀疑人生现象训练loss一路下降验证集IC始终在0附近换初始化参数跑四次结果都不一样。原因把几十个财务指标全部塞进模型LSTM的特征维度高参数量暴增但有效样本只有那么多个模型只能把训练集背下来。解决先用单因子IC做一轮筛选只保留在历史数据上IC绝对值稳定大于0.02的因子个数控制在五个到八个。同时把 dropout 调到0.3以上hidden_size 回调到32或64加上早停机制。模型简单了以后验证集IC反而不容易大起大落。5.5 窗口重叠与样本切分边界模糊验证指标虚高的最后一处死角现象训练集和验证集的IC几乎一样高两个集合的预测值分布也高度相似换用完全随机的新时间段测试后表现大打折扣。原因滑窗样本之间重叠严重训练集最后几天的样本和验证集开头几天的样本共享了大部分时间步。LSTM只要记住最近几天的走势就能同时“猜中”这两个集合的标签跟它到底学到了什么因子逻辑没有关系。解决样本按形成日的时间戳切分而不是按行号切分训练集和验证集之间留出至少一个 pred_horizon 的空白作为 embargo。切完以后可以打印两边的日期区间确认验证集最早的样本在训练集最晚样本之后至少20个交易日。6. 把模型输出变成选股信号并验证回测与参数敏感性分析模型训练完了不代表项目结束了。LSTM输出的是一串预测收益率要把这串数变成选股信号必须做排序、回测和参数敏感性分析。6.1 滚动调仓回测的简单实现常见做法不是按预测收益率绝对值下单而是按预测值截面排名选股因为回归预测的数值未必准确反映绝对收益但不同股票之间的相对高低是有排序意义的。我一般每20个交易日调仓一次每次选预测值最高的10只股票等权买入def run_simple_backtest(pred_df, ret_df, rank_n10, rebalance_days20): pred_df: date, code, predret_df: date, code, future_ret dates sorted(pred_df[date].unique()) rebalance_dates dates[::rebalance_days] nav 1.0 records [] for i, trade_date in enumerate(rebalance_dates): if i 1 len(rebalance_dates): break next_date rebalance_dates[i 1] day_pred pred_df[pred_df[date] trade_date] top_codes day_pred.nlargest(rank_n, pred)[code].tolist() period_ret ret_df[ (ret_df[date] trade_date) (ret_df[date] next_date) (ret_df[code].isin(top_codes)) ] # 每只股票在持仓期内的平均收益再对组合取均值 avg_ret period_ret.groupby(code)[future_ret].mean().mean() nav * (1 avg_ret) records.append({date: trade_date, period_ret: avg_ret, nav: nav}) return pd.DataFrame(records)回测至少记录四个数字年化收益、超额收益对沪深300或全市场等权组合、最大回撤、调仓换手率。换手率很关键如果每期选出的股票几乎全部更换说明模型的预测排名不稳定实盘手续费会吃掉大量收益。我在回测代码里会加一个虚拟手续费按双边千分之二扣除别看这个数字小滚上一年差距非常明显。6.2 参数敏感性分析seq_len、hidden_size与预测周期怎么调毕设答辩时最常被问的一句话是“为什么选20天而不是10天”。回答“经验值”没有说服力最稳的准备方式是自己跑一组参数敏感性实验results [] for seq_len in [10, 20, 40]: for hidden_size in [32, 64, 128]: # 完整走一遍 create_lstm_samples - train - validate - backtest # 记录每个组合的IC均值、ICIR、年化超额、最大回撤 results.append({ seq_len: seq_len, hidden_size: hidden_size, ic_mean: ic_mean, icir: ic_ir, excess_annual: annual_excess, max_drawdown: max_drawdown })实验做完以后我一般先看ICIR。ICIR是IC均值除以IC标准差反映预测能力的稳定性比只看IC均值更可靠。如果五六个参数组合的ICIR都稳定在同一个水平说明模型吃的是因子横截面的信息时序窗口长短影响不大如果某个 seq_len 突然出现特别高的收益那大概率是过拟合出来的孤立点不要选它。我习惯把参数敏感性结果整理成一张三行三列的表格每组参数对应IC、换手率、最大回撤三个数字。这张表比任何文字描述都更能说明“为什么这样选参数”。最后说一个我自己的教训。早年代码里标准化和公告日对齐都做错了回测跑出年化70%的收益我一度以为找到了印钞机。后来把 ann_date 修正、滚动标准化替换全局scaler之后年化收益掉到12%出头那才是模型的真实水平。也是从那次开始我拿到任何LSTM预测源码第一反应永远是先去翻数据处理确认没有问题再去调网络结构。这个顺序颠倒的话你做出来的模型和选股策略都只是在给未来的函数打工。希望帮到你。本文还有配套的精品资源点击获取