☰
LightGBM+BiLSTM股票量化源码解析:从因子筛选到A股回测实战
2026/9/28 16:04:53 网站建设 项目流程

简介:这是一份面向毕业设计场景的 Python 股票量化系统完整源码包,适合金融、计算机相关专业学生,以及对 A 股量化投资策略与 Python 建模感兴趣的开发者。项目基于 A 股全市场股票数据,先由 LightGBM 对 50 个价量因子完成重要度筛选,保留 Top10 因子,再接入 BiLSTM 模型进行因子组合与策略构建,并经实证回测验证策略优于市场基准指数;从数据处理、因子筛选、模型训练到回测评估,完整展示了机器学习与深度学习方法在量化投研中的应用链路。压缩包共 1408 个文件,大小约 14.12MB,以 py/pyc 源码及编译文件为主,同时包含可执行程序、配置脚本、说明文档、模型文件与少量图片表格,便于直接运行实验、对比分析并继续二次开发。目前已有 640 人学习浏览,对准备量化相关课题或毕业设计的学生有较高参考价值,能帮助理解因子挖掘、时序预测与策略回测的完整落地方式。

1. 股票量化不是玄学:这套 LightGBM+BiLSTM 源码在解决什么问题

A股量化在很多人眼里是黑匣子,拿到一份源码也不敢跑,怕跑起来全是坑。这套 python股票量化系统源码 走的是一条很清晰的工程路线:先用 LightGBM 在 50 个价量因子里筛出最重要的 10 个,再用 BiLSTM 把这 10 个因子组合成预测模型,最后落到回测上验证策略收益能不能跑赢沪深300。它适合两类人:一类是毕业设计需要完整技术链的学生,另一类是已经会写 Python、但没时间从零搭建因子选股框架的从业者。整份代码不是纯理论演示,它自带 venv 虚拟环境、训练输出和回测依赖库,解压之后按顺序激活环境就能开始干活。

2. 先把环境跑起来:venv 配置、DLL 依赖与 A 股数据预处理

拿到 zip 先别急着看模型代码,量化项目翻车有一半是环境问题,另一半是数据问题。这套源码解压后你会看到一堆以 activate、pyvenv.cfg 结尾的 venv 虚拟环境文件,以及 y_hat.csv、y_hat2.csv、fitness_dll.dll、oputils.dll 这样看起来不像 Python 工程的文件。先把它们搞清楚,后面才不会跑一步报一个错。

2.1 源码文件清单:这些文件分别是干什么的

我拆包的习惯是先列文件清单,再按依赖关系决定启动顺序。这份资源里最容易被忽略的是两个 DLL 文件,它们是 C++ 编译出的本地优化库,LightGBM 训练和回测阶段会调用。如果它们缺失,程序不会在 import 时报错,而是跑到回测那一环突然崩溃或者直接闪退。

文件类型作用
activate / activate.bat / deactivate.bat环境脚本进入/退出 venv 虚拟环境
pyvenv.cfg / sysconfig.cfg / setup.cfg环境配置记录 Python 版本与 pip 配置
y_hat.csv / y_hat2.csv预测结果模型对验证集样本的预测输出,用于回测
fitness_dll.dll / oputils.dll本地库C++ 优化工具,供因子筛选和回测调用

从文件结构能推断出这套源码是用 venv 而不是 conda 管理的,pyvenv.cfg 里写的 Python 版本就是你复现时的基准版本。如果解压后直接双击 activate.bat 提示版本不符,最常见的坑是创建虚拟环境用的 Python 版本和你本机当前 Python 版本不一致。我一般会先看 pyvenv.cfg 里的 version 字段,再用对应版本重建环境。

2.2 启动虚拟环境:Windows 与 macOS 的差异

Windows 下的启动命令比较简单,直接在当前目录执行 activate.bat。macOS 或 Linux 环境则要改成 source 语法。

# Windows activate.bat # macOS / Linux source activate

激活之后命令行前缀会变成(venv),这时候再执行python -V确认解释器路径指向当前目录。如果你用的是 VSCode,还要手动把解释器切到.venv目录下,否则编辑器右下角会沿用全局 Python。这一步不做,后面装包全都装进了全局环境,虚拟环境等于白建,这是新手最容易踩的误区。

2.3 A股全市场数据怎么准备:行情清洗与复权处理

这套系统的核心输入是 A 股全市场的日线行情。数据源可以自己找,常见做法是拉取全市场 daily 表,包含日期、股票代码、开高低收价、成交量、成交额。拿到原始数据后第一件事是复权,因为前复权和后复权算出来的动量因子差别很大。

import pandas as pd # 以股票代码和日期为索引,读取原始日线数据 df = pd.read_csv("daily.csv", parse_dates=["trade_date"]) df.sort_values(["ts_code", "trade_date"], inplace=True) # 简化版前复权:用复权因子把历史价格调整到当前口径 factor_col = "adj_factor" df["adj_close"] = df["close"] * df[factor_col] / df.groupby("ts_code")[factor_col].transform("last") # 过滤上市未满60天的次新股,避免因子计算样本不足 df = df[df["trade_date"] >= df.groupby("ts_code")["trade_date"].transform("min") + pd.Timedelta(days=60)]

这段代码里有两个关键点。第一个是sort_values必须先按股票代码再按日期排序,因子计算依赖时间顺序,顺序乱了滚动窗口就全错。第二个是复权因子的用法,A 股除权除息后不复权价格会出现跳空,直接拿原始 close 算收益率会把分红造成的下跌误判成风险信号。数据量大的时候建议按 ts_code 分组循环处理,不要一次性concat全市场数据,内存容易撑爆。

2.4 50个价量因子怎么算:用滚动窗口生成特征矩阵

因子是这套系统最底层的原料。所谓价量因子,就是基于价格和成交量构造的技术指标,比如过去 5 日动量、20 日波动率、成交量乖离率。50 个因子在代码里一般以排行榜或列表形式集中管理,我拿到手后会先跑一遍全量因子列表,确认没有用到未来数据。

# 以动量类因子为例:遍历股票代码,滚动计算 def calc_momentum_feature(df, window=[5, 10, 20]): df_feat = pd.DataFrame(index=df.index) for w in window: # close.diff(w) / close.shift(w) 表示过去 w 日的收益率,避免用未来数据 df_feat[f"mom_{w}"] = df["adj_close"].diff(w) / df["adj_close"].shift(w) return df_feat # 对全市场股票循环计算,然后纵向拼接 all_feat = [] for code, sub_df in df.groupby("ts_code"): sub_df = sub_df.sort_values("trade_date") all_feat.append(calc_momentum_feature(sub_df)) feature_matrix = pd.concat(all_feat)

因子计算的边界坑通常在窗口对齐上。shift(w)把当期因子值滞后了 w 天,确保 t 时刻的因子只包含 t 之前的信息,这是整个因子筛选阶段不能破的底线。如果你在源码里看到某个因子用的是close.pct_change().rolling(w).mean()这种未来窗口,要立刻改成 shift 滞后处理。

3. LightGBM 因子筛选:50 个价量因子收敛到 10 个的做法

这套系统最聪明的设计是先让 LightGBM 做减法,而不是直接把 50 个因子塞进神经网络。原因很简单:BiLSTM 对输入维度和噪声非常敏感,原始因子里高度相关的技术指标会在神经网络里制造冗余计算,还会放大过拟合。先用树模型筛一遍因子,相当于给后面搭好了骨架。

3.1 为什么用 LightGBM 而不是直接上 BiLSTM

LightGBM 虽然是树模型,但它有两个天然优势非常适合因子筛选。第一是它对特征尺度不敏感,原始因子无论量纲差异多大,树分裂只看阈值,不需要先做标准化,省掉不少预处理麻烦。第二是特征重要性输出非常直观,一次训练就能给 50 个因子按重要性排出序,方便人工校验是否符合正常逻辑。

对比之下,直接用神经网络筛选因子的做法在实践中很难落地。神经网络的特征重要性依赖梯度传播和置换检验,计算成本高,而且结果不稳定,换一个随机种子排序就变了。树模型这一层筛选稳定很多,并且训练速度以秒级计算,200 棵树的模型在全市场数据上跑完也就几分钟。

3.2 标签构造与训练集划分:防止后视偏差

用 LightGBM 筛因子之前,得先定义什么叫“有效因子”。这套源码的做法是构造一个二分类标签:未来 5 日收益率为正记 1,否则记 0。因子是否重要,看它对判断未来涨跌的贡献度。

import lightgbm as lgb import numpy as np import pandas as pd # 特征矩阵与标签对齐 X = feature_matrix # 50列因子 y = (df["adj_close"].shift(-5) / df["adj_close"] - 1 > 0).astype(int) # 按时间切分:前80%训练,后20%验证,严禁随机打乱 split_date = X["trade_date"].quantile(0.8) train_idx = X["trade_date"] < split_date valid_idx = X["trade_date"] >= split_date dtrain = lgb.Dataset(X[train_idx], label=y[train_idx]) dvalid = lgb.Dataset(X[valid_idx], label=y[valid_idx]) params = { "objective": "binary", "metric": "auc", "learning_rate": 0.05, "num_leaves": 31, "max_depth": 6, "feature_fraction": 0.8, "verbosity": -1, } model = lgb.train( params, dtrain, num_boost_round=300, valid_sets=[dvalid], callbacks=[lgb.early_stopping(50), lgb.log_evaluation(50)] )

shift(-5)构造的标签用到了 t+5 时刻的收盘价,意味着预测目标是未来 5 日收益方向,这在因子上是合法的前视标签。但如果把shift(-5)写错成shift(5),标签就会与历史数据对齐,LightGBM 在验证集上的 AUC 会异常高,后面回测收益率也跟着虚高,这是拿到源码后必须第一个核对的地方。

3.3 特征重要性的计算口径:用 gain 而不是 split

LightGBM 的feature_importance()有两种口径:split 表示特征被用于分裂的次数,gain 表示特征分裂带来的平均增益之和。源码里应该用的是 gain 口径,因为分裂次数多不代表因子有效,可能一个噪声因子分裂了很多次但每次收益都很小。

importance = pd.Series( model.feature_importance(importance_type="gain"), index=X.columns ).sort_values(ascending=False) # 取前10个因子 top10 = importance.head(10).index.tolist() print(top10)

这里有一个值得注意的经验:如果筛出来的前 10 因子全是动量类或者全是波动率类,说明因子池本身的多样性不够。正常情况下价格动量、成交额、波动率、换手率这几类因子都应该有代表进入前十。用 LightGBM 做筛选的另一个好处是,它能间接捕捉因子与因子之间的交互效应,某个单独看不重要的因子,在树模型里与其他因子组合后增益会明显上升。

3.4 提高筛选稳定性的两个参数习惯

为了筛选结果不随随机种子剧烈波动,我跑这类任务时会固定三件事:random_state、feature_fraction不要设成 1.0,以及提前设置bagging_fraction。如果两次运行选出的 top10 因子重合度低于 80%,说明这个因子池稳定性不够,需要回去补充因子构造逻辑,而不是盲目调参。

params.update({ "bagging_fraction": 0.8, "bagging_freq": 1, "seed": 42, "feature_fraction_seed": 42, })

4. BiLSTM 因子组合建模:网络结构、训练参数与预测输出解析

LightGBM 筛出的 10 个因子只是“原料清单”,真正把原料组合起来的是 BiLSTM。它的作用不是预测股价本身,而是预测未来收益率的排序。这套源码把预测结果写在 y_hat.csv 和 y_hat2.csv 里,跑回测之前先要把这两份文件的含义搞清楚。

4.1 BiLSTM 在因子组合上到底起了什么作用

LSTM 家族的模型擅长抓住时间序列上的状态依赖,股价和价量因子恰恰带有明显的时序特征。一个因子在 t 时刻的取值,往往受过去一小段时间的走势影响,而 BiLSTM 的特殊之处在于它同时用前向和后向两个方向编码序列。

但这里有一个需要敲黑板的地方:双向结构如果直接套用在预测任务上,后向编码会把未来信息带进当前时刻的判断,这在金融数据上就是前端看未来。常规做法是在训练阶段使用双向结构学习因子之间的时间关联,预测时仍只用截止到当前时刻的输入窗口。拿到源码后先检查forward()函数里有没有对输入序列做截断处理,这是判断代码专业度的一个指标。

4.2 从因子到样本:序列窗口的切分方法

BiLSTM 的输入不能是单行因子值,而是一个时间窗口内的连续序列。每只股票的因子矩阵需要切成形状为(股票数 × 时间窗口数, 序列长度, 因子维度)的样本。

import numpy as np import torch SEQ_LEN = 20 # 每个样本回看20个交易日 N_FEATURES = 10 # LightGBM筛出的10个因子 BATCH_SIZE = 256 # 按股票代码分组,把连续因子序列切成(seq_len, n_features)的窗口 def build_sequences(feature_array, label_array, seq_len=20): xs, ys = [], [] for stock in range(feature_array.shape[0]): for i in range(seq_len, feature_array.shape[1]): xs.append(feature_array[stock, i-seq_len:i, :]) ys.append(label_array[stock, i]) return np.array(xs), np.array(ys) # 划分训练集与验证集(按时间顺序) X_train, y_train = build_sequences(train_feat, train_label) X_valid, y_valid = build_sequences(valid_feat, valid_label) train_loader = torch.utils.data.DataLoader( torch.tensor(X_train, dtype=torch.float32), batch_size=BATCH_SIZE, shuffle=True )

这里seq_len=20是最需要调超参数的环节。20 天大约对应一个月的交易窗口,如果把它改成 60,模型能看见更多历史信息,但训练数据量会缩小到原来的三分之一,过拟合风险随之升高。源码提供的默认值通常是反复试出来的,不要一上来就大改。

4.3 BiLSTM 网络结构与训练逻辑

模型主体就是标准的嵌入层加双向 LSTM 层加全连接输出层。PyTorch 实现很简洁,关键在双向层的输出如何合并。

import torch.nn as nn class FactorBiLSTM(nn.Module): def __init__(self, input_size=10, hidden_size=64, num_layers=2, output_size=1, dropout=0.2): super().__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, bidirectional=True, dropout=dropout, ) self.fc = nn.Linear(hidden_size * 2, output_size) # 双向拼接,维度翻倍 def forward(self, x): # x形状: (batch, seq_len, input_size) out, _ = self.lstm(x) # out: (batch, seq_len, hidden*2) last = out[:, -1, :] # 取最后一个时间步 return self.fc(last).squeeze(-1) model = FactorBiLSTM() criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

全连接层的输入是hidden_size * 2,因为双向 LSTM 会把前向和后向的输出拼接在一起,这一步忘了乘 2,模型运行时就会报维度不匹配的错误。训练目标用的是回归任务,直接预测未来 5 日累计收益率,这样做的好处是回测阶段可以直接把预测值当因子排序,不需要再做分类阈值转换。

训练循环中推荐在验证集上做早停,常见做法是连续 10 个 epoch 验证损失不下降就提前终止,同时把最优权重保存下来,避免后几个 epoch 过拟合导致预测值分布被拉偏。

best_loss = float("inf") for epoch in range(100): model.train() for batch_x, batch_y in train_loader: optimizer.zero_grad() pred = model(batch_x) loss = criterion(pred, batch_y) loss.backward() optimizer.step() model.eval() with torch.no_grad(): val_loss = criterion(model(X_valid), y_valid) if val_loss < best_loss: best_loss = val_loss torch.save(model.state_dict(), "bilstm_best.pt")

4.4 y_hat.csv 与 y_hat2.csv 的差别在哪

源码里输出两个预测结果文件,通常会让人疑惑。按文件命名习惯推断,y_hat.csv 可能是 LightGBM 筛选后 10 因子直接作为 BiLSTM 输入的验证集预测,y_hat2.csv 可能是换了序列长度或换了随机种子之后重训模型的预测结果。跑通之前,先用 Pandas 读一下两个文件的列数和行数。

y1 = pd.read_csv("y_hat.csv") y2 = pd.read_csv("y_hat2.csv") print(y1.shape, y2.shape) print(y1.head())

如果两份文件的行数不一致,先检查索引是否与验证集日期对齐,最常见的问题是索引错位,导致回测代码合并预测值和行情数据时出现大量 NaN。如果只是预测值大小不同,那是正常现象,可以分别跑一遍回测看看哪组结果更稳定。正规的验证逻辑是选择在验证集上 IC(信息系数)更高、换手率更温和的那组输出,而不是单看收益哪个高。

5. 回测与排查:为什么同样的代码跑出来的结果不如注释里的数字

回测阶段是整个项目最容易让人怀疑人生的地方。源码注释里写着年化收益跑赢沪深300,你自己跑出来却发现曲线在基准下方趴着。出现这种落差,通常不是模型失效,而是回测口径里的手续费、滑点、调仓时点和你默认的设置不一样。

5.1 回测框架必须设置的四个参数

A股回测不能像美股那样忽略交易成本。源码里应该内置了一个包含手续费、印花税、滑点和调仓频率的参数区,这是回测诚实程度的底线。下面是行业里比较常见的默认取值,可以直接对照源码里的参数检查:

参数常见取值说明
佣金费率万2.5双边收取,最低5元
印花税千1卖出单边收取
滑点0.1%按成交价偏差估算
调仓频率每5个交易日过高会吃掉收益,过低会钝化

如果源码注释里写的是“单边万3佣金,忽略滑点”,那跑出来的收益会偏高但不算失真;如果写的是“零手续费零滑点”,那回测曲线再漂亮也没有实际参考价值。

5.2 回测策略构建:预测值如何转成持仓

BiLSTM 预测出的是每只股票未来 5 日的预期收益率。策略层需要把这组连续值转成持仓权重。最稳妥的做法是排序分组,而不是硬性阈值。

# 按预测收益从高到低排序,取前10%作为持仓 daily_pred = pd.DataFrame({"code": codes, "pred": pred_values}) daily_pred["rank"] = daily_pred["pred"].rank(ascending=False, pct=True) selected = daily_pred[daily_pred["rank"] <= 0.1]["code"].tolist() # 模拟按开盘价调仓,记录成交价和交易成本 def execute_trade(selected_stocks, cash, fees): position_size = cash / len(selected_stocks) * (1 - fees) return {code: position_size for code in selected_stocks}

这里最容易出问题的是调仓时点。如果源码是在第 t 天收盘后拿到预测值、第 t 天收盘价成交,那就用到了当天收盘价信息,属于轻微未来函数。标准做法是第 t 天收盘后产生信号,第 t+1 天开盘按开盘价调仓。这个细节直接决定回测曲线每年差出几个百分点。

5.3 三条高频踩坑记录

现象一:运行回测脚本时,预测值和行情数据合并后行数骤减。 原因:y_hat.csv 的日期索引和行情表索引没有对齐,merge时默认做内连接,把缺失日期的行直接丢掉了。 解决:合并前先reset_index(),查看两个 DataFrame 的日期范围是否一致,再用outer连接保留全部交易日,最后dropna()处理无法预测的日子。

现象二:LightGBM 训练时 AUC 高达 0.98,但回测收益为负。 原因:标签构造用了未来 5 日收益,但特征矩阵里混入了当日收盘后才知道的成交量数据,形成了信息泄露。A 股的成交量是当日收盘后更新,如果你在 t 日开盘就用当日的成交量因子,等于提前看了收盘答案。 解决:所有当日价量因子必须前移一天,用shift(1)把因子值压到上一交易日的时点上。

现象三:验证集结果很好,一上全市场测试收益就垮掉。 原因:验证集是单一时段,可能恰好处于普涨行情,模型学到的因子组合规律只在那一刻有效。 解决:按时间滚动切分出三段区间,分别做训练和测试,观察三段结果是否一致。只在一段时间有效的话,说明因子选出的 10 个指标本身的生命周期太短,需要回因子筛选阶段调整。

5.4 拿到源码后的自查路径

拿到这套源码,我建议按固定顺序检查五个位置:环境是否激活、数据是否复权、标签是否用shift(-5)构造、特征是否用shift(1)滞后、回测调仓是否在次日开盘执行。这五个点全部核对无误,跑出来的结果和源码注释之间通常不会差太多,如果还差,再考虑是不是手续费参数的问题。

6. 把策略搬到自己的机器上:从预测结果到模拟交易的三步验证

整套源码跑通只是第一步,把模型输出的预测结果变成能信任的投资信号,还需要过三道关。我的习惯是先验验证集上的 IC,再验滚动窗口下的稳定性,最后用纯模拟方式跑一个月的实盘节奏。

第一步是算信息系数 IC。IC 的意思是模型预测值与真实收益之间的秩相关系数,计算公式是 Spearman 相关系数。Rank IC 大于 0.03 说明预测有微弱的信息量,大于 0.06 已经是很不错的结果。

from scipy.stats import spearmanr # 合并模型预测值和未来真实收益 valid_df = pd.DataFrame({ "y_pred": y_hat, "y_real": future_return }) valid_df = valid_df.dropna() ic, p_value = spearmanr(valid_df["y_pred"], valid_df["y_real"]) print(f"Rank IC: {ic:.4f}") # 按月份分组计算月度IC,并统计IC胜率 valid_df["month"] = pd.to_datetime(valid_df.index).to_period("M") monthly_ic = valid_df.groupby("month").apply( lambda g: spearmanr(g["y_pred"], g["y_real"])[0] ) print(f"IC胜率: {(monthly_ic > 0).mean():.2%}")

从这份资源的使用场景出发,我不建议只跑一次验证集就收工。量化系统的核心价值不在某一个模型的精度,而在训练流程是否可重复。从那以后我每次拿到新的量化源码,都强制走一遍滚动窗口重训——把训练区间每次后移 3 个月,重新训练模型并记录 IC,连续三次 IC 都稳定为正,我才会认真对待这组预测结果。

第三步是用模拟盘程序跑一个月的每日节奏,把模型预测值按天落库,记录每天的调仓信号和实际成交价差。这一步能很直观地暴露滑点设置是否过于乐观,也会让你看清哪些预测值在实盘中根本成交不了。量化系统的价值终究要在时间上验证,希望这份源码能帮你把第一版策略稳稳跑起来。

经过以上整理,资源逻辑已基本梳理清楚,你可以直接按结构使用或复现。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询