一、先看方法全貌
它不是直接预测市场指数,而是对每只股票生成一个未来收益预测值 TargetPredicted,每天在所有股票之间排序,再买入排名靠前的股票、卖出排名靠后的股票。模型质量最终通过组合 spread 和 Sharpe 风格指标判断。
Target 的生成公式。后续所有训练、Boruta 和回测都依赖这个标签,但无法仅凭当前 notebook 确认它究竟是下一日收益、调整后收益还是其他持有期收益。二、数据方法:从单表到多源特征
数据来自 JPX 日本股票预测任务。notebook 从训练目录读取并拼接:
| 数据源 | 进入方法体系的角色 |
|---|---|
stock_prices.csv | 股票 OHLC、成交量、调整因子、股息等基础行情 |
secondary_stock_prices.csv | 补充市场的股票成交量,用于行业成交量聚合 |
options.csv | Put/Call 成交量和隐含波动率,用于市场情绪特征 |
financials.csv | 盈利、每股收益及预测财务数据 |
stock_list.csv | 行业分类编码 |
clusters.csv | 外部提供的股票 cluster 映射 |
基础清洗
df['SecuritiesCode'] = df['SecuritiesCode'].astype('category')
主训练价格表和 secondary 价格表都经过类似预处理。代码会删除含缺失值的训练行,注释提到仍有约 8,200 行 OHLC 全为空,因此被丢弃。提交用的预处理函数则不删除缺失行,而是准备通过按股票排序后的 backfill 处理。
方法含义:训练和提交的缺失值策略并不完全相同。训练时是删除,提交时倾向于填补;如果缺失模式在训练和测试之间不同,模型行为可能发生漂移。
三、外部数据如何变成特征
行业信息
df.merge(
stock_list[['SecuritiesCode', '33SectorCode', '17SectorCode']],
on='SecuritiesCode', how='left'
)
最终主要保留 17SectorCode。它既可以表示行业身份,也可以帮助构造行业内的成交量状态。
行业成交量
date_sector_volume = df_all_securities.groupby(
by=['Date', '17SectorCode']
)['Volume'].sum()
得到 VolumeSector。这是一种上下文特征:某只股票的成交量不再只与自身历史比较,还可以放在当天行业整体活跃度中理解。
期权市场情绪
put_call_day_volume = df_options.groupby(
['Date', 'Putcall']
)['TradingVolume'].sum().unstack()
put_call_day_volume['put/call'] = (
put_call_day_volume['Put'] / put_call_day_volume['Call']
)
使用的期权特征是全市场日级别的 put/call。代码还计算了平均隐含波动率 Volatility,但它没有进入最终特征列表。Put/Call 比率的经济解释通常是市场情绪或对冲需求的代理,但它是全市场同日特征,对每只股票都相同,主要提供市场状态信息。
财务数据
df_prices.merge(
df_financials,
on=['SecuritiesCode', 'Date'], how='left'
)
保留并使用的财务特征包括 EarningsPerShare 和 ForecastEarningsPerShare;Profit 与 ForecastProfit 最终被注释掉。财务字段通过数值转换并用 0 填充。
SecuritiesCode + Date 精确合并避免了跨日期回填,但还不能证明财务数据在该交易日开盘前已经公开。需要进一步核对披露时间、交易时间和特征使用时点。四、技术指标:按股票独立计算
技术指标不是在整张表上直接滚动,而是对每个 SecuritiesCode 单独执行:
for code in tqdm(codes):
security_features = add_features_security(df, code)
这是正确的重要细节。因为不同股票的价格序列互不连续,如果跨股票计算 rolling、EMA 或 RSI,会把上一只股票的数据错误地带入下一只股票。
| 类别 | 指标 |
|---|---|
| 趋势与通道 | 布林带上/中/下轨、EMA_20、HT_TRENDLINE、TEMA_15/30/50 |
| 收益与动量 | ROC(10)、MOM(10) |
| 趋势强度 | ADX(14)、ADXR(14)、APO(12,26) |
| 动量振荡 | MACD(12,26,9)、RSI(14) |
| 成交量与波动率 | AD、OBV、ATR、NATR、TRANGE |
data_security = data_security.fillna(method='ffill')
指标初始阶段产生的缺失值先按股票前向填充,再将剩余缺失值、正负无穷统一变成 0。另有 drop_null_values_lagged 尝试删除指标尚未形成的 warm-up 行。
重要理解:填 0 会把“指标尚未形成”和“真实指标值为 0”混在一起。树模型可以学习这种模式,但它降低了缺失语义的清晰度。
五、特征筛选和最终特征
notebook 调用了 Boruta:
boruta = BorutaPy(
estimator=RandomForestRegressor(),
n_estimators='auto', max_iter=100
)
boruta.fit(
np.array(df_prices[features_training]),
np.array(df_prices.Target)
)
但没有展示 boruta.support_ 的结果,也没有根据筛选结果删除变量。因此 Boruta 在当前流程更像一次探索性分析,实际训练仍使用手工定义的特征列表。
最终训练特征包括:
SecuritiesCode, 17SectorCode, Open, High, Low, Close, Volume,
AdjustmentFactor, ExpectedDividend, EarningsPerShare,
ForecastEarningsPerShare, put/call, VolumeSector,
BBANDS_upper, BBANDS_lower, EMA_20, TEMA_15, TEMA_30,
TEMA_50, HT_TRENDLINE, ROC, MACD_macd, RSI, APO, ADX,
AD, OBV, NATR, Cluster
训练时从列表中删除 Date 和 Target,但保留 SecuritiesCode、17SectorCode 和 Cluster,并将若干字段转为 categorical。
Open 与 High 约为 0.9999,Close 与 EMA_20 约为 0.9896。LightGBM 能处理冗余特征,但这会增加解释难度,并可能放大某些市场阶段的拟合。六、目标、时间切分与调参
目标变量
Target 在多个地方被直接使用:
model.fit(df_train[features_training], df_train.Target)
boruta.fit(..., np.array(df_prices.Target))
但是标签定义代码缺失。无法确认预测时点、持有期、是否使用调整收益、是否截面标准化,也无法判断技术指标计算时是否已经正确避开未来信息。
时间切分
TRAIN_END = '2022-03-31'
TEST_START = '2022-04-01'
df_train = df_prices[df_prices['Date'] <= TRAIN_END][features]
df_test = df_prices[df_prices['Date'] >= TEST_START][features]
方向上采用了时间切分,而不是随机切分,这是金融时间序列中更合理的起点。但 notebook 只有一个切分点,没有滚动窗口、扩展窗口或多折时间验证。
Optuna 搜索
study = optuna.create_study(direction='minimize')
目标函数用验证部分的 RMSE 选择参数,搜索了正则化、采样比例、学习率、叶子数、树数量、深度和 bin 数等参数。报告出的最佳 trial RMSE 约为 0.02445。
最严重的方法问题:代码把 df_test 同时用于 Optuna 调参、early stopping 和最终效果评估。这样它已经不是独立测试集,而是模型选择集。最终回测表现会产生乐观偏差。
七、LightGBM 模型:实际使用的参数要分清
notebook 中出现了三组容易混淆的参数:
| 对象 | 角色 | 问题 |
|---|---|---|
best_trial | Optuna 搜索得到的最优结果 | 没有完整用于最终模型 |
params_best | 另一组手工记录的参数 | 后续没有真正使用 |
lgbm_params | 全局模型和 cluster 模型实际传入的参数 | 应以它为准理解最终模型 |
lgbm_params = {
'lambda': 0.020205134940549825,
'alpha': 0.008714278321966324,
'colsample_bytree': 1.0,
'subsample': 0.8,
'learning_rate': 0.0001,
'n_estimators': 1600,
'num_leaves': 64000,
'max_depth': 16,
'random_state': 2020,
'min_data_in_leaf': 1000,
'min_child_weight': 258,
'max_bin': 256,
'grow_policy': 'lossguide'
}
最终先训练一个全局 LGBMRegressor,随后对 6 个 cluster 分别训练模型。cluster 模型的动机是允许不同股票群体拥有不同的收益预测关系。
learning_rate=0.02、max_depth=20 等值,但最终实际使用的 lgbm_params 是另一组参数。需要明确这是有意的人工选择,还是 notebook 遗留代码。gamma、missing、grow_policy、tree_method='gpu_hist' 等参数更像 XGBoost 语境。它们是否被当前 LightGBM 版本接受、忽略或触发警告,需要在实际环境中检查;特别是 tree_method='gpu_hist' 不是标准 LightGBM GPU 配置。八、从回归预测到多空组合
模型输出每只股票的预测值后,notebook 不直接使用固定阈值,而是每天做横截面排序:
predictions = predictions.sort_values(
['Date', 'TargetPredicted'],
ascending=[True, False]
)
预测值最高的股票排名为 0。组合规则是:
- 每天做多排名前 200 的股票。
- 每天做空排名后 200 的股票。
- 前 200 名的权重从 2 线性递减到 1。
- 使用多空 spread 评估预测排序是否有经济价值。
portfolio_size = 200
weights = np.linspace(2, 1, num=portfolio_size)
报告结果包括:
指标 数值 解释 calc_spread_return_sharpe约 0.1980 spread 均值除以标准差的 Sharpe 风格指标 calc_spread_average_return约 0.3865 平均 spread 回报
不要直接把 0.1980 当成年化 Sharpe:代码是 buf.mean() / buf.std(),没有年化,也没有扣除交易成本、滑点、借券成本、换手和流动性限制。它更适合作为 notebook 内部的相对比较指标。
九、这套方法值得学习的地方
特征上下文丰富
把单股行情放到行业、期权情绪、财务状态和 cluster 背景中理解,而不是只使用 OHLC。
滚动计算边界正确
按股票独立生成技术指标,避免不同证券之间发生序列污染。
评价接近交易目标
最终关注每日横截面排序和多空 spread,而不只看回归 RMSE。
允许异质性
cluster-specific 模型允许不同股票群体拥有不同的特征收益关系。
时间切分意识
使用日期而不是随机抽样做训练/测试边界,方向上符合金融预测场景。
尝试自动调参
用 Optuna 搜索参数,说明作者意识到树深、叶子数、正则化和采样比例会影响结果。
十、需要优先修正的地方
- 补齐 Target 定义。明确预测时点、收益公式、持有期、价格调整方式和标签可用时间。
- 拆分验证集和测试集。例如训练期、调参期、最终测试期三段,不能让同一个
df_test承担三种角色。 - 使用滚动时间验证。在不同市场阶段重复评估,检查方法是否只适用于 2022 年某个阶段。
- 统一参数来源。只保留实际使用的参数,并记录 Optuna 最优参数为何没有用于最终模型。
- 确认 LightGBM 参数生效。清理 XGBoost 专属或可疑参数,检查训练日志和模型 dump。
- 处理财务披露时点。用真实公开时间进行 as-of join,避免把交易日后才披露的信息提前使用。
- 处理 Put/Call 除零。对 Call 成交量为零的日期设置明确规则,而不是让无穷值最终被不透明地填零。
- 明确缺失语义。对“没有数据”和“数值为 0”增加缺失指示变量,避免统一填零造成混淆。
- 加入交易现实约束。评估交易成本、滑点、换手、成交量容量、涨跌停和做空可得性。
- 真正应用特征筛选或删除无效代码。Boruta 如果只是探索,应展示结果;如果用于建模,应根据支持集重建训练特征。
十一、用实验拆解方法贡献
为了知道这套方法到底哪些部分有效,建议做逐步消融实验。所有实验都使用相同的时间切分、相同的排名组合和相同的成本假设:
| 实验 | 保留内容 | 回答的问题 |
|---|---|---|
| Baseline | OHLCV + 基础 LightGBM | 树模型本身是否有预测力? |
| + 技术指标 | Baseline + EMA/RSI/MACD/波动率 | 手工指标是否提供增量信息? |
| + 外部数据 | 再加入行业、Put/Call、财务 | 多源数据是否真正改善排序? |
| Global vs Cluster | 比较一个全局模型和 6 个模型 | 分群是否优于共享一个函数? |
| 成本回测 | 加入换手和滑点 | 纸面 spread 是否可实现? |
每次实验至少报告:按日期的平均收益、波动率、未年化和年化 Sharpe、最大回撤、换手率、多空持仓数量、不同日期区间的表现,以及训练/验证/测试的严格边界。