"""Minimal ranking-objective ablation loop — why lambda_rank fails here. This repo found that with only ~50 instruments per day the rank-gradient objectives (lambdarank / rank_xendcg) produce near-zero RankIC, while MSE objective + RankIC early-stop is the winner. This script replays that check by training a few LightGBM variants on the same lake split and printing RankIC. Reference repo impl: tac-qlib/examples/run_rank_objectives.py. python examples/run_rank_objectives.py --universe AAPL,MSFT,QQQ """ from __future__ import annotations import argparse import os import numpy as np import pandas as pd OBJECTIVES = ["mse", "lambdarank", "rank_xendcg"] def load_frame(lake_root: str, universe: list[str], start: str, end: str) -> pd.DataFrame: """Stack lake bars into a qlib-like (datetime, instrument) frame.""" from tac_qlib.data.config import LakeConfig lake = LakeConfig(lake_root, "US") frames = [] for sym in universe: p = lake.bar_path("1d", sym) if p.exists(): df = pd.read_parquet(p)[["t", "c"]].rename(columns={"t": "datetime", "c": "close"}) df["instrument"] = sym frames.append(df) out = pd.concat(frames, ignore_index=True) out["datetime"] = pd.to_datetime(out["datetime"]) out = out[(out["datetime"] >= start) & (out["datetime"] <= end)] return out.set_index(["datetime", "instrument"]) def label_5d(frame: pd.DataFrame) -> pd.Series: close = frame["close"].unstack() lbl = close.shift(-6) / close.shift(-1) - 1 return lbl.stack().rename("label") def train_one(lake_root: str, universe: list[str], objective: str, train: tuple, test: tuple): import lightgbm as lgb frame = load_frame(lake_root, universe, train[0], test[1]) label = label_5d(frame) data = pd.concat([frame["close"], label], axis=1).dropna() tr = data.loc[(data.index.get_level_values(0) >= train[0]) & (data.index.get_level_values(0) <= train[1])] te = data.loc[(data.index.get_level_values(0) >= test[0]) & (data.index.get_level_values(0) <= test[1])] dtrain = lgb.Dataset(tr[["close"]], label=tr["label"]) dtest = lgb.Dataset(te[["close"]], label=te["label"]) params = {"objective": objective, "learning_rate": 0.05, "num_leaves": 15, "verbosity": -1} model = lgb.train(params, dtrain, num_boost_round=100, valid_sets=[dtest]) pred = model.predict(te[["close"]], num_iteration=model.best_iteration) label_te = te["label"].to_numpy() # per-day RankIC days = te.index.get_level_values(0).unique() ics = [] for d in days: m = te.index.get_level_values(0) == d if m.sum() >= 3: ics.append(pd.Series(pred[m]).rank().corr(pd.Series(label_te[m]).rank())) return float(np.nanmean(ics)) def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--lake-root", default=os.environ.get("TAC_LAKE_DIR", "")) ap.add_argument("--universe", default="AAPL,MSFT,QQQ,IVV,SMH,TLT") args = ap.parse_args() universe = [s.strip().upper() for s in args.universe.split(",")] train = ("2026-03-01", "2026-05-31") test = ("2026-07-01", "2026-08-06") print(f"{'objective':<14}{'test RankIC':>12}") for obj in OBJECTIVES: ic = train_one(args.lake_root, universe, obj, train, test) print(f"{obj:<14}{ic:>12.4f}") if __name__ == "__main__": main()