Skip to content

Commit 4487a4a

Browse files
committed
Document ensemble backtest results and fail-closed scoring
1 parent 6e52f1f commit 4487a4a

8 files changed

Lines changed: 448 additions & 10 deletions

docs/LIVE_FORWARD_MONITOR.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ XGBoost also uses fold-safe early stopping:
7171
3. the outer temporal validation block remains untouched for trial scoring;
7272
4. the final all-data refit uses the median effective tree count learned across folds.
7373

74+
A trial is now eligible only when every temporal validation fold produces non-constant scores.
75+
After the final refit, the live snapshot also fails closed if every security receives the same
76+
score. This prevents tied scores from silently becoming an alphabetical ticker portfolio.
77+
7478
`model_selection.csv` records the requested estimator cap, best iteration, effective estimator
7579
count, and stopping-window settings for each fold. The aggregate row records the median and range
7680
of effective trees. Early stopping reduces unnecessary boosting rounds without using the live
@@ -165,3 +169,8 @@ predeclared ensemble, and SPY over many independent sessions, then report active
165169
drawdown, and uncertainty. Rank IC remains a component-model diagnostic; it is not available for
166170
the ensemble until paired historical out-of-sample scores are persisted. Model overlap is
167171
diagnostically useful, but high consensus is neither necessary nor sufficient for outperformance.
172+
173+
The complete historical portfolio comparison is recorded in the
174+
[Ridge-XGBoost ensemble experiment](experiments/2026-07-27-ridge-xgb-rank-ensemble.md).
175+
It includes compounding, identical execution costs, SPY, risk metrics, moving-block confidence
176+
intervals, a shuffled-score placebo, and a final chronological-fold check.

docs/experiments/2026-07-27-ridge-xgb-rank-ensemble.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,34 @@ top-tail signal. The live test must determine whether that trade-off improves re
4242
No claim should be made from the weight-selection objective on all historical folds. The primary
4343
historical ensemble estimate is the expanding prior-fold result, and the live portfolio is evaluated
4444
only after it is frozen.
45+
46+
## Complete portfolio backtest
47+
48+
A subsequent portfolio-level test used the same rules for Ridge, XGBoost, and the ensemble:
49+
50+
- 182 weekly observations from 2020-01-10 through 2023-06-30;
51+
- fully invested equal-weight top 40 with a 25% sector cap;
52+
- 8 bps per one-way traded weight, a $10 fixed fee per trade, and $1 million starting NAV;
53+
- adjusted SPY close-to-close returns with a 0.5% annual management fee;
54+
- 5,000 moving-block bootstrap draws and 100 shuffled-score permutations.
55+
56+
| Portfolio | Net cumulative | Net CAGR | Sharpe | Max drawdown | Weekly turnover |
57+
|---|---:|---:|---:|---:|---:|
58+
| SPY | 39.58% | 10.00% | 0.54 | -34.12% ||
59+
| Ridge | 103.88% | 22.57% | 0.77 | -27.37% | 142.05% |
60+
| XGBoost | 190.28% | 35.59% | 0.97 | -39.44% | 111.01% |
61+
| Ridge-XGBoost ensemble | 110.05% | 23.62% | 0.78 | -35.92% | 117.93% |
62+
63+
The ensemble's gross CAGR was 31.73%, so modeled costs reduced CAGR by 8.11 percentage points.
64+
Its annualized active return versus SPY was 14.63%, but the 95% moving-block interval was
65+
[-5.64%, 37.64%]. Its shuffled-score information-ratio p-value was 0.089. These are encouraging
66+
development statistics, not decisive evidence.
67+
68+
The final chronological fold covered 26 observations from January through June 2023. The ensemble
69+
returned 9.95%, versus 10.54% for XGBoost and 15.04% for SPY. Its Sharpe of 1.39 and maximum
70+
drawdown of -6.58% were marginally better than XGBoost's 1.38 and -7.93%, but SPY remained better
71+
on return, Sharpe, and drawdown in that period.
72+
73+
The final fold is not called a genuine lockbox: it was untouched by each nested model fit, but the
74+
two-model ensemble was proposed after the v10 results had already been inspected. Survivorship and
75+
point-in-time data limitations also remain. A new confirmation period is still required.

site/data/dashboard.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

site/data/manifest.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
{
22
"schema_version": 1,
3-
"generated_at_utc": "2026-07-27T23:01:45.416516+00:00",
4-
"latest_decision_date": "2026-07-27",
5-
"snapshot_count": 14,
6-
"evaluation_count": 11,
7-
"dashboard_sha256": "b33b26ce655ddef3a9eab8150a79977584de64bd7c47a45c7549a32c4ad1f906",
8-
"dashboard_bytes": 243869,
3+
"generated_at_utc": "2026-07-28T20:51:43.451436+00:00",
4+
"latest_decision_date": "2026-07-28",
5+
"snapshot_count": 17,
6+
"evaluation_count": 14,
7+
"dashboard_sha256": "632ee5ddcc24f98828ca332080b7f054c0fb9e8a87c211d143cc675ced32722e",
8+
"dashboard_bytes": 305847,
99
"source_policy": "sanitized_live_forward_outputs_only"
1010
}

src/experiments/live_forward_test.py

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ def run_live_forward_snapshot(
5454
optuna_timeout_seconds: int | None = None,
5555
selection_metric: str = "top40_raw_excess",
5656
model_family: str = "auto",
57+
preselected_model_params: dict[str, Any] | None = None,
58+
preselected_feature_count: int | None = None,
59+
preselected_trial: int | None = None,
5760
) -> dict[str, Any]:
5861
"""Retrain a live-forward ranker and save today's paper-trading scores."""
5962
decision_date = pd.Timestamp(as_of).normalize() if as_of else pd.Timestamp.today().normalize()
@@ -113,7 +116,33 @@ def run_live_forward_snapshot(
113116
label_horizon_sessions,
114117
)
115118
)
116-
if tune_hyperparameters:
119+
recovery_refit = preselected_model_params is not None
120+
if recovery_refit:
121+
model_name = model_family
122+
if model_name not in {"ridge", "xgb_ranker"}:
123+
raise ValueError("A recovery refit requires an explicit model family")
124+
exclude_prefixes = rank_cfg.get("features", {}).get("exclude_prefixes", [])
125+
features = _top_features(train, preselected_feature_count, exclude_prefixes)
126+
model_params = dict(preselected_model_params)
127+
model = _make_live_model(model_name, model_params).fit(
128+
train[features], train["target"]
129+
)
130+
selection_path = output / "model_selection.csv"
131+
tuning = (
132+
pd.read_csv(selection_path)
133+
if selection_path.exists() else pd.DataFrame()
134+
)
135+
if preselected_trial is not None and not tuning.empty:
136+
chosen = tuning.loc[
137+
tuning["row_type"].eq("aggregate")
138+
& tuning["trial"].eq(preselected_trial)
139+
]
140+
tuning = pd.concat(
141+
[chosen, tuning.drop(index=chosen.index)],
142+
ignore_index=True,
143+
)
144+
tuning.to_csv(selection_path, index=False)
145+
elif tune_hyperparameters:
117146
model, model_name, model_params, features, tuning = _select_and_fit_live_model(
118147
train,
119148
rank_cfg,
@@ -148,6 +177,12 @@ def run_live_forward_snapshot(
148177
)
149178
live_frame["score"] = model.predict(live_frame.reindex(columns=features))
150179
live_frame = live_frame.replace([np.inf, -np.inf], np.nan).dropna(subset=["score"])
180+
live_score_unique = int(live_frame["score"].nunique())
181+
if live_score_unique < 2:
182+
raise RuntimeError(
183+
f"{model_name} produced constant live scores; refusing to freeze an "
184+
"arbitrary ticker-ordered portfolio"
185+
)
151186
selected = sector_diversified_top_k(
152187
live_frame["score"].droplevel("date"),
153188
live_frame["sector"].droplevel("date"),
@@ -199,9 +234,10 @@ def run_live_forward_snapshot(
199234
"split_event_cache": str(split_cache_file),
200235
"split_features_status": "candidate_only_pending_event_study",
201236
"live_scored_names": int(ranked["ticker"].nunique()),
237+
"live_unique_scores": live_score_unique,
202238
"holdings": int(holdings),
203239
"model_selection": {
204-
"enabled": bool(tune_hyperparameters),
240+
"enabled": bool(tune_hyperparameters or recovery_refit),
205241
"validation_days": int(validation_days) if tune_hyperparameters else None,
206242
"tuning_folds": int(tuning_folds) if tune_hyperparameters else None,
207243
"purge_days": (
@@ -216,6 +252,8 @@ def run_live_forward_snapshot(
216252
"optuna_timeout_seconds": optuna_timeout_seconds if tune_hyperparameters else None,
217253
"selection_metric": selection_metric if tune_hyperparameters else None,
218254
"model_family": model_family,
255+
"recovery_refit": recovery_refit,
256+
"preselected_trial": preselected_trial,
219257
"search_profile": rank_cfg.get("live_tuning", {}).get("profile", "broad_default"),
220258
"search_space": rank_cfg.get("live_tuning", {}),
221259
"report": str(output / "model_selection.csv") if tune_hyperparameters else None,
@@ -1094,7 +1132,10 @@ def objective(trial: Any) -> float:
10941132
trial.set_user_attr(key, value)
10951133
if selection_metric not in aggregate:
10961134
raise ValueError(f"Unknown live selection metric: {selection_metric}")
1097-
return float(aggregate[selection_metric])
1135+
return (
1136+
float(aggregate[selection_metric])
1137+
if aggregate["selection_eligible"] else float("-inf")
1138+
)
10981139

10991140
sampler = optuna.samplers.TPESampler(seed=42)
11001141
study = optuna.create_study(direction="maximize", sampler=sampler)
@@ -1111,6 +1152,13 @@ def objective(trial: Any) -> float:
11111152
na_position="last",
11121153
).reset_index(drop=True)
11131154
aggregate_table = table.loc[table["row_type"].eq("aggregate")].copy()
1155+
aggregate_table = aggregate_table.loc[
1156+
aggregate_table["selection_eligible"].fillna(False).astype(bool)
1157+
]
1158+
if aggregate_table.empty:
1159+
raise RuntimeError(
1160+
"No model-selection trial produced non-constant scores in every fold"
1161+
)
11141162
aggregate_table = aggregate_table.sort_values(
11151163
[selection_metric, "rank_ic"],
11161164
ascending=False,
@@ -1295,6 +1343,14 @@ def _make_live_model(
12951343
def _aggregate_trial_rows(rows: list[dict[str, Any]], trial: int) -> dict[str, Any]:
12961344
frame = pd.DataFrame(rows)
12971345
first = rows[0]
1346+
validation_score_dates = (
1347+
frame["validation_score_dates"]
1348+
if "validation_score_dates" in frame else pd.Series(1, index=frame.index)
1349+
)
1350+
constant_score_dates = (
1351+
frame["constant_score_dates"]
1352+
if "constant_score_dates" in frame else pd.Series(0, index=frame.index)
1353+
)
12981354
aggregate = {
12991355
"row_type": "aggregate",
13001356
"trial": trial,
@@ -1313,7 +1369,18 @@ def _aggregate_trial_rows(rows: list[dict[str, Any]], trial: int) -> dict[str, A
13131369
"top40_target_excess": float(frame["top40_target_excess"].mean()),
13141370
"validation_start": str(frame["validation_start"].min()),
13151371
"validation_end": str(frame["validation_end"].max()),
1372+
"fold_count": int(len(frame)),
1373+
"valid_rank_ic_folds": int(frame["rank_ic"].notna().sum()),
1374+
"constant_score_dates": int(constant_score_dates.sum()),
1375+
"score_date_coverage": float(
1376+
(validation_score_dates - constant_score_dates).sum()
1377+
/ max(1, validation_score_dates.sum())
1378+
),
13161379
}
1380+
aggregate["selection_eligible"] = bool(
1381+
aggregate["valid_rank_ic_folds"] == aggregate["fold_count"]
1382+
and aggregate["constant_score_dates"] == 0
1383+
)
13171384
if first["model"] == "xgb_ranker" and "effective_n_estimators" in frame:
13181385
effective = pd.to_numeric(
13191386
frame["effective_n_estimators"],
@@ -1376,6 +1443,7 @@ def _validation_row(
13761443
holdings: int,
13771444
) -> dict[str, Any]:
13781445
daily_ic = _daily_rank_ic(validation["target"], scores)
1446+
daily_unique = scores.groupby(level="date").nunique()
13791447
top_raw = _topk_daily_mean(validation["raw_forward_return"], scores, holdings)
13801448
universe_raw = validation["raw_forward_return"].groupby(level="date").mean().mean()
13811449
top_target = _topk_daily_mean(validation["target"], scores, holdings)
@@ -1387,6 +1455,9 @@ def _validation_row(
13871455
"resolved_feature_count": len(feature_names),
13881456
"rank_ic": mean_rank_ic(validation["target"], scores),
13891457
"positive_ic_rate": float(daily_ic.gt(0.0).mean()) if not daily_ic.empty else np.nan,
1458+
"validation_score_dates": int(len(daily_unique)),
1459+
"constant_score_dates": int(daily_unique.le(1).sum()),
1460+
"score_date_coverage": float(daily_unique.gt(1).mean()),
13901461
"top40_raw_forward_return": float(top_raw),
13911462
"universe_raw_forward_return": float(universe_raw),
13921463
"top40_raw_excess": float(top_raw - universe_raw),

0 commit comments

Comments
 (0)