Skip to content

Commit 6e52f1f

Browse files
committed
Document and display the Ridge–XGBoost ensemble shadow portfolio
1 parent 181bb43 commit 6e52f1f

11 files changed

Lines changed: 622 additions & 20 deletions

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,12 +282,14 @@ frozen-snapshot contract, dashboard timestamps, model-overlap metrics, and gener
282282
The [GitHub Pages deployment guide](docs/GITHUB_PAGES_DEPLOYMENT.md) documents the public-data
283283
boundary, static-site publication, validation gate, and one-time repository configuration.
284284

285-
The daily command first refreshes the latest prior Ridge and XGBoost portfolios against the
285+
The daily command first refreshes the latest prior Ridge, XGBoost, and available ensemble
286+
portfolios against the
286287
completed session. Yahoo's final hourly bar begins at 15:30, so the command requires that bar and
287288
retries five times at 60-second intervals if it is not available yet. It evaluates the frozen
288289
portfolios and refreshes both dashboard outputs before the longer tuning stage begins. It then
289290
requires an official daily price row for the decision date, independently tunes and refits Ridge
290-
and XGBoost, saves both new top-40 portfolios, writes
291+
and XGBoost, saves both new top-40 portfolios, freezes a separate temporally selected 25% Ridge /
292+
75% XGBoost percentile-rank ensemble shadow portfolio, writes
291293
`selection_stability_YYYY-MM-DD.json`, and refreshes both dashboards again. The stability report
292294
tracks same-day model overlap, day-over-day retention, top-10 persistence, rank correlation,
293295
entrants/exits, implied replacement, and multi-day consensus names. This ordering makes the

docs/LIVE_FORWARD_MONITOR.md

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,12 @@ The command performs these steps in order:
3030
decision date.
3131
7. Tunes Ridge and XGBoost independently on temporal validation data, refits the selected
3232
configurations, and freezes both top-40 lists for the next evaluation session.
33-
8. Writes model-agreement, day-over-day selection-stability diagnostics, and candidate split
33+
8. Freezes a separate shadow portfolio by blending the two full-universe percentile rankings
34+
with the historically selected 25% Ridge / 75% XGBoost weight and applying the same top-40
35+
sector cap.
36+
9. Writes model-agreement, day-over-day selection-stability diagnostics, and candidate split
3437
features that are not yet eligible for model fitting.
35-
9. Rebuilds both dashboard outputs again so they include the newly frozen pending portfolios.
38+
10. Rebuilds both dashboard outputs again so they include the newly frozen pending portfolios.
3639

3740
If final market data is unavailable, the command retries and then fails closed. It must not create a
3841
snapshot from a partial trading day.
@@ -84,8 +87,10 @@ decision date or its future return.
8487
into an earlier feature row.
8588
- Split features use only completed split events on or before the feature date. Upcoming splits
8689
require a separate timestamped announcement source and are not inferred from future event dates.
87-
- Ridge and XGBoost are displayed together; neither is chosen retrospectively using realized live
88-
returns.
90+
- Ridge, XGBoost, and the shadow ensemble are displayed together; none is chosen retrospectively
91+
using realized live returns.
92+
- The ensemble uses 25% Ridge / 75% XGBoost, selected by expanding historical OOS folds. Each fold's
93+
reported weight used only earlier completed folds; no live realized return selected the weight.
8994
- The July 20 session is a setup/test observation. The official comparison baseline begins at the
9095
July 20 SPY close, and portfolio performance begins on July 21.
9196

@@ -131,6 +136,9 @@ Generated outputs remain under the ignored `reports/live_forward/` directory:
131136

132137
- dated Ridge and XGBoost snapshot directories with manifests, tuning results, predictions, and
133138
selected holdings;
139+
- dated `ridge_xgb_ensemble/` shadow snapshots with normalized component ranks, selected holdings,
140+
and SHA-256 hashes of both source score files;
141+
- a model-aware `live_forward_results.csv` rebuilt from the canonical per-snapshot summaries;
134142
- intraday and close-to-close evaluation files for each frozen portfolio;
135143
- holding-level returns, benchmark-relative returns, and beat-SPY indicators for completed sessions;
136144
- `selection_stability_YYYY-MM-DD.json`;
@@ -144,9 +152,16 @@ one-time repository setting.
144152
Keep source code, configurations, tests, and documentation in Git. Do not commit cached prices,
145153
trained artifacts, generated reports, credentials, or temporary pytest directories.
146154

155+
The consolidated result key is `(model, decision_date, evaluation date, horizon, benchmark)`.
156+
Including `model` prevents one same-session XGBoost observation from overwriting Ridge. If the
157+
table is missing or suspected to be stale, `rebuild_live_forward_results()` reconstructs it from
158+
the intact canonical snapshot JSON files; archived experimental variants are not mixed into the
159+
official live history.
160+
147161
## Interpretation boundary
148162

149-
The live sample is currently too short for a performance claim. Compare Ridge, XGBoost, and SPY over
150-
many independent sessions, then report active return, Rank IC, turnover, drawdown, and uncertainty.
151-
Model overlap is diagnostically useful, but high consensus is neither necessary nor sufficient for
152-
outperformance.
163+
The live sample is currently too short for a performance claim. Compare Ridge, XGBoost, the
164+
predeclared ensemble, and SPY over many independent sessions, then report active return, turnover,
165+
drawdown, and uncertainty. Rank IC remains a component-model diagnostic; it is not available for
166+
the ensemble until paired historical out-of-sample scores are persisted. Model overlap is
167+
diagnostically useful, but high consensus is neither necessary nor sufficient for outperformance.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Ridge-XGBoost Rank Ensemble
2+
3+
## Question
4+
5+
Can Ridge's broad linear ranking and XGBoost's nonlinear top-tail selection be combined without
6+
using the live-forward outcomes to choose the blend?
7+
8+
## Design
9+
10+
- Inputs: paired Ridge and XGBoost OOS predictions retained by the v10 experiment.
11+
- Period: seven chronological outer folds from 2020-01-10 through 2023-06-30.
12+
- Rows: 45,484 ticker-date predictions.
13+
- Normalization: within-date percentile rank for each model.
14+
- Candidate Ridge weights: 0%, 25%, 50%, 75%, and 100%; XGBoost receives the remainder.
15+
- Selection rule: for each outer fold after the first, choose the weight with the best top-40 raw
16+
excess return using only completed earlier OOS folds. The first fold uses 50/50.
17+
- Portfolio rule: 40 equal-weight names with the same 25% sector cap.
18+
19+
This expanding prior-fold rule prevents the evaluated fold's realized returns from selecting its
20+
own weight.
21+
22+
## Results
23+
24+
| Model | Mean Rank IC | Top-40 excess per weekly observation |
25+
|---|---:|---:|
26+
| Ridge | 0.00560 | 0.1436% |
27+
| XGBoost ranker | 0.00222 | 0.2121% |
28+
| Earlier Ridge-LightGBM-XGBoost ensemble | 0.00142 | -0.0760% |
29+
| New temporal Ridge-XGBoost ensemble | 0.00188 | 0.1825% |
30+
31+
After the first fold, the expanding history selected 25% Ridge / 75% XGBoost for every subsequent
32+
fold. Using all completed historical OOS folds, that is also the frozen weight for the next live
33+
shadow portfolio.
34+
35+
## Conclusion
36+
37+
Removing LightGBM repairs the earlier ensemble failure. The two-model blend improves top-40 excess
38+
over Ridge, but does not beat XGBoost's historical point estimate. It remains useful as a
39+
diversification challenger because it reduces reliance on XGBoost without discarding its stronger
40+
top-tail signal. The live test must determine whether that trade-off improves realized stability.
41+
42+
No claim should be made from the weight-selection objective on all historical folds. The primary
43+
historical ensemble estimate is the expanding prior-fold result, and the live portfolio is evaluated
44+
only after it is frozen.

site/assets/dashboard.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

site/data/dashboard.json

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

site/data/manifest.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
{
22
"schema_version": 1,
3-
"generated_at_utc": "2026-07-27T21:49:58.674643+00:00",
3+
"generated_at_utc": "2026-07-27T23:01:45.416516+00:00",
44
"latest_decision_date": "2026-07-27",
5-
"snapshot_count": 13,
5+
"snapshot_count": 14,
66
"evaluation_count": 11,
7-
"dashboard_sha256": "8eef32a85c89066d74e51f15698a92cd733de04ca6cbb48cc29bddb6396b171b",
8-
"dashboard_bytes": 236813,
7+
"dashboard_sha256": "b33b26ce655ddef3a9eab8150a79977584de64bd7c47a45c7549a32c4ad1f906",
8+
"dashboard_bytes": 243869,
99
"source_policy": "sanitized_live_forward_outputs_only"
1010
}

src/cli.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1372,6 +1372,7 @@ def validate_live_dashboard_site_command(site_dir: str) -> None:
13721372
@click.option("--optuna-trials", default=50, show_default=True, type=int)
13731373
@click.option("--ridge-optuna-trials", default=None, type=int)
13741374
@click.option("--xgb-optuna-trials", default=None, type=int)
1375+
@click.option("--ensemble-ridge-weight", default=0.25, show_default=True, type=float)
13751376
@click.option("--optuna-timeout-seconds", default=1800, show_default=True, type=int)
13761377
@click.option("--tuning-folds", default=5, show_default=True, type=int)
13771378
@click.option("--close-retries", default=5, show_default=True, type=int)
@@ -1384,6 +1385,7 @@ def run_live_forward_daily_command(
13841385
optuna_trials: int,
13851386
ridge_optuna_trials: int | None,
13861387
xgb_optuna_trials: int | None,
1388+
ensemble_ridge_weight: float,
13871389
optuna_timeout_seconds: int,
13881390
tuning_folds: int,
13891391
close_retries: int,
@@ -1400,6 +1402,7 @@ def run_live_forward_daily_command(
14001402
from .experiments.live_forward_test import (
14011403
analyze_live_selection_stability,
14021404
build_live_dashboard,
1405+
build_live_rank_ensemble,
14031406
evaluate_live_forward_snapshot,
14041407
generate_intraday_benchmark_report,
14051408
latest_prior_model_snapshots,
@@ -1487,6 +1490,16 @@ def run_live_forward_daily_command(
14871490
optuna_trials=xgb_optuna_trials or optuna_trials,
14881491
**common,
14891492
)
1493+
ensemble = build_live_rank_ensemble(
1494+
ridge["output"],
1495+
xgb["output"],
1496+
output_root=root / "ridge_xgb_ensemble",
1497+
ridge_weight=ensemble_ridge_weight,
1498+
holdings=holdings,
1499+
weight_selection_report=(
1500+
"docs/experiments/2026-07-27-ridge-xgb-rank-ensemble.md"
1501+
),
1502+
)
14901503
stability = analyze_live_selection_stability(root, decision)
14911504
dashboard = build_live_dashboard(root)
14921505
public_site = publish_live_dashboard(root, public_site_dir)
@@ -1499,6 +1512,7 @@ def run_live_forward_daily_command(
14991512
"pretraining_public_site": pretraining_public_site["output_dir"],
15001513
"ridge_output": ridge["output"],
15011514
"xgb_output": xgb["output"],
1515+
"ensemble_output": ensemble["output"],
15021516
"selection_stability": stability["output"],
15031517
"dashboard": dashboard["dashboard"],
15041518
"public_site": public_site["output_dir"],

src/experiments/live_dashboard.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def dashboard_site_assets() -> dict[str, str]:
9696
<section class="section panel"><div class="section-head"><div><h2>Experiment history</h2><p>Newest evaluated evidence is shown first. Filter the archive and sort any column as it grows.</p></div><div class="history-controls"><label>Browse by<select id="historyGroup"><option value="evaluation_date">Evaluation date</option><option value="model">Selected model</option></select></label><label id="historyValueLabel">Evaluation date<select id="historyValue"></select></label><label>Rows<select id="historyLimit"><option value="6">Latest 6</option><option value="12">Latest 12</option><option value="all">All matching</option></select></label></div></div><div id="historyTable"></div></section>
9797
</main><script id="dashboard-data" type="application/json">__DASHBOARD_PAYLOAD__</script><script>
9898
const snapshots=JSON.parse(document.getElementById('dashboard-data').textContent),startDate=document.getElementById('startDate'),endDate=document.getElementById('endDate'),stockDate=document.getElementById('stockDate'),stockModel=document.getElementById('stockModel'),historyGroup=document.getElementById('historyGroup'),historyValue=document.getElementById('historyValue'),historyLimit=document.getElementById('historyLimit'),refreshData=document.getElementById('refreshData'),STATE_KEY='live-forward-dashboard-state-v1';let savedState={};try{savedState=JSON.parse(localStorage.getItem(STATE_KEY)||'{}')}catch{}let historySort=savedState.historySort||{key:'evaluation_date',direction:'desc'},stockSort=savedState.stockSort||{key:'rank',direction:'asc'};function saveState(){const active=document.querySelector('[data-range].active');try{localStorage.setItem(STATE_KEY,JSON.stringify({startDate:startDate.value,endDate:endDate.value,range:active?.dataset.range||'custom',stockDate:stockDate.value,stockModel:stockModel.value,historyGroup:historyGroup.value,historyValue:historyValue.value,historyLimit:historyLimit.value,historySort,stockSort}))}catch{}}
99-
const COLORS={ridge:'#3156d3',xgb_ranker:'#0f9f8f',benchmark:'#d89614'},human=(v='')=>String(v).replaceAll('_',' ').replace(/\b\w/g,c=>c.toUpperCase()),modelName=v=>({ridge:'Ridge',xgb_ranker:'XGBoost ranker'}[v]||human(v)),fmtPct=v=>v==null||Number.isNaN(Number(v))?'n/a':(Number(v)*100).toFixed(2)+'%',fmtNum=(v,d=4)=>v==null||Number.isNaN(Number(v))?'n/a':Number(v).toFixed(d),cls=v=>Number(v)>=0?'pos':'neg';function barEndLabel(value){const parts=String(value).split(' ');if(parts.length<2)return String(value);const date=parts[0].slice(5),time=parts[1];if(time==='16:00:00')return`${date} 16:00`;if(time==='15:30:00')return`${date} 16:00`;const bits=time.split(':').map(Number);return`${date} ${String(bits[0]+1).padStart(2,'0')}:${String(bits[1]).padStart(2,'0')}`}
99+
const COLORS={ridge:'#3156d3',xgb_ranker:'#0f9f8f',ridge_xgb_ensemble:'#7559c7',benchmark:'#d89614'},human=(v='')=>String(v).replaceAll('_',' ').replace(/\b\w/g,c=>c.toUpperCase()),modelName=v=>({ridge:'Ridge',xgb_ranker:'XGBoost ranker',ridge_xgb_ensemble:'Ridge–XGBoost ensemble'}[v]||human(v)),fmtPct=v=>v==null||Number.isNaN(Number(v))?'n/a':(Number(v)*100).toFixed(2)+'%',fmtNum=(v,d=4)=>v==null||Number.isNaN(Number(v))?'n/a':Number(v).toFixed(d),cls=v=>Number(v)>=0?'pos':'neg';function barEndLabel(value){const parts=String(value).split(' ');if(parts.length<2)return String(value);const date=parts[0].slice(5),time=parts[1];if(time==='16:00:00')return`${date} 16:00`;if(time==='15:30:00')return`${date} 16:00`;const bits=time.split(':').map(Number);return`${date} ${String(bits[0]+1).padStart(2,'0')}:${String(bits[1]).padStart(2,'0')}`}
100100
function option(v,t){const e=document.createElement('option');e.value=v;e.textContent=t;return e}function evals(s){const a=[...(s.evaluations||[])];if(s.intraday)a.unshift({...s.intraday,horizon_label:'Intraday monitor',holdings:[]});return a}function chartEval(s){return s.intraday||(s.evaluations||[]).at(-1)||null}function monitorDate(s){const e=chartEval(s);return e?.monitor_date||e?.eval_date||e?.series?.[0]?.timestamp?.slice(0,10)||s.decision_date}
101101
const trackedModels=[...new Set(snapshots.filter(chartEval).map(s=>s.model_id))],dateModelCounts=new Map;snapshots.filter(chartEval).forEach(s=>{const d=monitorDate(s);if(!dateModelCounts.has(d))dateModelCounts.set(d,new Set);dateModelCounts.get(d).add(s.model_id)});const dates=[...dateModelCounts.entries()].filter(([,models])=>models.size===trackedModels.length).map(([d])=>d).sort();if(dates.length){startDate.value=dates.includes(savedState.startDate)?savedState.startDate:dates[0];endDate.value=dates.includes(savedState.endDate)?savedState.endDate:dates.at(-1);startDate.min=endDate.min=dates[0];startDate.max=endDate.max=dates.at(-1)}document.querySelectorAll('[data-range]').forEach(b=>b.classList.toggle('active',(savedState.range||'all')===b.dataset.range));if([...historyGroup.options].some(o=>o.value===savedState.historyGroup))historyGroup.value=savedState.historyGroup;if([...historyLimit.options].some(o=>o.value===savedState.historyLimit))historyLimit.value=savedState.historyLimit;
102102
function filtered(){return snapshots.filter(s=>{const d=monitorDate(s);return chartEval(s)&&(!startDate.value||d>=startDate.value)&&(!endDate.value||d<=endDate.value)})}

0 commit comments

Comments
 (0)