BUILD 2026-08-22 11:41:18    MODEL exp-1-*BM    UNIVERSE 20 instruments (15 stocks / 5 crypto)
SOURCE Yahoo Finance, daily bars    REQUESTED RANGE 2015-01-01 .. 2026-08-23    DELIVERED 2020-03-27 .. 2026-08-22
BARS 32,015 total, 31,995 scored out-of-sample    » index
Mean out-of-sample AUC = 0.5062 — at or below coin flip (0.5000).
This page documents how the experiment is run, not a working strategy. Nothing here is trading advice.
The questionDataFeatures LabelValidationExecution MetricsFeature diagnostics LimitationsReproduce

The question

One narrow question, asked the same way for every instrument: given only the recent price history of a single asset, can a model tell whether tomorrow closes higher than today?

Everything below is the machinery for answering that honestly — mainly, for making sure the model is never scored on data it was fitted on. The answer so far is no. That result is the point of the experiment, not a bug in it.

1 · Data

Source. Yahoo Finance via PyBroker's built-in adapter, daily bars, cached locally so repeated builds do not re-download. No paid feed, no survivorship-bias correction, no corporate-action handling beyond what the source already applies.

Requested range. 2015-01-01 to 2026-08-23. The right edge is exclusive at the source, so the end date is set to tomorrow to make sure today's bar is included. Actually delivered across the universe: 2020-03-27 to 2026-08-22 — younger instruments simply start later.

Universe. 20 instruments, fixed by hand, no screening rule:

AAPL AMD AMZN AVGO BTC-USD COIN DIS DOGE-USD ETH-USD GOOGL JPM META MSFT NFLX NVDA SOL-USD TSLA V WMT XRP-USD

Shape of one raw bar. Eight fields arrive per instrument per day: date, symbol, open, high, low, close, volume, adj_close.

Of those eight, the experiment currently uses exactly one: close. Open, high, low and volume are read from the source, kept in the bar frame for charting and order fills, and never reach the model. That is a deliberate simplification, and a cheap place to look for improvements.

2 · Features

Raw prices are never fed to the model. A tree-based model splits on thresholds it saw in training, and price levels drift permanently upward — a rule like «above 140 → up» is meaningless once the price leaves that range. So every input is transformed into a scale-free quantity that means the same thing for a $216 stock and a $0.20 coin.

Six such features are computed, all of them functions of close alone:

Feature Definition What it measures
sma20close / mean(close, 20) − 1Distance from the 20-day moving average. Positive means price is above its recent level.
sma50close / mean(close, 50) − 1The same over 50 days — a slower read on the same idea.
rsi14100 − 100 / (1 + avg gain / avg loss), 14-day Wilder smoothingHow one-sided recent moves have been. Bounded 0–100; above 70 is conventionally read as overbought.
vol20std(pct_change(close), 20)Realised daily volatility — how hard the instrument is currently shaking.
ret1close[t] / close[t−1] − 1Yesterday's return.
ret5close[t] / close[t−5] − 1Return over the last five bars.

Each feature is registered as an indicator and computed over the full history before any split, which is safe because all six look strictly backwards. Rows where any feature is still warming up (the first 50 bars, driven by the longest window) are dropped from training.

The model receives these six columns and nothing else — no date, no ticker identity, no price level. A row from 2015 and a row from 2026 are indistinguishable to it except by the values themselves.

3 · Label

Binary, and about as blunt as a label can be:

y = 1 if close[t+1] > close[t] else 0

Direction only. A 0.1% drift up and a 9% gap up are the same class; a bar that first falls 5% and then recovers is scored purely on where it closed. The model therefore optimises for being right about the sign, not for being right when it matters — a known weakness, and the reason a magnitude-aware or barrier-based label is the first thing worth trying.

The final training row is always discarded, because its label needs a bar that does not exist yet.

4 · Validation protocol

Walk-forward, 5 windows, 80/20 split, retrained from scratch in every window. The window slides rather than expands: the start of the training block moves forward too, so the model never carries an ever-growing tail of ancient history.

Split. The instrument's history is cut into 5 successive windows.
Train. The first 80% of each window fits a fresh model. Nothing is carried over between windows — no warm start, no shared state.
Purge. With lookahead=1, training bars whose label depends on a bar inside the test block are removed. Without this the model would be taught the answer to the first test question.
Predict. The remaining 20% is scored. These predictions are the only ones that ever count.
Repeat. The window slides forward and the whole thing runs again, 5 times per instrument.

Two separate guards keep the future out. lookahead=1 handles the label leak described above. Order delays handle the execution leak: a decision made on the close of bar t is filled on bar t+1, never at the price that produced the decision.

Each instrument gets its own independent model, trained only on its own history — roughly 1,300 bars per window. No information crosses between instruments.

5 · Execution and costs

MODELGradient-boosted decision trees, published as exp-1-*BM. Configuration: boosting rounds 200  ·  learning rate 0.05  ·  leaves per tree 7  ·  min samples per leaf 40  ·  feature subsample 0.8  ·  random seed 42. Deliberately small and heavily regularised — with ~1,300 training rows and a near-zero signal, a larger model would fit noise.
OUTPUTProbability that the next bar closes higher, in [0, 1].
ENTRYProbability above 0.55 → go long with the full account balance.
EXITProbability below 0.50 → close the position. In between, whatever position exists is held.
POSITIONLong or flat. No shorting, no sizing, no leverage, one instrument per account.
FILLNext bar after the signal, both ways.
COSTS0.03% of order value, charged per order — 3 bps one way, about 6 bps for a round trip. Slippage, spread and borrow are not modelled.
CAPITAL$100,000 starting cash per instrument, independent accounts.
Costs are not a rounding error here. On the most heavily traded instruments they consume a double-digit percentage of final equity, which is what a strategy with a near-zero edge and a high turnover should expect.

6 · How quality is measured

OOS AUC is the headline number, and it is computed only on bars carrying a prediction — that is, test-block bars. Training bars have no prediction attached and are excluded automatically. Across the universe 31,995 bars are scored this way.

AUC answers «if you pick one up-day and one down-day at random, how often does the model score the up-day higher?» 0.5000 is a coin flip. It is used instead of accuracy because it ignores the threshold and is not fooled by class imbalance — a model that predicts «up» every day scores 0.5, whatever the hit rate looks like.

Returns on the index page are equity from the walk-forward run, net of costs, compared against buy & hold over the identical span. Sharpe in the table is PyBroker's trade-level figure and is not annualised; the QuantStats tearsheet on each instrument page reports the annualised one. Both are correct, and they are different numbers.

7 · What the features actually contain

AUC judges the model. It says nothing about whether the ingredients were any good. The table below measures each feature on its own, against the next day's return — rank correlation (IC), its t-statistic across instruments, the univariate AUC, and how often the sign of the effect agrees across the universe.

FeatureRank IC (1d)t-stat Univariate AUCSign agreement
ret1-0.0304-6.180.479095%
ret5-0.0196-3.350.486380%
rsi14-0.0143-2.100.490975%
sma20-0.0137-2.000.490470%
sma50-0.0116-1.820.493175%
vol20-0.0002-0.060.496055%

Read it like this. One feature carries essentially all of the signal: ret1, at IC −0.03 with the sign agreeing on 19 of 20 instruments. The negative sign means short-term mean reversion — a day that fell is slightly more likely to be followed by a rise. The effect is real and long documented.

It is also far too small to trade. An IC of 0.03 against daily volatility around 2.4% implies roughly 7 basis points of expected edge per day, while a round trip costs about 6. The one thing the features do predict lives on a horizon where the costs eat it.

The other five range from weak to nothing, and they overlap heavily: sma20, sma50 and rsi14 correlate 0.77–0.90 with one another, all being restatements of «price versus its own average». Counting independent inputs, there are closer to three than six.

Measured once on 2026-08-21 over the full sample (57,130 bars, 20 instruments) as a static diagnostic. Unlike everything else on this site it is not recomputed at build time, and it is in-sample by construction, so treat it as an upper bound on how much these features contain.

8 · Known limitations

SINGLE FIELDOnly close feeds the features. Intraday range, gaps and volume are sitting unused in the same dataframe.
NO POOLINGOne model per instrument on ~1,300 rows, while the universe holds 32,015 bars in total. The per-instrument differences in measured IC are on the same order as their estimation error, which argues that fitting separately is fitting noise.
CRUDE LABELSign of the next close, so magnitude and path are discarded.
FIXED UNIVERSEChosen by hand, all large and liquid, heavily tilted to US tech. They move together, which makes 20 instruments far fewer than 20 independent tests.
NO TRIAL CORRECTIONThere is no Deflated Sharpe Ratio or any other adjustment for the number of configurations tried. Any future parameter search will need one before its results mean anything.
UNSTABLE BY DESIGNWalk-forward boundaries move whenever the end date moves, so returns shift between daily builds. With AUC at 0.5062 that instability is itself evidence: it is noise being resampled.
SOURCE QUALITYFree end-of-day data. Splits and dividends are trusted as delivered, and crypto trades on weekends while stocks do not.

9 · Reproduce

The whole pipeline is a few hundred lines and runs in about a minute for the full universe:

uv run python spike/run_all.py

Environment overrides: DUPELESS_START, DUPELESS_END, DUPELESS_SITE (output directory) and DUPELESS_THEME (terminal | bloomberg | crt).

Stack: PyBroker for the walk-forward engine and order simulation, gradient boosting for the classifier, QuantStats for the per-instrument tearsheets, Plotly for charts, Python 3.12. Output is static HTML — no server, no database, no runtime.

Methodology — 20 instruments, 31,995 out-of-sample bars scored Features: sma20 sma50 rsi14 vol20 ret1 ret5 Mean OOS AUC 0.5062