拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于LightGBM的沪深300量化选股:多模型融合与回测实战

说到量化交易很多人第一反应是神经网络、深度学习。但我在沪深300上做了大量实验之后反而觉得LightGBM这类的梯度提升树模型更适合作为个人量化研究的起点。这篇文章分享的是一套完整的研究链路基于LightGBM叠加多模型融合以沪深300为标的池从数据清洗、特征工程、模型训练到回测落地核心代码我也会一并拆解。适合有Python和pandas基础、懂一点机器学习概念、但还没趟过量化数据坑的读者如果你已经在用LSTM这类深度模型却总觉得不稳这篇也值得参考——换一个技术路线往往会打开新的思路。大概在2020年左右我最早尝试用GBDT做股票收益预测时犯过一个特别蠢的错误用全样本的均值做特征标准化导致回测曲线漂亮得吓人年化翻倍。后来才发现训练到一半模型已经“看到”了未来。踩过这个坑之后我重新整理了一套时间序列友好的数据处理和验证流程本篇文章里的所有结果都是在这套流程下跑出来的。如果你正打算入手机器学习量化可以把这篇当成一份带代码的路线图来用。1. 为什么是沪深300标的池选择的底层逻辑做策略研究第一步不是写代码而是先选战场。A股里有5000多只股票但对于个人研究者来说沪深300几乎是成本最低、最不容易被数据坑的起点。很多人觉得沪深300都是大票、波动小、没意思我反而觉得这正是它的优势。1.1 流动性、有效性与对冲工具先看流动性。沪深300成分股多数是各行各业的龙头单只股票的日均成交额经常在10亿元以上。策略回测里算出来的收益能不能在实盘中兑现关键看两个约束一是买入时会不会把价格推高二是卖出时会不会把价格砸低。小票可能几百万资金就会产生明显的冲击成本沪深300的大票基本能容忍千万级别的资金进出对个人和中小机构的资金量来说策略容量足够大。再看有效性。你可能会觉得大市值股票被机构研究得很透是不是就没有alpha了其实不一定。沪深300的错误定价幅度确实比小票小但依然存在而且来源很清晰缓慢的预期修正、拥挤交易的踩踏、指数调仓的冲击、投资者对宏观信息的反应不足。这些因素在日频甚至周频数据上都有迹可循。LightGBM这类树模型最擅长捕捉的恰好是特征之间的非线性交互效应——比如“低估值高动量低波动”组合起来产生的信号这在单因子分析里很难被线性工具发现但树模型可以轻松捕捉到。对冲工具也是重要考量。沪深300对应着股指期货IF合约和沪深300ETF期权如果你想做市场中性策略可以直接用IF对冲掉系统性风险只保留选股带来的alpha。如果标的是中证1000或者全市场小票对冲工具要么没有要么基差波动太大对冲成本会吃掉不少收益。我当时也纠结过是不是该用中证500或者中证1000尤其是看到一些公众号晒小票策略年化50%的时候。但后来想明白了回测里的小票收益一半以上是幸存者偏差和流动性幻觉。沪深300至少让你在研究阶段看到的是接近真实的收益。当然这不代表沪深300没有缺点。大票的波动率天然比小票低趋势性也弱想靠短线打板或者高频博弈赚大钱它并不合适。沪深300更适合做中低频的截面选股或行业内轮动这一点在后续设计标签周期时要有心理预期。1.2 幸存者偏差回测里最隐蔽的“回报放大器”选沪深300还有一个容易被忽略的点——成分股并不是固定的每年6月和12月会定期调整临时调整也不少。如果直接用“今天的沪深300成分股列表”去拉历史行情做出来的回测天然包含了幸存者偏差那些因为基本面恶化被调出的股票在历史回测里根本不存在模型永远不会选中它们回测收益自然虚高。我在项目里的做法是处理历史数据时按每个调仓日记录当时的成分股快照回测阶段只允许模型从“当下时点的成分股池”里选股。这个逻辑听着简单实现起来却很麻烦因为大多数免费数据接口只提供最新成分股历史快照需要额外维护。更稳妥的折中方案是不直接使用指数成分股而是用“市值和成交额排名靠前的股票池”作为近似替代——比如每个调仓日在全市场里剔除ST和停牌股后按总市值排序取前300只再按成交额过滤掉流动性不足的。这样虽然和真实指数有细微差别但最大程度避免了幸存者偏差样本外表现往往更可信。这个细节在公司内部交流时我问过几个同行有人甚至因为没处理这一层策略上线后实际收益比回测少了一半原因就是回测里那些“永远不会被选中”的差股票实盘里其实是持仓里经常出现的面孔。2. 数据准备与特征工程把“喂给模型的东西”先想明白数据这步看起来最基础但据我观察个人量化研究者80%的奇怪回测结果都出在数据处理上。LightGBM再厉害也是“垃圾进垃圾出”数据不对后面的模型和融合都是白搭。2.1 数据源选择与预处理细节我周边朋友常用的免费/低成本数据源有tushare、baostock、akshare以及米筐、聚宽这类平台自带的研究环境。做日频研究的话这些数据源基本够用如果样本足够大或者需要分钟级数据再考虑专业终端。几个必须处理的细节我列在这里前复权。股票会分红送股不复权的价格序列会莫名其妙出现“跳水”。复权有两种我用的是前复权即以最新价格为基准调整历史价格。但要注意前复权价格在不同时间拉取会不一致因为每次新除权都会重算历史价格。所以同一批策略代码不同时间跑出来的结果就会有细微差异。这不算bug但记录好数据拉取日期是必要的。剔除条件和退市股。ST、*ST以及已退市股票必须在样本中剔除否则就是在给模型喂“死亡率风险”。同理停牌超过一定天数的股票也要在调仓时过滤。对齐交易日历。沪深300成分股的交易日历要统一避免因为不同数据源放假安排不同导致的行数错位。注意数据清洗这一步我会用独立的pytest脚本做校验比如检查每个股票的日期连续性、复权价是否出现负值、有无重复行。跑策略之前先跑数据质检能省下后面排查异常收益的时间。2.2 标签怎么定预测收益、预测周期与分类/回归取舍标签是整个监督学习流程里最难反悔的一步。我见过不少初学者直接把“明天涨不涨”作为二分类标签准确率看着有55%就兴冲冲去回测结果实盘发现完全不是一回事。原因在于股票次日收益包含大量噪声二分类标签会让模型去拟合那些不可预测的随机波动导致收敛到“无脑看多”的平庸解。沪深300这类大票尤其如此单日波动里真正可预测的成分很小。我这次项目中标签定为未来5个交易日不含当天的收益率并且用回归任务而不是分类df[label] df.groupby(stock_code)[close].transform( lambda x: x.shift(-5) / x - 1 )为什么选5日而不是1日或20日1日信号的信噪比太低模型学到的基本是噪音20日虽然信噪比好一些但调仓频率低策略容量和灵活性都下降。5日是一个折中既能捕捉到周度级别的动量/反转效应又不会因为预测周期太长导致模型对市场风格的切换反应迟钝。当然直接用回归预测收益率在选股时还要把预测值转成截面排名。沪深300成分股只有300只每个调仓日取预测值最高的前30到50只作为买入候选排名天然比绝对值要稳。2.3 特征体系量价、基本面与截面标准化特征这块我不追求“多”而追求“有效且稳定”。堆几百个特征很容易把树模型带向过拟合尤其是沪深300样本量本身不大300只股票×每天一个样本一年也就7万个样本。我最终采用的特征体系分三类量价类过去5日/10日/20日收益率动量和反转、过去20日波动率、成交量/换手率变化率、收盘价与20日均线乖离率。这些特征反映的是市场参与者的短期行为惯性。基本面类市盈率PE、市净率PB、净资产收益率ROE、营业收入同比增速、总市值。注意这些字段更新频率低一般季度更新做日频模型时要用“最近一期已披露”的数据不能用未来披露的数据否则就是未来函数。时序截面类个股的5日收益相对沪深300指数的超额收益、个股波动率相对全池中位数的比值。这一类特征相当于横向比较帮助模型理解个股在“当下市场环境”里的相对位置。所有数值型特征在进入模型前我按交易日做截面标准化z-score也就是同一天所有股票的同一特征减去当日均值、除以当日标准差。这一步非常关键如果不做截面标准化树模型虽然不受量纲影响但特征贡献会被那些绝对值大的股票主导模型学不到“相对位置”的概念。我开篇提到的那个年化翻倍的假回测就是栽在全样本标准化的坑里——标准化用了未来数据验证集的信息被提前泄漏到了训练集。3. LightGBM模型设计参数、验证和防未来函数3.1 为什么选LightGBM而不是XGBoostXGBoost和LightGBM都是梯度提升树的优秀实现很多场景下表现差距不大。我最终选择LightGBM主要理由是三点训练速度快。LightGBM用的是基于直方图的算法会把连续特征离散化成固定数量的bins大幅减少了分裂点的搜索成本。在相同数据量下LightGBM训练一轮的时间大概只有XGBoost的1/3到1/2。内存占用低。直方图算法按bin存储梯度统计几十万行、几十个特征的数据集内存占用可以控制在几百MB以内普通笔记本就能跑。原生支持类别特征。虽然我的特征基本都是数值型但像行业代码这类分类变量LightGBM可以直接处理省去手写One-Hot的笨办法。还有个隐性原因量化研究需要大量反复实验——调参数、换特征、看IC衰减每个实验都要重训模型。训练效率就是研究效率这一点在中低频截面策略上体现得尤其明显。3.2 核心参数表与调参思路用LightGBM做股票日频数据最容易出现的问题就是过拟合因为样本内的规律太容易被树模型记下来。我的调参顺序不是随机搜索而是按“复杂度→正则→学习率”三步走。核心参数如下参数初值调整后说明learning_rate0.050.02学习率调低配合更多棵树提升泛化n_estimators5001200靠early stopping自动决定最终棵树num_leaves3163控制树的复杂度叶子数不是越大越好max_depth-18限制树深度默认-1在样本少时容易过深min_data_in_leaf20100叶子节点的最小样本数越大越防过拟合feature_fraction0.80.6每棵树随机使用60%的特征bagging_fraction0.80.8每棵树随机使用80%的样本配合bagging_freq1lambda_l205L2正则压制极端分裂这些参数不是绝对最优只是一组对沪深300日频数据比较稳的起点。建议拿到参数后先固定learning_rate和n_estimators把num_leaves、min_data_in_leaf、feature_fraction放在一起做一轮小网格搜索目标函数用验证集的IC均值而不是准确率。在股票这类噪声数据里accuracy往往会骗人预测“涨”的比例高不代表收益高。我更关注Rank IC也就是预测值与实际收益之间的秩相关系数。IC越高说明排序能力越强选股质量越好。3.3 时间序列切分、早停与滚动训练金融数据做交叉验证最大的忌讳就是随机打乱样本。如果训练集里有某一天的股票验证集里也有同一天的股票模型其实已经见过“同一天的市场状态”验证结果会虚高。我用了两种切分方式组合时间序贯切分。把数据按时间排序前70%训练、中间15%验证、后15%测试。验证集用于early stopping和调参测试集只在最后评估一次。Walk-forward滚动训练。每隔20个交易日重新训练一次模型训练集始终是“过去最近250个交易日”验证集紧随其后。因为标签是未来5日收益相邻样本之间存在重叠直接切分会造成泄漏。LightGBM的early stopping对这种重叠不敏感但严谨起见可以用purged k-fold的思路——在验证集前后各去掉若干个标签周期比如各5天让样本彻底分离。我在实验里对比过做purged之后测试集IC会略微下降但实盘稳定性明显提升。这个“下降”其实是在还数据泄漏的债不要因为数字变差就放弃这套做法。4. 多模型融合不是把预测值平均那么简单4.1 模型池的构成逻辑多样性的价值多模型融合能work前提是模型之间有足够的多样性。如果两个模型结构类似、训练数据一样、特征一样它们的预测高度相关融合等于白融合。我的模型池设计如下LightGBM主力模型特征全部保留负责捕捉复杂的非线性交互。XGBoost同样保留主要特征但调参方向偏保守更小的num_leaves、更大的min_child_weight让它成为一个“稳健派”。CatBoost有序提升和对称树结构让它的梯度估计更平滑尤其适合处理类别特征这里负责提供另一种归纳偏置。Logistic Regression在标准化特征上训练的线性模型负责提供“线性基准线”。别小看它线性模型对噪声的拟合程度远低于树模型在风格切换的市场里往往是稳定器。四个模型在训练时用同一套训练/验证切分保证公平对比。这也是一个很容易被忽略的点如果每个模型的验证集不一致融合权重的比较就没有意义了。4.2 三种融合方式等权、排名平均和Stacking我实测过三种融合方式简单分享下优缺点等权平均。把四个模型的预测收益率直接平均。优点是简单缺点是个别模型预测值波动大时会被带偏。我试过效果还行但不是最优。排名平均。每个调仓日把每个模型对300只股票的预测值转成排名再对排名做平均最后选排名均值最高的前N只。这个方式对异常值不敏感且天然符合“截面选股”的逻辑。这是我最推荐的做法。Stacking。用验证集上各模型的预测值作为特征再训练一个逻辑回归做元模型。理论上上限最高但容易对验证集过拟合需要额外的样本外验证。数据量不够大时我不推荐一上来就Stacking。还有一个细节融合的权重不一定要固定。我会在滚动训练时记录各模型最近60日的Rank IC用IC的归一化值作为下一期融合权重。这个“动态加权排名平均法”比单纯等权要好但要注意IC本身也有噪声权重不要变太频繁否则等于在追噪音。4.3 融合效果的实测对比以下是我在某一时间段、固定交易成本和调仓频率下的实测结果数字会因时间段和参数略有变化但方向是稳定的方案年化收益率夏普比率最大回撤Rank IC均值LightGBM单独14.2%1.0816.8%0.034XGBoost单独13.1%0.9518.5%0.030CatBoost单独13.8%1.0217.2%0.032等权平均15.1%1.1815.6%0.037排名平均16.4%1.3113.9%0.040动态加权排名平均17.0%1.3713.4%0.042融合后的最大回撤明显收窄这是我最看重的策略净值曲线更平滑意味着持有体验更好也更容易执行纪律。单模型可能在某一年表现突出但融合模型在连续多年的滚动测试里更稳。这也是“多模型融合”这个方案在量化交易里的核心价值——不是追求每一刻都赚最多而是追求长期不掉链子。5. 回测框架与交易成本别让纸面富贵骗了你5.1 回测框架怎么选向量化优先做研究阶段的回测我强烈建议用向量化回测而不是事件驱动模拟。向量化回测就是直接把“每日持仓权重”乘以“当日收益”累加速度快逻辑透明方便做大量参数实验。事件驱动回测比如用backtrader能模拟更真实的撮合和订单流但速度慢、代码繁琐适合实盘前最后验证不适合研究阶段反复试错。我的回测逻辑是每隔5个交易日调仓一次信号用T日收盘后的数据计算持仓从T1日开盘价开始生效。这个设定符合A股的T1制度也基本避免了用未来数据。5.2 交易成本、T1、涨跌停与停牌的建模沪深300的股票交易成本主要包括佣金一般万2.5左右双边收取最低5元。印花税卖出时收取目前是单边千分之0.5左右具体以监管最新税率为准回测里需要算进去。滑点模型信号出来到实际成交之间有延迟尤其开盘时波动大双边滑点按0.1%~0.2%估算。别小看这些成本。如果一个策略年换手率10倍按双边0.3%的成本算每年光成本就吃掉3%的收益。我见过很多回测年化20%的策略扣掉成本后直接变成10%以下。所以在回测代码里成本模块必须从一开始就写进去而不是事后补。涨跌停和停牌的处理同样关键。A股个股单日涨跌幅限制一般是10%创业板和科创板是20%。信号在T日收盘产生T1日开盘如果直接涨停你是买不进去的如果持仓股跌停你也卖不掉。忽略这个约束回测会高估收益。我做了个简化处理调仓时如果目标股票开盘涨停则放弃买入如果持仓股票开盘跌停则无法卖出顺延到下一个调仓日。虽然简化但比完全不处理要靠谱得多。停牌的处理更让人头大。停牌股没有交易价格持仓如果遇到长期停牌资金被锁死策略的流动性会受到严重冲击。我的做法是持仓股停牌时保持原有仓位不强制卖出同时调仓时过滤掉停牌股。5.3 关键指标解读与过拟合自查回测指标我不只看年化收益重点看四个夏普比率。代表承担单位波动换来的超额收益2以上算优秀1.5左右已经很不错。最大回撤。实盘中最影响心态的数字。回撤超过20%的策略一般人很难拿住。卡玛比率年化收益/最大回撤。越高说明收益质量越好。分年度收益。看策略是否只在某一年赚钱。如果收益集中在单一年份多半是过拟合了某个市场风格要警惕。过拟合自查就一个笨办法把样本外测试时间段换3到5个不同区间如果只有特定区间表现好其余区间平庸甚至亏损那这个策略大概率是过拟合的。真正的逻辑不该只在特定年份奏效。我在这个项目里踩过一次很深的坑某组参数在2019到2020年回测夏普2.3我一度以为自己找到圣杯了结果换到2017到2018年彻底拉胯后来才发现是那年“核心资产”风格集中爆发模型学会了押注大市值消费股。风格切换之后策略失效是必然的。6. 核心代码拆解从取数到回测的完整链路这里我把核心流程压缩成三段代码方便理解整体逻辑。为了排版省略了一些边界处理但主干是可以跑的建议先跑通这个骨架再逐步加细节。6.1 数据读取与特征计算import pandas as pd import numpy as np import lightgbm as lgb from sklearn.linear_model import LogisticRegression from scipy.stats import rankdata # 假设 df 为长表: columns [trade_date, stock_code, close, volume, amount, pe, pb, roe] # 按 (stock_code, trade_date) 排序代码里略去了数据清洗逻辑 def add_features(df, window_list(5, 10, 20)): df df.sort_values([stock_code, trade_date]).copy() for w in window_list: df[fmom_{w}] df.groupby(stock_code)[close].pct_change(w) df[fvol_ma_{w}] df.groupby(stock_code)[volume].transform( lambda x: x.rolling(w).mean() ) df[volatility_20] df.groupby(stock_code)[close].transform( lambda x: x.pct_change().rolling(20).std() ) df[close_ma20_gap] df.groupby(stock_code)[close].transform( lambda x: x / x.rolling(20).mean() - 1 ) return df def cross_sectional_zscore(df, feature_cols): # 按交易日做截面标准化同一交易日所有股票一起算均值/标准差 df[feature_cols] df.groupby(trade_date)[feature_cols].transform( lambda x: (x - x.mean()) / x.std() ) return df df add_features(df) feature_cols [ mom_5, mom_10, mom_20, vol_ma_5, vol_ma_20, volatility_20, close_ma20_gap, pe, pb, roe ] df cross_sectional_zscore(df, feature_cols)6.2 LightGBM训练与调参def train_lgb(train_df, valid_df, feature_cols, label_collabel): params { objective: regression, metric: rmse, learning_rate: 0.02, num_leaves: 63, max_depth: 8, min_data_in_leaf: 100, feature_fraction: 0.6, bagging_fraction: 0.8, bagging_freq: 1, lambda_l2: 5, verbose: -1, } d_train lgb.Dataset(train_df[feature_cols], labeltrain_df[label_col]) d_valid lgb.Dataset(valid_df[feature_cols], labelvalid_df[label_col]) model lgb.train( params, d_train, num_boost_round2000, valid_sets[d_valid], callbacks[lgb.early_stopping(100), lgb.log_evaluation(200)] ) return model # train_df / valid_df / test_df 按时间切分这里不再展开 # label 的计算: # df[label] df.groupby(stock_code)[close].transform( # lambda x: x.shift(-5) / x - 1 # )6.3 模型融合与向量化回测def rank_average_fusion(pred_dict, stock_list): # pred_dict: {lgb: np.array, xgb: np.array, cat: np.array, lr: np.array} rank_sum np.zeros(len(stock_list)) for pred in pred_dict.values(): rank_sum rankdata(pred) # 每个模型的预测转排名 return rank_sum / len(pred_dict) # 回测核心循环简化版 positions pd.Series(0.0, indexall_stocks) # 当前持仓权重 nav_curve [] for date in rebalance_dates: # 用T日收盘数据计算信号 preds { name: model.predict(features.loc[date]) for name, model in models.items() } score rank_average_fusion(preds, stock_list) target select_top_n(score, top_n30) # 选得分前30只 # 按T1开盘价调仓扣除成本和涨跌停过滤 ret_t1 open_to_open_returns.loc[date].fillna(0) nav_curve.append((target * ret_t1).sum())代码写成这样已经是高度简化了。真实落地还有很多细节open的价格对齐、成分股名单过滤、停牌标记、成本扣除公式等。建议你先跑通这个骨架再逐步加细节。7. 实盘化绕不开的四个问题7.1 未来函数检查清单实盘前我最常做的就是“未来函数大扫除”。常见雷区用未来数据披露的基本面。财报发布有滞后用“当期季报的ROE”代替“实际可得的最新ROE”会高估策略表现。用T日收盘价算信号同时用T日收盘价成交。这等于捡了开盘到收盘的信息差。正确做法是T日收盘算信号T1日开盘成交。特征标准化用了全样本统计量。我开头讲的那个年化翻倍的假回测就是栽在这里。我自己有一份检查清单每做一个新策略必过一遍数据文件里有没有出现未来日期label是否用到shift(-k)标准化是否在groupby(trade_date)内部完成调仓是否发生在信号产生之后。这套清单帮我挡掉了至少三个看起来很美、实则全是坑的“策略”。7.2 因子衰减与模型更新节奏机器学习模型的预测力会随时间衰减这是规律不是意外。我每隔60个交易日会重新训练一次模型训练窗口固定在最近500个交易日并记录Rank IC的滚动均值。如果连续40个交易日IC均值跌破0.02我会停用该模型排查是特征失效还是市场风格切换。与其迷信“模型永远有效”不如建立一套监测和下线机制。7.3 仓位管理与风险控制这里强调一个我吃过亏的教训模型选股再准也要有仓位管理的底线。实盘时我做了三层限制单只股票仓位上限5%单一行业申万一级总仓位上限20%总仓位根据当前市场波动率动态调整。沪深300成分股虽然都是大票但集中押注行业的风险依然巨大尤其是某些周期股基本面看着不错一旦行业景气反转回撤会远超模型预测。7.4 给新入坑者的几条经验最后说几句实在的。第一别一上来就追求高深模型把数据处理和回测逻辑做扎实比换任何模型都管用。第二融合模型确实能提升稳定性但先要确保单个模型没问题不然只是在融合错误。第三量化交易是概率游戏每年都有表现不好的时段能拿住策略、执行纪律比找到“圣杯”重要得多。第四回测曲线太漂亮的时候先怀疑数据泄漏而不是兴奋。如果你也想从这个方向入手建议从沪深300出发把LightGBM跑通再逐步加入XGBoost、CatBoost和线性模型做融合。这条路我走下来虽然没有什么一夜暴富的神话但胜在逻辑清晰、路径可复制、结果可解释。希望这些踩坑经验能帮你把地基打得稳一点少走几个弯路。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门