From 5d1800a45c9ee8505d37bb67e68887d2b5002824 Mon Sep 17 00:00:00 2001 From: HanSun103 Date: Wed, 29 Jul 2026 20:06:03 -0400 Subject: [PATCH] Audit README evidence and dashboard links --- README.md | 223 ++++++++++-------- docs/design/DASHBOARD_TWO_PAGE_MERGE.md | 13 +- site/data/manifest.json | 2 +- site/index.html | 2 +- src/experiments/live_dashboard_publish.py | 13 +- .../public_dashboard_validation.py | 5 + tests/test_live_dashboard_publish.py | 2 + 7 files changed, 153 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index c3186a5..f46dd2c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# AI-Driven Portfolio Research & Backtesting System +# AI-Driven Stock Selection & Backtesting System > A leakage-aware research platform for cross-sectional stock selection, > financial-news NLP, realistic execution, and paper-live monitoring. @@ -6,7 +6,6 @@ ![Python 3.11+](https://img.shields.io/badge/Python-3.11%2B-2563eb) ![Research status](https://img.shields.io/badge/status-paper--live_research-d97706) ![Tests](https://img.shields.io/badge/tests-221_passing-0f766e) -![License](https://img.shields.io/badge/license-MIT-64748b) The project asks a narrow investment question: @@ -21,7 +20,7 @@ establish production alpha. **Current experiment:** horizon-matched 5/10/20-session paper-live comparison -**Universe:** point-in-time S&P 500 membership → liquid top 250 → sector-capped top 40 +**Universe:** point-in-time S&P 500 membership, liquid top 250, sector-capped top 40 **Models:** Ridge baseline and independently tuned XGBoost challenger @@ -34,28 +33,28 @@ repeatedly challenging attractive backtests and keeping the failed tests. forecasting, CVaR optimization, pair trading, technical features, and financial-news sentiment on a small present-day stock universe. It looked complete, but the architecture was more mature than the evidence. - [See the original full architecture and experiment trail →](https://github.com/HanSun103/AI-Driven-Portfolio-Optimization-Backtesting-System/tree/archive-experiments) + [See the original full architecture and experiment trail](https://github.com/HanSun103/AI-Driven-Portfolio-Optimization-Backtesting-System/tree/archive-experiments) 2. **The first audit changed the standard.** Fill timing, transaction-cost accounting, label maturity, and optimizer safeguards required correction. That invalidated an early headline result and established the project's fail-closed research discipline. - [Read the first canonical rerun and corrections →](docs/experiments/2026-07-13-core-weekly-v1.md) -3. **The question shifted from “what return?” to “which stocks?”** Weak return + [Read the first canonical rerun and corrections](docs/experiments/2026-07-13-core-weekly-v1.md) +3. **The question shifted from "what return?" to "which stocks?"** Weak return calibration but better cross-sectional ordering led to sector-neutral rank targets, point-in-time membership, nested purged validation, and top-k winner selection. - [Read the repaired rank-40 evidence →](docs/experiments/2026-07-17-repaired-rank40-selection.md) + [Read the repaired rank-40 evidence](docs/experiments/2026-07-17-repaired-rank40-selection.md) 4. **Promising additions had to earn activation.** Sentiment improved several development point estimates, while smart-universe filtering, survival ML, pair trading, topics, and ensembles either failed gates or remained inconclusive. Negative results were retained rather than blended into an ever-larger strategy. - [Explore the dated experiment archive →](https://github.com/HanSun103/AI-Driven-Portfolio-Optimization-Backtesting-System/tree/archive-experiments/docs/experiments) + [Explore the dated experiment archive](https://github.com/HanSun103/AI-Driven-Portfolio-Optimization-Backtesting-System/tree/archive-experiments/docs/experiments) 5. **The live horizon was redesigned.** The one-session monitor accumulated observations quickly but tested the wrong economic horizon and created extreme turnover. A matched historical sweep identified a 10-to-20-session - plateau, with 20 sessions as the leading—not uniquely proven—candidate. - [Read the horizon decision →](docs/experiments/2026-07-28-live-horizon-decision.md) + plateau, with 20 sessions as the leading, but not uniquely proven, candidate. + [Read the horizon decision](docs/experiments/2026-07-28-live-horizon-decision.md) 6. **The project is now a focused paper-live experiment.** The active system refreshes real market/news data, applies strict FinBERT NLP, independently tunes Ridge and XGBoost, freezes daily forecasts, and tracks separately @@ -65,6 +64,99 @@ repeatedly challenging attractive backtests and keeping the failed tests. The long research history remains available through the links above; the rest of this README describes only the final active system and its current evidence. +### Ridge, XGBoost, and ensemble development backtest + +This is the complete portfolio comparison used by the current README +visualization. It covers 182 weekly observations from 2020-01-10 through +2023-06-30. Each model holds 40 equal-weight stocks with a 25% sector cap. +Results include 8 basis points per one-way traded weight and a $10 fixed fee +per trade. + +| Model and portfolio | Model construction | Mean rank IC | Net cumulative return | Net CAGR | Sharpe | Maximum drawdown | +|---|---|---:|---:|---:|---:|---:| +| Ridge | Linear rank model | 0.00560 | 103.88% | 22.57% | 0.77 | -27.37% | +| XGBoost ranker | Nonlinear pairwise rank model | 0.00222 | 190.28% | 35.59% | 0.97 | -39.44% | +| Ridge-XGBoost ensemble | Expanding prior-fold blend; 50/50 in the first fold, then 25% Ridge and 75% XGBoost | 0.00188 | 110.05% | 23.62% | 0.78 | -35.92% | +| SPY benchmark | Buy and hold with the experiment's 0.5% annual management fee | N/A | 39.58% | 10.00% | 0.54 | -34.12% | + +The ensemble improved the top-40 excess-return point estimate over Ridge but +did not beat XGBoost. Its active-return confidence interval crossed zero and +its shuffled-score information-ratio p-value was `0.089`, so the experiment +did not establish a promotion claim. +[Read the Ridge-XGBoost rank ensemble experiment](docs/experiments/2026-07-27-ridge-xgb-rank-ensemble.md) + +## Architecture + +The historical system contained many experimental sleeves. Its +[full architecture is preserved on the archive-experiments branch](https://github.com/HanSun103/AI-Driven-Portfolio-Optimization-Backtesting-System/tree/archive-experiments#architecture). +The active system keeps only the modules needed to test the current +winner-selection and sentiment hypotheses: + +```mermaid +flowchart TD + subgraph INGEST["Market, membership, and news ingestion"] + MARKET["Adjusted OHLCV
SPY and sector ETFs
incremental local cache"] + MEMBERSHIP["Effective-dated S&P 500 membership
sector history and corporate events"] + NEWS["Timestamped financial news
persistent deduplication
source and freshness audit"] + end + + subgraph PANEL["Leakage-safe research panel"] + UNIVERSE["Lagged 63-session liquidity
point-in-time top 250"] + NLP["Entity linking and FinBERT
event tags and recency
15:45 ET availability clock"] + FEATURES["Technical, residual, risk,
sector-relative, sentiment,
event and recency features"] + TARGETS["Sector-neutral rank targets
5, 10, and 20 sessions
next-open to open"] + end + + subgraph LEARN["Horizon-matched model development"] + VALIDATION["Rolling temporal folds
mature labels and horizon purge"] + SELECTION["Fold-local feature ranking
feature-count and Optuna search
XGBoost early stopping"] + MODELS["Independent models
Ridge baseline
XGBoost challenger"] + end + + subgraph PORTFOLIO["Portfolio construction and execution"] + SCORES["Score the eligible
cross-section"] + TOP40["Sector-capped top 40
100% research exposure
equal weight"] + CLOCKS["Matched refit and rebalance
5 to 5 / 10 to 10 / 20 to 20"] + TACTICAL["Shadow tactical sentiment event
next-open routing only
same close disabled by default"] + BUFFER["Rebalance buffer
next experiment, not active"] + end + + subgraph EVIDENCE["Backtesting, evidence, and live monitoring"] + BACKTEST["Point-in-time walk-forward backtest
costs, turnover, benchmark,
return and drawdown"] + COHORTS["Daily immutable forecasts
partial marks and matched maturity"] + TACTICALLOG["Separate tactical ledger
no credit to strategic returns"] + DIAGNOSTICS["IC, quantiles, distributions,
SHAP and shuffled controls"] + DASHBOARD["Two-page live dashboard
stock-level monitor
horizon comparison"] + end + + MARKET --> UNIVERSE + MEMBERSHIP --> UNIVERSE + NEWS --> NLP + MARKET --> FEATURES + UNIVERSE --> FEATURES + NLP --> FEATURES + FEATURES --> TARGETS --> VALIDATION --> SELECTION --> MODELS + FEATURES --> SCORES + MODELS --> SCORES --> TOP40 --> CLOCKS + NLP --> TACTICAL --> TACTICALLOG + BUFFER -. "under audit" .-> TOP40 + CLOCKS --> BACKTEST + CLOCKS --> COHORTS + BACKTEST --> DIAGNOSTICS + TACTICALLOG --> DIAGNOSTICS + COHORTS --> DIAGNOSTICS --> DASHBOARD +``` + +### What happens inside each active module + +| Module | Active code | Responsibility | +|---|---|---| +| Market, membership, and news ingestion | `src/data`, `src/nlp` | Refresh adjusted market history and financial news; retain effective-dated membership and sectors; deduplicate records; preserve source, coverage, timestamp, and freshness audits | +| Leakage-safe research panel | `src/features`, `src/targets` | Form the lagged liquid universe; run strict local FinBERT and event tagging; build technical, cross-sectional, residual, risk, sentiment, and recency features; create horizon-matched rank targets | +| Horizon-matched model development | `src/models/rank_models.py`, `src/experiments/live_forward_test.py` | Purge overlapping labels; rank features inside temporal folds; tune feature count and hyperparameters; apply early stopping; keep Ridge and XGBoost selection independent | +| Portfolio construction and execution | `src/portfolio/sparse_rank.py`, `src/experiments/live_forward_20d.py` | Score the eligible top 250; enforce the sector cap; freeze equal-weight top 40; operate matched refit/rebalance clocks; route qualifying tactical events to the next open; keep same-close routing disabled unless quote and broker-cutoff controls are supplied | +| Backtesting, evidence, and live monitoring | `src/backtest`, `src/experiments` | Simulate next-open execution and costs; mature immutable cohorts; keep tactical attribution separate from strategic returns; report return, IC, turnover, drawdown, distributions, SHAP, and shuffled controls; publish the two-page dashboard | + ## Research findings | Finding | Current evidence | Boundary | @@ -72,7 +164,7 @@ of this README describes only the final active system and its current evidence. | Winner selection is more promising than raw-return prediction | Repaired weekly OOS rank IC `0.0059`; top-40 excess `0.1686%` per week; shuffled-score p-value `0.0099` | Average signal is thin, unstable by year, and the active-return confidence interval crosses zero | | Sentiment appears useful | Technical-plus-sentiment point estimates exceeded technical-only at 5, 10, and 20 sessions | The comparison does not yet prove a causal NLP increment; prior robustness gates were inconclusive | | Twenty sessions is the leading horizon | Full-OOS annualized active point estimates: 5d `4.53%`, 10d `6.65%`, 20d `8.59%` | Twenty versus ten sessions was not statistically distinguishable (`t = 0.33`) | -| Rebalancing behavior is model-specific | Over six July 20–28 sessions, freezing improved Ridge net return by `3.93` points; daily replacement improved XGBoost by only `0.53` points net | Six sessions cannot determine permanent policy; a preregistered buffer comparison is required | +| Rebalancing behavior is model-specific | Over six July 20-28 sessions, freezing improved Ridge net return by `3.93` points; daily replacement improved XGBoost by only `0.53` points net | Six sessions cannot determine permanent policy; a preregistered buffer comparison is required | | Live operation is feasible | Current market/news refresh, strict local FinBERT, purged tuning, frozen forecasts, and immutable manifests completed | The redesigned live forecasts have not matured; there is no live-return claim yet | The detailed decision record is @@ -87,9 +179,9 @@ The detailed decision record is This development backtest compares Ridge, XGBoost, the proposed ensemble, and SPY after the declared research costs. The plot intentionally includes drawdown, return distribution, and turnover: cumulative return alone can hide -fragility and implementation drag. The underlying ensemble experiment was -rejected as a promotion candidate even though some point estimates were -positive. +fragility and implementation drag. The ensemble did not qualify for promotion; +it remains a development challenger because its confidence interval crossed +zero and its shuffled-score test was not decisive. Source: [Ridge/XGBoost full-backtest record](docs/experiments/2026-07-27-ridge-xgb-rank-ensemble.md). @@ -184,13 +276,13 @@ from July 20: | Ridge net | -2.19% | +1.74% | Freeze by 3.93 points | | XGBoost gross | -0.58% | -1.78% | Daily by 1.20 points | | XGBoost net | -1.37% | -1.90% | Daily by 0.53 points | -| SPY | -0.17% | -0.17% | — | +| SPY | -0.17% | -0.17% | N/A | -“Net” uses $1 million starting capital, 8 bps one-way execution cost, and $10 +"Net" uses $1 million starting capital, 8 bps one-way execution cost, and $10 per trade. Daily selection generated approximately 616% Ridge turnover and 662% XGBoost turnover including initial purchases; frozen-name portfolios were near 100%. Frozen equal-weight and buy-and-hold controls were very similar, so -membership changes—not small weight drift—drove most of the difference. +membership changes, rather than small weight drift, drove most of the difference. This result supports removing daily full replacement, but it does **not** prove that every scheduled rebalance should be a complete top-40 refresh. The current @@ -205,15 +297,15 @@ experiment will compare, separately for Ridge and XGBoost: Buffer thresholds must be selected on historical temporal validation and frozen before forward use. The six observed sessions will not choose the threshold. -[Read the complete rebalancing ablation →](docs/experiments/2026-07-29-live-rebalancing-ablation.md) +[Read the complete rebalancing ablation](docs/experiments/2026-07-29-live-rebalancing-ablation.md) -[Review the rebalance-buffer experiment draft →](docs/design/REBALANCE_BUFFER_EXPERIMENT.md) +[Review the rebalance-buffer experiment draft](docs/design/REBALANCE_BUFFER_EXPERIMENT.md) ### Frozen-cohort dashboard The legacy one-session experiment is now a frozen-cohort monitoring track rather than a source of new daily portfolios. Its daily command refreshes prices, -extends the existing July 20–27 Ridge/XGBoost cohorts, rebuilds the local +extends the existing July 20-27 Ridge/XGBoost cohorts, rebuilds the local dashboard, and regenerates the sanitized GitHub Pages bundle: ```bash @@ -221,16 +313,17 @@ python -m src.cli run-live-forward-daily --as-of YYYY-MM-DD ``` The **Holding-period laboratory** can keep any dated list unchanged or compare -custom 1–60-session replacement horizons, including 5/10/20-session presets. +custom 1-60-session replacement horizons, including 5/10/20-session presets. Ridge and XGBoost can be toggled independently while SPY remains permanently visible from the shared decision-close baseline. It reports net return, SPY-relative return, replacement-only turnover, and rebalance counts under identical execution assumptions. -The deployed monitor now has two linked pages with one shared visual shell: +The GitHub Pages workflow publishes two linked pages with one shared visual +shell from `main`: -- [Live Forward Monitor — default view →](https://hansun103.github.io/AI-Driven-Portfolio-Optimization-Backtesting-System/) -- [Horizon Comparison — 5/10/20-session research →](https://hansun103.github.io/AI-Driven-Portfolio-Optimization-Backtesting-System/horizons.html) +- [Live Forward Monitor: default view](https://hansun103.github.io/AI-Driven-Portfolio-Optimization-Backtesting-System/) +- [Horizon Comparison: 5/10/20-session research](https://hansun103.github.io/AI-Driven-Portfolio-Optimization-Backtesting-System/horizons.html) The original Stock-level view remains canonical rather than being duplicated on the horizon page. The two-page contract and evidence boundaries are recorded @@ -241,73 +334,6 @@ experiment described above. The former one-session fitting workflow remains available only through the explicit `--retrain` flag and is not part of the normal monitoring run. -## Architecture - -The historical system contained many experimental sleeves. Its full architecture -is preserved on the -[archive-experiments branch](https://github.com/HanSun103/AI-Driven-Portfolio-Optimization-Backtesting-System/tree/archive-experiments#architecture). -The active system is intentionally smaller: - -```mermaid -flowchart TD - subgraph INPUT["1 · Point-in-time inputs"] - PRICES["Adjusted OHLCV
SPY + sector ETFs"] - MEMBERS["Effective-dated membership
and corporate events"] - NEWS["Timestamped news
persistent deduplicated store"] - end - - subgraph BUILD["2 · Audited signal panel"] - ELIGIBLE["Lagged 63-session liquidity
point-in-time top 250"] - NLP["Entity linking → FinBERT → event tags
15:45 ET availability clock"] - FEATURES["Technical + residual + sector-relative
slow sentiment and recency"] - end - - subgraph LEARN["3 · Horizon-specific learning"] - LABELS["5 / 10 / 20-session
next-open-to-open rank labels"] - VALIDATE["Rolling temporal folds
horizon purge + fold-local selection"] - MODELS["Ridge baseline
XGBoost challenger"] - end - - subgraph DECIDE["4 · Frozen portfolio decisions"] - SCORE["Score eligible cross-section"] - TOP40["Sector-capped top 40
equal weight"] - CLOCKS["Refit / rebalance clocks
5→5 · 10→10 · 20→20"] - BUFFER["Rebalance buffer
candidate, not active"] - end - - subgraph MEASURE["5 · Evidence and monitoring"] - COHORTS["Daily frozen forecast cohorts
matched maturity"] - STRATEGY["Scheduled portfolio returns
gross, net, turnover, drawdown"] - REPORTS["IC · quantiles · SHAP · placebos
immutable manifests and dashboard"] - end - - PRICES --> ELIGIBLE - MEMBERS --> ELIGIBLE - NEWS --> NLP - PRICES --> FEATURES - ELIGIBLE --> FEATURES - NLP --> FEATURES - FEATURES --> LABELS --> VALIDATE --> MODELS - FEATURES --> SCORE - MODELS --> SCORE --> TOP40 --> CLOCKS - BUFFER -. "next experiment" .-> TOP40 - CLOCKS --> COHORTS - CLOCKS --> STRATEGY - COHORTS --> REPORTS - STRATEGY --> REPORTS -``` - -### What happens inside each active module - -| Stage | Active code | Detailed responsibility | -|---|---|---| -| Data and universe | `src/data` | Incrementally refresh adjusted market history; retain effective-dated membership; join sectors backward; calculate lagged dollar-volume eligibility; preserve source, coverage, timestamps, and exclusions | -| Financial NLP | `src/nlp`, `src/features/nlp_features.py` | Fetch and deduplicate news; link securities; score locally with FinBERT without lexical fallback; tag events; map publication time to the first legal decision; aggregate sentiment, mentions, novelty, and recency | -| Features and targets | `src/features`, `src/experiments/forecast_horizon_sweep.py` | Build technical, residual, risk, sector-relative, and NLP predictors; cross-sectionalize without future labels; create matching 5/10/20-session next-open-to-open sector-neutral rank targets | -| Model selection | `src/models/rank_models.py`, `src/experiments/live_forward_test.py` | Rank features inside each temporal fold; search feature count and hyperparameters; purge overlapping labels; apply XGBoost early stopping; keep Ridge and XGBoost independent | -| Portfolio and live clock | `src/portfolio/sparse_rank.py`, `src/experiments/live_forward_20d.py` | Score the point-in-time top 250; enforce the 25% sector cap; freeze equal-weight top 40; run horizon-matched refit/rebalance clocks; route only fully gated tactical events | -| Evaluation and governance | `src/experiments` | Mature frozen cohorts; rebuild scheduled strategy returns; report IC, quantiles, distributions, costs, turnover, drawdown, SHAP, and shuffled controls; refuse to rewrite frozen artifacts | - ## Research controls The main pipeline is designed to fail closed: @@ -318,7 +344,9 @@ The main pipeline is designed to fail closed: - Labels must mature before training and are purged from validation boundaries. - Feature selection and tuning occur inside temporal training folds. - Ridge remains the hard-to-fake baseline. -- Sentiment claims require a technical-only paired arm. +- Historical sentiment comparisons require a technical-only paired arm. The + current six-arm paper-live monitor includes sentiment in every model, so it + cannot by itself attribute future performance specifically to sentiment. - Same-close news execution requires timestamped quotes and a verified broker cutoff; otherwise execution moves to the next open. - Missing real market data, stale news, failed FinBERT inference, constant @@ -385,13 +413,15 @@ because they weaken the preferred narrative. ## Known limitations 1. No redesigned 20-session paper-live forecast has matured yet. -2. The strongest repaired historical test covers roughly 2018–2023, not a - full decade. It therefore spans too few independent market regimes for a +2. The development backtest reported above contains 182 OOS weeks from + 2020-01-10 through 2023-06-30; the underlying data and training history + begin in 2018. The evaluated portfolio period is therefore about three and + a half years, not a full decade, and spans too few independent regimes for a durable claim. Extending point-in-time testing toward at least ten years is a research target, not a guarantee of adequacy: long-window backtests still require regime analysis, untouched confirmation, and correction for the number of strategies tried. See - [CFA Institute’s backtesting guidance](https://www.cfainstitute.org/insights/professional-learning/refresher-readings/2026/backtesting-and-simulation) + [CFA Institute's backtesting guidance](https://www.cfainstitute.org/insights/professional-learning/refresher-readings/2026/backtesting-and-simulation) and the [deflated-Sharpe discussion of sample length and multiple testing](https://academic.oup.com/jrssig/article/18/6/22/7038278). 3. The strongest portfolio results remain development evidence, not a genuine @@ -400,7 +430,8 @@ because they weaken the preferred narrative. sector classification. 5. The average ranking edge is small and varies materially by year and regime. 6. Sentiment is promising, but coverage, timestamp quality, and causal - attribution remain incomplete. + attribution remain incomplete. The current six live model/horizon arms do + not include a technical-only prospective control. 7. The high-recall survival model has not passed its activation gates and remains a shadow watchlist. 8. Same-close tactical execution is unvalidated without intraday quotes and diff --git a/docs/design/DASHBOARD_TWO_PAGE_MERGE.md b/docs/design/DASHBOARD_TWO_PAGE_MERGE.md index 280180b..829a4b2 100644 --- a/docs/design/DASHBOARD_TWO_PAGE_MERGE.md +++ b/docs/design/DASHBOARD_TWO_PAGE_MERGE.md @@ -1,6 +1,7 @@ # Two-page dashboard merge design -Status: local proposal awaiting approval. Do not merge or deploy yet. +Status: implemented. The GitHub Pages workflow publishes production updates +only from `main`. ## Decision @@ -80,9 +81,9 @@ The daily pipeline should: Publishing must be atomic: a cycle should never expose one page with a newer data contract than the other. -## Approval boundary +## Implementation status -The current local Horizon Comparison preview demonstrates the second page. After -approval, the merge work will add the page switcher and v2 arm support to the -original dashboard, generate the final `horizons.html`, update the refresh and -deployment workflows, and then request approval before pushing. +The shared page switcher, v2 arm support, partial-outcome display, collapsed +SHAP panels, final `horizons.html`, daily refresh integration, public payload +validation, and GitHub Pages packaging are implemented. The deployment job +runs only for `main`. diff --git a/site/data/manifest.json b/site/data/manifest.json index 28b9456..2c116dc 100644 --- a/site/data/manifest.json +++ b/site/data/manifest.json @@ -1,6 +1,6 @@ { "schema_version": 3, - "generated_at_utc": "2026-07-29T22:29:54.012984+00:00", + "generated_at_utc": "2026-07-29T23:59:49.062830+00:00", "latest_decision_date": "2026-07-28", "snapshot_count": 17, "evaluation_count": 14, diff --git a/site/index.html b/site/index.html index 6375b63..de17064 100644 --- a/site/index.html +++ b/site/index.html @@ -2,7 +2,7 @@ Live Forward Research Monitor -
AI Portfolio Research

Live Forward Monitor

Out-of-sample model portfolios tracked against SPY. Every session uses the portfolio frozen before its evaluation window.

Default viewDaily evidenceFrozen-cohort monitor
+
AI Portfolio Research

Live Forward Monitor

Out-of-sample model portfolios tracked against SPY. Every session uses the portfolio frozen before its evaluation window.

Default viewDaily evidenceFrozen-cohort monitor
Frozen evidence loaded
diff --git a/src/experiments/live_dashboard_publish.py b/src/experiments/live_dashboard_publish.py index 9b90033..bcdf940 100644 --- a/src/experiments/live_dashboard_publish.py +++ b/src/experiments/live_dashboard_publish.py @@ -333,9 +333,16 @@ def publish_live_dashboard( latest_decision = max(item["decision_date"] for item in snapshots) assets = dashboard_site_assets() - assets["index.html"] = assets["index.html"].replace( - 'href="../live_forward_horizon_matched_v2/dashboard.html"', - 'href="./horizons.html"', + assets["index.html"] = ( + assets["index.html"] + .replace( + 'class="page-link active" href="dashboard.html"', + 'class="page-link active" href="./index.html"', + ) + .replace( + 'href="../live_forward_horizon_matched_v2/dashboard.html"', + 'href="./horizons.html"', + ) ) for relative, contents in assets.items(): target = destination / relative diff --git a/src/experiments/public_dashboard_validation.py b/src/experiments/public_dashboard_validation.py index 1f08834..61e6536 100644 --- a/src/experiments/public_dashboard_validation.py +++ b/src/experiments/public_dashboard_validation.py @@ -50,6 +50,11 @@ def validate_public_dashboard_bundle(output_dir: str | Path) -> dict[str, Any]: exposed = [token for token in forbidden if token in public_text] if exposed: raise ValueError(f"Public dashboard contains forbidden private fields: {exposed}") + index_html = (root / "index.html").read_text(encoding="utf-8") + if 'href="dashboard.html"' in index_html: + raise ValueError( + "Public dashboard must link its default page through index.html" + ) payload = json.loads(dashboard_text) snapshots = payload.get("snapshots", []) if isinstance(payload, dict) else payload diff --git a/tests/test_live_dashboard_publish.py b/tests/test_live_dashboard_publish.py index 73d3a53..d7b3ff7 100644 --- a/tests/test_live_dashboard_publish.py +++ b/tests/test_live_dashboard_publish.py @@ -153,6 +153,8 @@ def test_publish_site_splits_assets_and_passes_validation( horizons = (site / "horizons.html").read_text(encoding="utf-8") assert '' in index assert '' in index + assert 'href="./index.html" aria-current="page"' in index + assert 'href="dashboard.html"' not in index assert 'href="./horizons.html"' in index assert 'href="./index.html"' in horizons assert "Horizon Comparison" in horizons