Predicting the Japan Stock Market
LightGBM Final Notebook 方法分析

这份 notebook 的核心方法是:把日本股票的价格、成交量、行业、期权、财务和聚类信息组合起来,使用技术指标构造回归特征,再通过全局模型和 cluster-specific 模型预测股票收益,最后按每日横截面排名构造多空组合。

一、先看方法全貌

它不是直接预测市场指数,而是对每只股票生成一个未来收益预测值 TargetPredicted,每天在所有股票之间排序,再买入排名靠前的股票、卖出排名靠后的股票。模型质量最终通过组合 spread 和 Sharpe 风格指标判断。

合并多源市场数据→按股票计算技术指标→构造 Target→时间切分→Optuna + LightGBM→按 Cluster 训练→横截面多空回测
6
股票 cluster 数量
200 / 200
每天多头与空头股票数量
0.1980
报告中的 spread Sharpe 风格数值
阅读时必须先记住一个缺口:notebook 中没有展示 Target 的生成公式。后续所有训练、Boruta 和回测都依赖这个标签,但无法仅凭当前 notebook 确认它究竟是下一日收益、调整后收益还是其他持有期收益。

二、数据方法:从单表到多源特征

数据来自 JPX 日本股票预测任务。notebook 从训练目录读取并拼接:

数据源进入方法体系的角色
stock_prices.csv股票 OHLC、成交量、调整因子、股息等基础行情
secondary_stock_prices.csv补充市场的股票成交量,用于行业成交量聚合
options.csvPut/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。

共线性观察:OHLC、均线和布林带之间相关性非常高,例如 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_trialOptuna 搜索得到的最优结果没有完整用于最终模型
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 模型的动机是允许不同股票群体拥有不同的收益预测关系。

配置一致性风险:Optuna 的 best trial 包含 learning_rate=0.02、max_depth=20 等值,但最终实际使用的 lgbm_params 是另一组参数。需要明确这是有意的人工选择,还是 notebook 遗留代码。
API 风险: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.1980spread 均值除以标准差的 Sharpe 风格指标
calc_spread_average_return约 0.3865平均 spread 回报
不要直接把 0.1980 当成年化 Sharpe:代码是 buf.mean() / buf.std(),没有年化,也没有扣除交易成本、滑点、借券成本、换手和流动性限制。它更适合作为 notebook 内部的相对比较指标。

九、这套方法值得学习的地方

特征上下文丰富

把单股行情放到行业、期权情绪、财务状态和 cluster 背景中理解,而不是只使用 OHLC。

滚动计算边界正确

按股票独立生成技术指标,避免不同证券之间发生序列污染。

评价接近交易目标

最终关注每日横截面排序和多空 spread,而不只看回归 RMSE。

允许异质性

cluster-specific 模型允许不同股票群体拥有不同的特征收益关系。

时间切分意识

使用日期而不是随机抽样做训练/测试边界,方向上符合金融预测场景。

尝试自动调参

用 Optuna 搜索参数,说明作者意识到树深、叶子数、正则化和采样比例会影响结果。

十、需要优先修正的地方

  1. 补齐 Target 定义。明确预测时点、收益公式、持有期、价格调整方式和标签可用时间。
  2. 拆分验证集和测试集。例如训练期、调参期、最终测试期三段,不能让同一个 df_test 承担三种角色。
  3. 使用滚动时间验证。在不同市场阶段重复评估,检查方法是否只适用于 2022 年某个阶段。
  4. 统一参数来源。只保留实际使用的参数,并记录 Optuna 最优参数为何没有用于最终模型。
  5. 确认 LightGBM 参数生效。清理 XGBoost 专属或可疑参数,检查训练日志和模型 dump。
  6. 处理财务披露时点。用真实公开时间进行 as-of join,避免把交易日后才披露的信息提前使用。
  7. 处理 Put/Call 除零。对 Call 成交量为零的日期设置明确规则,而不是让无穷值最终被不透明地填零。
  8. 明确缺失语义。对“没有数据”和“数值为 0”增加缺失指示变量,避免统一填零造成混淆。
  9. 加入交易现实约束。评估交易成本、滑点、换手、成交量容量、涨跌停和做空可得性。
  10. 真正应用特征筛选或删除无效代码。Boruta 如果只是探索,应展示结果;如果用于建模,应根据支持集重建训练特征。
推荐的研究顺序:先保证标签和时间边界正确,再做无泄漏的滚动验证;接着优化组合构造和成本模型;最后才比较 global、cluster-specific、技术指标和外部数据各组件的增量价值。

十一、用实验拆解方法贡献

为了知道这套方法到底哪些部分有效,建议做逐步消融实验。所有实验都使用相同的时间切分、相同的排名组合和相同的成本假设:

实验保留内容回答的问题
BaselineOHLCV + 基础 LightGBM树模型本身是否有预测力?
+ 技术指标Baseline + EMA/RSI/MACD/波动率手工指标是否提供增量信息?
+ 外部数据再加入行业、Put/Call、财务多源数据是否真正改善排序?
Global vs Cluster比较一个全局模型和 6 个模型分群是否优于共享一个函数?
成本回测加入换手和滑点纸面 spread 是否可实现?

每次实验至少报告:按日期的平均收益、波动率、未年化和年化 Sharpe、最大回撤、换手率、多空持仓数量、不同日期区间的表现,以及训练/验证/测试的严格边界。