A walk-forward evaluation framework for next-day equity return forecasting, comparing a tree ensemble, a bidirectional LSTM, a time-series transformer and a stacked meta-ensemble against five baselines.
The headline result is a null. Across 30 US large caps and 2010–2026, none of the models forecasts next-day returns better than trivial baselines. No model achieves positive out-of-sample R², none beats its majority class, and none has statistically significant alpha after correcting for multiple testing.
That is the finding, not a failure of the implementation. The value of this repository is the evaluation protocol that makes such a result legible — and the documented case study of what the same models reported before that protocol existed.
An earlier version of this project reported R² = 0.9627 and MAPE = 9.89% and described itself as achieving state-of-the-art precision. Those numbers were real arithmetic on a broken target.
| Before (price target) | After (return target) | |
|---|---|---|
| Target | next-day close price | next-day log return |
| Best model, primary metric | R² = 0.9627 | R²_OOS = +0.0011 (fold median; mean −0.0022 ± 0.0109, pooled −0.0003) |
| Naive / zero-return baseline | R² = 0.9992, MAPE 1.23% | R²_OOS = −0.0023 (fold median; pooled −0.0028) |
| Does the best model beat the baseline? | No — and the metric hid it | Not distinguishably — a thousandth above a benchmark at zero, with a fold sd of 0.011 and DM p = 0.47 |
Both runs use identical frozen data (docs/frozen_aapl_raw.csv) and an
identical 12-fold configuration.
The old model was asked to predict tomorrow's price while being given today's price as a feature. It learned the identity function. On a trending price series, R² measures the price series — so the naive "tomorrow equals today" baseline scored higher than every model (R² 0.9992, MAPE 1.23%) while the best model managed 0.9627 and 9.89%. Every model was worse than doing nothing, and the metric could not show it.
Full tables: docs/baseline_before_refactor.csv,
docs/baseline_after_refactor.csv.
This is a null result on a narrow question. It is not any of the following.
It does not claim markets are efficient. Efficiency is a theory about all information and all horizons. This tests 31 technical and macro features on one horizon.
It does not claim equity returns are unpredictable. A large literature finds predictability at monthly and annual horizons, in the cross-section rather than the time series, and conditional on variables absent here (valuation ratios, order flow, earnings revisions, sentiment, options-implied measures). Nothing here speaks to any of that.
It does not claim deep learning cannot work in finance. Two architectures, one hyperparameter configuration each, one seed for the headline and five for the variance study. A negative result at this scale is evidence about these runs, not about a model class.
It does not claim these models are correctly specified. The neural models were given a 90-day lookback and default-ish hyperparameters. Optuna search was available but disabled for the reported runs. A better-tuned model might do better; this repository does not test that.
The claim is narrow and specific: with these features, this horizon, this universe and this protocol, the models do not beat trivial baselines, and the protocol is strict enough that had they done so, the result would be credible.
AAPL, 2010-03-16 → 2026-09-03, 12 expanding walk-forward folds, common
evaluation window of 2520 forecast days (2016-05-27 → 2026-06-05). R²_OOS is
reported three ways from the same per-fold series: the fold median is the
headline, the fold mean ± sd shows the spread, and the pooled value (a
variance-weighted ratio that the worst fold dominates; see section 8.3) is
secondary. results/fold_aggregates_headline.csv,
python analysis/fold_aggregates.py.
| Model | DA (%) | Majority (%) | DA − majority (pp) | PT p | R²_OOS, fold median | R²_OOS, fold mean ± sd | R²_OOS, pooled | RMSE (bps) |
|---|---|---|---|---|---|---|---|---|
| Tree Ensemble | 52.82 | 54.01 | −1.19 | 0.0586 | −0.0081 | −0.0152 ± 0.0326 | −0.02037 | 183.4 |
| BiLSTM ᵃ | 52.47 | 54.01 | −1.54 | 0.7159 | −0.0009 | −0.0043 ± 0.0113 | −0.00499 | 182.0 |
| Transformer ᵃ | 52.60 | 54.01 | −1.41 | 0.7065 | −0.0003 | +0.0011 ± 0.0074 | +0.00102 | 181.5 |
| Ridge (returns) | 53.28 | 54.01 | −0.73 | 0.2698 | 0.0000 | +0.0004 ± 0.0033 | +0.00124 | 181.5 |
| Logistic (direction) | 53.88 | 54.01 | −0.13 | 0.5217 | −0.0004 | −0.0008 ± 0.0027 | −0.00027 | 181.6 |
| Hybrid meta (+VIX) ᵇ | 52.65 | 54.01 | −1.37 | 0.7480 | +0.0011 | −0.0022 ± 0.0109 | −0.00029 | 181.6 |
| Hybrid meta (no VIX) ᵇ | 52.30 | 54.01 | −1.71 | 0.5926 | +0.0010 | −0.0016 ± 0.0120 | −0.00197 | 181.8 |
| Zero return | 45.99 | 54.01 | −8.02 | — | −0.0023 | −0.0037 ± 0.0063 | −0.00276 | 181.8 |
| Historical mean | 54.01 | 54.01 | 0.00 | — | 0.0000 | 0.0000 ± 0.0000 | 0.00000 | 181.6 |
| AR(1) returns | 53.71 | 54.01 | −0.30 | 0.2355 | −0.0027 | −0.0028 ± 0.0063 | −0.00347 | 181.9 |
| ARIMA(5,0,0) returns | 52.05 | 54.01 | −1.96 | 0.8962 | −0.0082 | −0.0066 ± 0.0101 | −0.00753 | 182.3 |
| Random sign | 50.26 | 54.01 | −3.75 | 0.2745 | −0.8630 | −1.1199 ± 0.5787 | −0.89480 | 249.9 |
ᵃ Single seed (42) drawn from a distribution measured over 5 seeds.
DA (%) R²_OOS PT p BiLSTM range 51.46 – 53.91 −0.0102 – −0.0032 0.014 – 0.893 Transformer range 52.27 – 52.95 −0.0106 – +0.0010 0.201 – 0.696 Under the pooled aggregation the Transformer's +0.00102 was the only positive R²_OOS; under the fold median it is −0.0003 and the two meta rows are +0.0011 and +0.0010. Which row sits a thousandth above zero depends on the aggregation, and every one of them is inside a seed range or a fold sd that crosses zero. None is a positive result.
ᵇ Single seed, range not measured. The meta-learner's inputs are the base-model predictions, so it inherits their seed variance and adds the meta-fit on top. Its true spread is at least as wide as the base models' and was not quantified.
Not one model beats the majority class. Always predicting "up" scores 54.01%; the best model reaches 54.04% on the fixture and 52.82% here. Directional accuracy against 50% is meaningless on a series that rises 54% of the time.
No Diebold-Mariano test against the zero-return baseline is significant on
2520 shared days — the smallest p-value is 0.177 (Transformer). Random sign
is significant at p<0.0001 in favour of the baseline, which is the sanity
check behaving.
30 US large caps across sectors, same 12-fold configuration, seed 42.
595,224 predictions persisted to results/predictions/.
Tree Ensemble across all 30:
| mean | median | sd | min | max | |
|---|---|---|---|---|---|
| DA − majority (pp) | −2.17 | −2.41 | 1.33 | −4.16 | +0.15 |
| R²_OOS, pooled | −0.116 | −0.096 | 0.074 | −0.343 | −0.032 |
| R²_OOS, fold median | −0.047 | −0.042 | 0.021 | −0.107 | −0.014 |
| PT p-value | 0.440 | 0.408 | 0.312 | 0.021 | 0.932 |
| t(alpha) | −0.84 | −0.64 | 1.12 | −2.64 | +0.99 |
| count | |
|---|---|
| Tickers with R²_OOS > 0 | 0 / 30 — pooled, fold mean and fold median alike (results/fold_aggregates_sweep.csv) |
| Tickers with t(alpha) > 1.96 | 0 / 30 |
| Tickers beating majority class | 3 / 30 (by ≤ 0.15pp) |
Does any ticker break the null?
| test | raw p<0.05 | expected by chance | Holm-corrected | P(≥ k ‖ null) |
|---|---|---|---|---|
| Pesaran-Timmermann | 2 / 30 (AVGO, META) | 1.5 | 0 / 30 | 0.446 |
| alpha | 8 / 30 | 1.5 | 0 / 30 | <0.001 |
The two linear comparators (src/linear_models.py; results/comparators/)
on the same thirty tickers: R²_OOS within 0.017 of zero by fold median on
every ticker, positive on 12 (ridge) and 18 (logistic) of 30, which is what a
forecast of the historical mean scores; DA − majority mean −0.48 and −0.28 pp;
PT p < 0.05 on 5 of 28 and 4 of 28 defined tests (four comparator runs predicted
up every day and have no test). The binomial reference assumes independent tests; a
simulated null of equicorrelated statistics (analysis/correlated_hit_null.py,
results/hit_count_null.csv) gives P = 0.012 / 0.084 / 0.099 for the ridge's 5 at
ρ = 0 / 0.3 / 0.5 and 0.024 at the correlation of the PT statistics measured by block
bootstrap (0.04; the returns themselves correlate 0.36, but the statistic nets the
market component out); 0.049 / 0.123 / 0.128 / 0.062 for the logistic's 4; and for the
pooled 11 of 90, 0.006 independent, 0.126 at ρ_ticker 0.3 / ρ_model 0.8, 0.017 at the
measured 0.03 / 0.20. All eleven are positive-association hits (lower tail 0 of 86;
mean statistic +0.46, results/mean_pt_null.csv), but only 8 of the 11 beat their majority
rate pooled and 4 by fold median. Holm within model 0 and 1; Holm across all 90 direction
tests, 0; alpha hits 1+1 and 0+2 of 30 (negative + positive), none
surviving Holm. Paper §6.2 discusses the excess of uncorrected directional hits under both nulls; the one within-model Holm survivor (logistic, NKE, +2.14 pp pooled) has a fold-median edge of −0.66 pp.
Two PT hits against 1.5 expected is chance. The eight alpha hits look alarming until you check the sign: all eight are t < −1.96, none positive. The tree underperforms a rising market because it holds ~50% exposure at beta 0.44–0.65; that is a shortfall, not skill.
Five seeds each of the BiLSTM and Transformer on AAPL, same 12-fold configuration.
The statistic is mean|arch gap| / mean(seed sd). Its null distribution — both
architectures identical, five seeds each — was obtained by simulation, not a
closed form (400,000 trials of two iid normal samples at n=5):
null median 1.22
null 90% interval [0.67, 1.77]
| metric | observed ratio | p (one-tailed) | inside 90% interval |
|---|---|---|---|
| R²_OOS | 1.66 | 0.083 | yes |
| Directional accuracy | 1.69 | 0.072 | yes |
| sd of predicted returns | 0.62 | 0.036 | no (lower tail) |
The architecture difference is not distinguishable from seed noise. Both 1.66 and 1.69 fall inside the interval you would get if the two architectures were identical.
The 0.62 on prediction dispersion falls below the interval, which would suggest reseeding moves that quantity more than switching architecture does. This is not claimed: it is a single comparison at n=5, one of three reported, and it is not corrected for that.
Two apparent positive findings dissolve across seeds:
- Transformer R²_OOS changes sign across seeds (−0.0106 to +0.0010).
- BiLSTM seed 4 reports PT p = 0.0137, the only configuration in this project beating its majority class with a significant PT statistic. Its other four seeds give 0.33, 0.52, 0.71, 0.89. The seed study ran 10 tests (2 architectures × 5 seeds); the expected number of hits at p<0.05 is 0.5 and P(at least one) is 0.401. This is the expected chance hit.
Long/flat on the predicted sign, 7.5bps round-trip, buy-and-hold on the same 2520 days.
| Model | Alpha ann. | t(alpha) | p Holm | Beta | Exposure | Sharpe net |
|---|---|---|---|---|---|---|
| Tree Ensemble | +0.0740 | 1.71 | 0.873 | 0.663 | 0.608 | 1.166 |
| Hybrid meta (+VIX) | −0.0419 | −1.88 | 0.667 | 0.937 | 0.888 | 0.861 |
| Transformer | +0.0156 | 0.39 | 1.000 | 0.742 | 0.845 | 0.963 |
| Ridge (returns) | +0.0390 | 1.23 | 1.000 | 0.860 | 0.846 | 1.115 |
| Logistic (direction) | −0.0162 | −1.05 | 1.000 | 0.971 | 0.985 | 0.973 |
| BiLSTM | −0.0257 | −0.93 | 1.000 | 0.899 | 0.848 | 0.896 |
| Historical mean | −0.0000 | −1.00 | 1.000 | 1.000 | 1.000 | 1.045 |
| Buy and hold | 0.0000 | — | — | 1.000 | 1.000 | 1.045 |
Zero strategies significant, corrected or uncorrected. Simulating the null
gives E[max‖t‖] = 1.92 across the eleven tests if independent and 1.44 at ρ=0.8
(P(max‖t‖ ≥ 1.71) = 0.63 and 0.29), so the tree's t = 1.71 is what no-alpha
looks like when you look eleven times. The count is read from the economics
table (results/aapl_economics_holm.csv), never typed in.
Raw Sharpe cannot distinguish skill from market exposure: Historical mean has
beta 1.000 and exposure 1.000 — it is buy-and-hold, holding a long position
99.9% of the time. The tree's net Sharpe of 1.166 against buy-and-hold's 1.045
is the other face of the same point: it compares a position with beta 0.663
and 61% exposure against one with beta 1 and 100%, so the gap is not a
like-for-like measure of skill. The market-adjusted alpha is, and the table
above (t = 1.71, Holm p = 0.698, inside the null's expected maximum) and the
stress table below (t = 1.43 at 10 bps; negative under a one-day lag at any
cost) show what that alpha is worth.
The tree's alpha survives neither stress:
| lag | cost (bps) | alpha ann. | t | p |
|---|---|---|---|---|
| 0 | 0.0 | +0.1101 | +2.55 | 0.011 |
| 0 | 7.5 | +0.0740 | +1.71 | 0.087 |
| 0 | 10.0 | +0.0619 | +1.43 | 0.152 |
| 1 | 0.0 | −0.0179 | −0.41 | 0.682 |
| 1 | 7.5 | −0.0539 | −1.24 | 0.217 |
Only the zero-cost, zero-lag corner is significant. A one-day execution lag flips the sign negative at every cost level, including zero cost.
Each fold trains on one return distribution and is scored on the next. A KS test on all 357 fold-ticker pairs:
KS rejects at 5%: 119/357 = 33.3% (expected under no shift: 5.0%)
Regressing per-fold performance on the fold's KS statistic:
| outcome | slope | t | p | R² |
|---|---|---|---|---|
| R²_OOS ~ KS | −1.576 | −6.84 | <0.0001 | 0.116 |
| DA (%) ~ KS | −11.65 | −2.09 | 0.038 | 0.012 |
| folds | mean R²_OOS | mean DA | |
|---|---|---|---|
| KS not significant | 238 | −0.0505 | 50.82 |
| KS significant | 119 | −0.1419 | 49.69 |
Welch t = −4.10, p = 0.0001 on R²_OOS; Mann-Whitney p = 0.0003. The per-fold
series and both tests are committed as results/fold_r2_oos.csv and
results/shift_tests.csv.
Shifted folds are ~2.8× worse. But KS explains 11.6% of R²_OOS variance and only 1.2% of DA variance: distribution shift explains how much the models lose, not why they have no directional signal.
Every figure below is drawn from the numbers in the results tables — the
committed per-fold predictions under results/, the frozen comparison CSVs
under docs/, and nothing else. No figure computes a metric of its own; where
one needs a derived series (an equity curve from daily returns) it calls the
same evaluation function the tables used, on the same inputs, so a picture
cannot disagree with its table.
Two ways they are produced:
- Every pipeline run writes the per-run set to
images/(gitignored, regenerated each time) unless--no-plotsis passed. python analysis/make_figures.pyrebuilds the committed set below intodocs/figures/fromresults/anddocs/, without retraining anything.python analysis/freeze_headline_predictions.pyre-runs the §3.1 headline configuration on the frozen CSV and writes its per-fold predictions toresults/headline/AAPL__frozen__seed42.parquet, the committed source of every AAPL figure. About an hour on CPU; only needed if a model changes.
Provenance. Each subsection below names its source. The AAPL figures in §4.2 are built from
results/headline/AAPL__frozen__seed42.parquet: the §3.1 headline run itself, re-executed ondocs/frozen_aapl_raw.csvwith the same 12-fold configuration and seed, so their fold boundaries are Table 1's (fold 0's test window opens 2014-05-29).make_figures.pyasserts two things before drawing: the parquet's fold-0 realised returns equal the frozen dataset's, and the comparison table rebuilt from the parquet equalsdocs/baseline_after_refactor.csv, which is Table 1, to the printed digits.tests/test_headline_provenance.pyruns the same two checks. The cross-ticker figures in §4.3 come from the 30-ticker sweep, where every ticker sits on its own fetch grid and no offset arises. The sweep's AAPL fetch cache is a different file from the frozen CSV, and its fold grid opens 49 trading days earlier (2014-03-19); the two figures that use that run, the five-seed study and the ringed AAPL point in the cross-ticker strip, state the offset in their own titles rather than relying on this note.
Source: results/headline/AAPL__frozen__seed42.parquet and docs/frozen_aapl_raw.csv.
| figure | what it shows |
|---|---|
aapl_predicted_vs_realised.png |
Predicted vs realised next-day return, one panel per model. Every model is a horizontal band. Correlations are −0.012 to +0.068 across the seven panels; prediction spread is 2–23% of realised spread. This is the central picture. |
aapl_prediction_dispersion.png |
Distribution of each model's predictions beside the realised distribution. The models that pick a level rather than forecast collapse to a spike. |
aapl_model_comparison.png |
Directional accuracy minus the majority-class rate, and R²_OOS, per model, with zero lines. Models first, baselines last. |
aapl_alpha_beta.png |
Annualised alpha with its t-statistic (red if |t| > 1.96 — none is) and beta against buy-and-hold. Historical mean has beta 1.000: it is the market. |
aapl_equity_curves.png |
Net-of-cost growth of 1 for each long/flat strategy against buy-and-hold, log scale. The historical-mean curve sits on buy-and-hold: it is always long. The tree's Sharpe (1.17) edges buy-and-hold's (1.04), but its alpha t-statistic is +1.71, inside the noise band (see aapl_alpha_beta.png); the meta curves sit below buy-and-hold. The flat stretches in the tree and meta curves through 2021 are time out of the market. |
aapl_r2_oos_by_fold.png |
R²_OOS per walk-forward fold, per model. Shows the instability that the pooled number hides. |
aapl_da_minus_majority_by_fold.png |
The same for directional accuracy minus majority. |
aapl_calibration.png |
Empirical coverage of the 95% interval per fold, quantiles from earlier folds only, against the nominal line. |
aapl_walk_forward_folds.png |
Price history with each fold's test span shaded: 12 expanding folds, test always after train. |
Source: results/predictions/, results/seeds/, results/fold_diagnostics.csv
and docs/baseline_*.csv. Only the before/after pair is on the frozen data.
| figure | what it shows |
|---|---|
cross_ticker_null.png |
Tree Ensemble on 30 US large caps: DA − majority, R²_OOS and t(alpha) as strip plots, AAPL ringed. 0/30 above zero on R²_OOS; 0/30 with t > +1.96; 8/30 with t < −1.96. The ringed AAPL is the sweep's own run, on the sweep grid (fold 0 opens 2014-03-19, 49 trading days before Table 1's), as the title states. |
seed_variance.png |
Five seeds each of BiLSTM and Transformer, run on the sweep's fetch cache and therefore on the sweep grid; the title states the 49-day offset from Table 1. The Transformer's R²_OOS straddles zero across seeds; the DA clouds overlap. Architecture is not distinguishable from seed noise. Requires results/raw/ to rebuild. |
shift_vs_r2_oos.png |
Per-fold R²_OOS against the fold's train-vs-test KS statistic, all 357 fold-ticker pairs, shifted folds in red with the OLS fit. Distribution shift predicts the magnitude error (slope −1.58, t = −6.84) — but the fit's R² = 0.116 is printed on the figure so the ceiling is visible: shift explains about a ninth of the R²_OOS variance and almost none of the directional error. Requires results/raw/ to rebuild. |
flowchart TB
subgraph DATA["Data layer — src/fetch_data.py"]
Y["Yahoo Finance<br/>OHLCV plus GSPC, VIX, TNX"]
FE["engineer_features<br/>31 scale-free features<br/>ffill only, never bfill"]
FC["FEATURE_COLUMNS<br/>explicit whitelist"]
end
subgraph CONTRACT["Contract — src/dataset.py + src/contracts.py"]
BD["build_dataset<br/>y = log Close t+1 over Close t<br/>the ONLY target definition"]
DS["Dataset<br/>X, y, feature_date, target_date, close_t"]
end
subgraph SPLIT["Validation — src/walk_forward.py"]
WF["WalkForwardSplitter<br/>expanding window<br/>train always precedes test"]
end
subgraph BASE["Base models — predict y, keyed by target_date"]
TR["Tree Ensemble<br/>XGBoost + Random Forest"]
LS["BiLSTM<br/>3 layers, 90-day lookback"]
TF["Transformer<br/>4 heads, 90-day lookback"]
end
subgraph BASE2["Baselines — same contract"]
B1["Zero return"]
B2["Historical mean"]
B3["AR(1) and ARIMA on returns"]
B4["Random sign"]
end
subgraph META["Meta-ensemble — src/meta_ensemble.py"]
MF["build_meta_frame<br/>VIX read at feature_date"]
ST["fit_stacked_meta<br/>fold k sees only folds 0 to k-1"]
end
subgraph EVAL["Evaluation — src/evaluate.py + src/backtest.py"]
CW["common_evaluation_window<br/>intersection of all target_dates"]
EV["Return-space metrics<br/>DA vs majority, PT, R2_OOS, DM"]
BT["Economics<br/>alpha, beta, exposure, Holm"]
end
VAL["validate_predictions<br/>schema, no NaN,<br/>unique sorted target_date"]
Y --> FE
FE --> FC
FC --> BD
BD --> DS
DS --> WF
WF --> TR
WF --> LS
WF --> TF
WF --> B1
WF --> B2
WF --> B3
WF --> B4
TR --> MF
LS --> MF
TF --> MF
MF --> ST
ST --> CW
TR --> CW
B1 --> CW
B2 --> CW
CW --> EV
CW --> BT
TR -.-> VAL
LS -.-> VAL
TF -.-> VAL
ST -.-> VAL
B1 -.-> VAL
flowchart LR
F0["Fold 0<br/>train 2010-2014<br/>test 2014-2015"]
F1["Fold 1<br/>train 2010-2015<br/>test 2015-2016"]
F2["Fold 2<br/>train 2010-2016<br/>test 2016-2017"]
F3["Folds 3 to 11<br/>..."]
X0["meta: NO PREDICTION<br/>no prior folds"]
X1["meta: NO PREDICTION<br/>only 1 prior fold"]
M2["meta fitted on<br/>folds 0 and 1"]
P2["meta predicts fold 2"]
W["Common evaluation window<br/>folds 2 to 11 only<br/>ALL models restricted to it"]
F0 --> X0
F1 --> X1
F0 --> M2
F1 --> M2
M2 --> P2
F2 --> P2
P2 --> W
F3 --> W
X0 -. excluded .-> W
X1 -. excluded .-> W
A meta-model for fold k is fitted only on folds 0 … k−1, so the first two
folds produce no meta-prediction. Every primary comparison then runs on the
intersection of all models' forecast days. Without this, the base models
would be scored across two extra folds the meta-learner never saw — on the
AAPL fixture those folds were the 2020 crash, at 3.22% daily realised
volatility against 1.92% elsewhere.
Everything depends on one convention, enforced by src/contracts.py.
| Quantity | Definition |
|---|---|
feature_date[t] |
Trading day t. All features use information available at the close of day t. |
target_date[t] |
Trading day t+1. The day being forecast. |
close_t[t] |
Close[t], the last observed price. Known at forecast time. |
y[t] |
log(Close[t+1] / Close[t]), the next-day log return. The target. |
Every model returns a frame with exactly target_date, fold_id, close_t, y_true, y_pred, keyed by target_date. validate_predictions() asserts
the schema, rejects NaNs and infinities, and requires target_date to be
unique and sorted. It is called at the end of every train function and fails
loudly.
Keying by target_date is what removes the off-by-one between a sequence model
consuming a 90-day window and a tree consuming one row. A test asserts all
three base models forecast exactly the same set of days.
Price is a display quantity only: close_hat[t+1] = close_t[t] * exp(y_hat[t]).
No primary metric is computed on reconstructed prices.
| Control | Mechanism |
|---|---|
| Truncation invariance | Every feature at row t must be identical whether computed on full history or history truncated at t. Catches centred windows, global fits, and any forward reference. |
| Mutation-tested | Six known leaks are injected into a copy of the feature code and each must be caught (tests/test_leakage_mutations.py). A test that never fails is not a test. |
| No backward fill | ffill() only. Verified behaviourally on a fixture whose macro series starts after the price series, since bfill can only alter leading NaNs. |
| Explicit feature set | FEATURE_COLUMNS is a whitelist. Every raw column is banned as a feature unless named in LEVEL_FEATURES_ALLOWED, so a new raw column is banned by default. |
| Target correlation bound | No feature may correlate above 0.5 with the next-day return. Observed maximum 0.205 (SP500_Return_1d, short-horizon reversal). |
| Fold-respecting stacking | Meta-model for fold k fitted only on folds 0…k−1. |
| Scalers fitted on training folds only | StandardScaler, not MinMax: MinMax maps the training range onto [0,1] and puts every larger test move outside it. |
| Offline tests | yfinance is imported lazily inside _get_ticker(); a subprocess test asserts it is not in sys.modules after importing the data layer. |
Test suite: 341 tests. The most important is
test_features_do_not_depend_on_future_rows.
These are material and are not worked around.
Models are refitted once per fold — once per year. A model trading in May 2026 was last fitted on data ending June 2025. Real deployment would refit monthly or daily. This is the single largest gap between this evaluation and practice, and it plausibly explains part of the poor performance: the distribution-shift analysis shows a third of folds train on a materially different return distribution from the one they are scored on, and a staler model suffers more. The direction of this bias is against the models.
The 30 tickers are all names that still trade today. Companies delisted, acquired or bankrupted over 2010–2026 are absent. This biases every cross-sectional aggregate upward — the surviving universe outperformed. The sweep prints this warning on every run. Fixing it requires point-in-time index constituents including delisted names, which this project does not have.
| Limitation | Detail |
|---|---|
| One market, one horizon | US large-cap equities, next-day. No other geography, asset class or horizon. |
| Single seed for headline results | Only the 5-seed AAPL study quantifies neural variance. Cross-ticker results are seed 42 only. |
| Hyperparameters not tuned | Optuna support exists but was disabled. One configuration per architecture. |
| No transaction-cost model beyond a flat spread | 7.5bps round-trip, no market impact, no borrow cost for shorts. |
| Long/flat only in reported results | Long/short is implemented but not the reported configuration. |
| Sixteen years of history | 2010-03-16 → 2026-09-03 gives 12 annual folds, 10 of them in the common window. Fold count, not calendar length, is what limits the DM and Holm procedures; a longer sample would put pre-2010 market structure in every fold's training set under the expanding window and worsen survivorship bias. Per-fold R²_OOS shows no relationship with training size (slope +2.0e-06, t = +0.21, p = 0.838, results/fold_r2_oos.csv), though fold index confounds training size with era. A rolling window was not tested. |
| 12 folds, not 57 | The frozen comparison uses --test-size 252 for tractability. The default 63-day config gives 57 folds and takes ~11–45h per run. |
| Pooled R²_OOS depends on where the fold boundaries fall | Same AAPL tree, same code, same seed: pooled R²_OOS −0.025 on the frozen grid and −0.121 on the sweep grid (49 days earlier). The data is not the cause (results/grid_vs_data.csv); one fold whose boundary sits on the March 2020 low is. Across ten grid offsets the pooled value ranges over 0.089, the fold median over 0.029 (results/grid_offset_sweep.csv). The cross-ticker pooled mean is grid-conditional (−0.122 vs −0.032 on five tickers); the 0/30 null is not (results/grid_conditional_tickers.csv). |
| Recursive multi-step forecasting is a demo | --demo-forecast is excluded from every metric. Each step feeds a synthetic bar back into the features and errors compound. |
Every number in section 3.1 comes from one command on frozen data.
git clone https://github.com/harshitt13/Stock-Market-Prediction-Model.git
cd Stock-Market-Prediction-Model
git checkout refactor/returns-pipeline
pip install -r requirements.txt
# Table 1 — AAPL headline, no network access, ~100 min
python src/main.py --ticker AAPL \
--raw-csv docs/frozen_aapl_raw.csv \
--min-train 1008 --test-size 252 --step-size 252 --no-plots| Artefact | Value |
|---|---|
| Results commit | 3fa7f8b (seeds), baeca9f (sweep) |
| Frozen input | docs/frozen_aapl_raw.csv |
| SHA-256 | 213808b8e187e49a6a0406e5d217428b1ded57848409d42a723945b950a57ac5 |
| Shape | 4144 rows × 34 cols, AAPL 2010-03-16 → 2026-09-03 |
| Seed | 42 (headline), 0–4 (variance study) |
| Environment | requirements-lock.txt (pip freeze of the environment that produced the results) |
| Meta-learner fits | results/AAPL__frozen__seed42_meta_fits.csv (per-fold ridge penalty, intercept, coefficients; python analysis/persist_meta_fits.py) |
| Shift tests | results/fold_r2_oos.csv, results/shift_tests.csv, results/tree_exposure.csv (python analysis/distribution_shift_and_exposure.py; needs the gitignored results/raw/) |
| Runtime | ~100 min, single machine, CPU only |
The --raw-csv flag exists so a run reproduces exactly the data an earlier run
saw. The before/after comparison in section 1 uses this file for both runs,
so it measures the code change and nothing else.
Reproducing the rest:
python src/fetch_universe.py # cache 30 tickers, ~3 min
python src/experiments.py --parallel --workers 6 # sweep, ~270 min
python seed_study.py # 5 seeds AAPL, ~240 min
python analysis/cross_ticker_sweep.py # section 3.2
python analysis/seed_variance.py # section 3.3
python analysis/alpha_correction_and_window.py # section 3.4
python analysis/distribution_shift_and_exposure.py # section 3.5
python analysis/build_paper.py # docs/paper/build/paper_full.md and .docxresults/predictions/, results/seeds/ and results/headline/ are committed,
so every analysis script runs without retraining. results/raw/ is gitignored (86MB,
regenerable).
| Path | Purpose |
|---|---|
src/fetch_data.py |
Data acquisition and leak-free feature engineering. FEATURE_COLUMNS. |
src/dataset.py |
build_dataset() — the single definition of the target and alignment. |
src/contracts.py |
Prediction contract, validation, common evaluation window. |
src/walk_forward.py |
Expanding-window splitter. |
src/tree_model.py src/lstm_model.py src/transformer_model.py |
Base models. |
src/baselines.py |
Zero return, historical mean, AR(1), ARIMA, random sign. |
src/meta_ensemble.py |
Fold-respecting stacking, VIX gating, interval calibration. |
src/evaluate.py |
Return-space metrics, Pesaran-Timmermann, R²_OOS, Diebold-Mariano. |
src/backtest.py |
Economics: alpha/beta, Holm correction, cost and lag stress. |
src/linear_models.py |
Two well-regularised linear comparators (RidgeCV on returns, L2 logistic on direction) on the same contract. |
src/experiments.py |
Multi-ticker sweep, parallel execution, parquet persistence. |
src/main.py |
CLI orchestrator. |
analysis/ |
One-shot analyses that read results/ without retraining. |
docs/ |
Frozen input and the before/after comparison tables. |
tests/ |
341 tests, including the mutation-tested leakage suite. |
# Evaluate on live data
python src/main.py --ticker MSFT --start 2015-01-01
# Custom walk-forward configuration
python src/main.py --ticker MSFT --start 2015-01-01 \
--min-train 1008 --test-size 252 --step-size 252
# Illustrative 30-day recursive forecast (see warning below)
python src/main.py --ticker AAPL --demo-forecast 30Recursive multi-step forecasts are excluded from every claim in this project and appear in no metrics table. Each step feeds a synthetic bar back into the feature pipeline, so ATR, Bollinger and intraday-range features are computed from a price with no real high, low or volume, and errors compound. For genuine multi-horizon results, train separate direct models for h = 1, 5, 21 rather than recursing. Off by default.
| File | Contents |
|---|---|
data/model_comparison.csv |
Primary table, return-space metrics on the common window. |
data/model_comparison_full_range.csv |
Secondary, per-model full range. Not comparable across rows. |
data/diebold_mariano.csv |
DM tests against the zero-return baseline. |
data/backtest.csv |
Alpha, beta, exposure, Sharpe, Holm-corrected p. |
data/aligned_predictions.csv |
One row per forecast day, every model side by side. |
Flaws found and fixed. Each produced plausible numbers rather than a crash, which is what made them dangerous.
- The price-target identity function. Predicting tomorrow's close given today's close as a feature scored R² 0.9627 while forecasting nothing.
- Two incompatible definitions of directional accuracy. One differenced the concatenated prediction series, inventing a spurious observation at every fold boundary.
- An 80/20 split inside the test set is not out-of-fold. The meta-learner was fitted on the first 80% of pooled test rows and scored on 100% of them.
- A cold R²_OOS benchmark flatters everything. Starting the expanding-mean
benchmark from nothing made the zero-return baseline score +0.087 instead of
−0.005. This bug recurred during analysis of the 30-ticker sweep and was
caught by the zero-return baseline scoring +0.035 on all 30 tickers. The
fix is now structural: the training returns are a required argument of
the evaluator, omitting them is an error, and the flag that recorded the
omission is gone (
src/evaluate.py,require_training_returns). - Window length manufactures significance. The same VIX-gating DM test gives p = 0.0203 favouring −VIX on a 420-day window and p = 0.5519 favouring +VIX on 2520 days. Same code, same statistic, opposite signs.
- A cross-window comparison flattered the base models. Scoring each model over its own range gave the base models 1.9–2.3pp more directional accuracy than the common window does.
Stock predictions are inherently probabilistic and subject to structural regime shifts. This model operates purely on technical and macro features, omitting fundamental and sentiment analysis. The results reported here are negative — the models do not forecast next-day returns better than trivial baselines. Do not use this system for real financial trading.
