Kyle's λ is not a constant

Research project Market microstructure · Mohamed El Khoudimi

Can you predict market impact without predicting who loses money?

I tested whether a classic price-impact measure, Kyle's lambda, is stable, forecastable and economically useful across 351 million trades, then froze the hypotheses and success criteria before opening a four-pair holdout.

I started with a simple contradiction. Kyle's model gives a constant depth parameter, but traders estimate its reciprocal, lambda, as if it changes through time. I wanted to know whether those changes are real, predictable, and useful for trading decisions. They are real and partly predictable. The trading thesis I built on top of them did not survive the holdout.

Data
351M raw trades, Apr 2018 to Nov 2019
Markets
6 Binance spot pairs · 1 Nasdaq day (AMZN) · ~11 h Coinbase L3
Design
2 development pairs, 4 held-out pairs, predictions committed before the holdout ran
Primary result
Lambda is forecastable. Forecast lambda does not predict maker losses.
Holdout results · 4 untouched pairs
Lambda is not constant through the dayConstancy rejected far more often than chance on 3 of 4 pairsSupported
Lambda can be forecast from the pastBeats seasonality and a tuned trailing estimate on 4 of 4 pairsSupported
Forecast lambda predicts which passive fills lose money0 of 4 pairsNot supported
Quoting on forecast lambda beats quoting on trailing lambda0 of 4 pairsNot supported
Scheduling trades on forecast lambda lowers execution cost2 of 4 pairs significant; I required 3Partial
Pre-registered before holdout
Freeze commit 8f3e63b · 25 Sep 2026 18:33:59 UTC

I committed the hypotheses, pass rules and frozen code before running any analysis on ETHBTC, LTCBTC, NEOUSDT or QTUMUSDT. The first holdout results were committed later at 19:03:28 UTC. The verdicts above use the held-out pairs, not the development set.

What lambda measures

How far does price move when someone pushes on the market?

Every trade that crosses the spread is a push. A buyer who needs to buy now takes the cheapest offers, then the next ones, and the price moves up. How far it moves depends on how much is resting in the book.

In a deep market, a large push barely moves the price. In a thin market, a small push moves it a lot. Lambda is that sensitivity: how many basis points the price moves per dollar of net buying or selling.

It matters because it is the cost of trading. A fund that has to buy $1M pays more when lambda is high. A market maker who sells to that buyer loses more if the price keeps moving. Desks estimate lambda every day for exactly these reasons.

How I estimate it for technical readers

Within a window (say 5 minutes), I cut time into 1-second bars and regress the mid-price return on signed order flow:

r_i = a + λ · x_i + e_i        i = 1-second bars in the window
r_i  mid-price return, bps
x_i  signed flow: buyer-initiated $ minus seller-initiated $, in $M
λ    bps of price move per $1M of net flow

A marketable order that sweeps several resting orders prints several trades, so I first collapse fills into parent orders by taker order id. I use a bounce-corrected mid (the Binance tape has no quotes), because regressing raw trade prices on flow counts part of the bid-ask spread as "impact". I also estimate the square-root form of Hasbrouck (2009), an order-count version, and, where a book exists, the order-flow-imbalance slope of Cont, Kukanov and Stoikov (2014).

Same push, two markets

Illustration

Deep market · λ = 20

+10.0 bps

Lots resting at each price. The push is absorbed.

Thin market · λ = 80

+40.0 bps

Little resting. The same push walks the price.

Illustrative numbers, not live data. λ in bps per $1M of net flow. For scale: the median 5-minute estimate on BTCUSDT in my sample was 44.2 bps per $1M. Higher lambda means more price impact per unit of flow.

The model says depth is constant. Desks treat it as moving.

Kyle (1985) models one informed trader, noise traders who trade for their own reasons, and market makers who set prices so that they break even. As trading becomes continuous, the equilibrium lambda goes flat. Depth is constant through the session.

Practitioners estimate lambda as a rolling regression and let it move. Both cannot be literally right, and the rolling estimate is itself noisy. So I asked whether the movement you see in a rolling lambda is mostly estimation noise, or whether it contains structure you can predict.

I don't read the answer as "Kyle was wrong". The constant-depth equilibrium is too restrictive as a literal description of intraday data. Its logic about who sets lambda, and why, turned out to explain my negative result.

Kyle's equilibrium λ, solved numerically

Verified by tests
  • 4 auctions
  • 20 auctions
  • 200 auctions
From my solver in kyle.py. Tests check it against the closed forms: one auction gives λ = σv/(2σu); with 400 auctions the interior is flat to within 1%.

One real day: BTCUSDT, 26 October 2019 (UTC)

  • 5-minute estimate (about 57% noise)
  • Constant: trailing 7-day median
  • My forecast, made 5 minutes ahead
  • Predictable deviation from the constant
Real data. The day was picked by a fixed rule (the 2019 day with the largest realized variance and no missing windows), not by eye. The forecasts are out of sample: each comes from a model trained only on earlier months.

A forecast can be statistically right and economically useless.

  1. Is lambda stable enough to estimate at all?

    If a 5-minute estimate is mostly noise, reacting to it is reacting to noise.

  2. Can next-window lambda be forecast from the past?

    And is that anything more than the time-of-day pattern everyone already knows about?

  3. If I can forecast it, does that tell me who is about to lose money, or how to trade more cheaply?

    This is the question a trading desk cares about. It needs the forecast to line up with a separate outcome, adverse selection or execution cost, on data I had not looked at.

Research design

I designed on two pairs, then tested on four I had not analysed.

A holdout is data I deliberately refused to analyse until I had frozen the code and written down what would count as success.

Development
BTCUSDT · BNBUSDT

Binance spot, raw trades with buyer and seller order ids, 2018-04-06 to 2019-11-17. Every design decision was made here.

Holdout
ETHBTC · LTCBTC · NEOUSDT · QTUMUSDT

Same exchange and period. Bars were built before the freeze; no analysis ran until the predictions were committed.

Cross-venue
AMZN (Nasdaq) · Coinbase L3

One LOBSTER day of AMZN (2012-06-21, 6.5 h). Coinbase BTC-USDT and ETH-USDT order-by-order samples, about 11 h together. Supporting evidence only.

The pipeline

Raw trades351M prints Signed order flowfills → parent orders Rolling λ estimator1 s bars, 1 min to 1 day windows Reliability auditPhase 1 Conditional forecastPhase 2, walk-forward
Freeze + commit predictions8f3e63b Adverse-selection testPhase 3 Quote-gating testPhase 4a Execution testPhase 4b Holdout scorecard7 predictions × 4 pairs
The same code ran on the development pairs first and then, unchanged, on the holdout pairs. The book-level venues ran through the same estimators with their own loaders.
Data handling I had to get right for technical readers

Trade sign. Binance's isBuyerMaker = true means the aggressor sold. I checked the sign empirically: signed flow and returns correlate positively, and the estimated lambdas are positive on every pair.

Outages. Seconds inside any gap longer than 300 s are masked, and windows more than 20% masked are dropped. On the thinnest holdout pairs this also removes genuinely quiet periods. I note that as a limitation.

File dates. The mirror's file names are shifted a day from the UTC timestamps inside, so I key everything off timestamps.

Units. ETHBTC and LTCBTC are quoted in BTC. I convert flow to USD with the BTCUSDT mid of the same second, so lambda is in bps per $1M everywhere. That conversion is where my post-holdout bug lived (below).

Phase 1 Estimator audit

A 5-minute rolling lambda on BTCUSDT is about 57% noise.

If I take the latest 5-minute estimate and treat it as truth, most of what I am reacting to is estimation noise. One window's standard error is about the size of the estimate itself.

Share of a rolling λ's variation that is real

BTCUSDT, 1-second bars, 591 days. Split-half reliability: each window is estimated twice from interleaved bars, and the agreement between the two halves is the share of variance that is real.
0.43
reliability at 5 minutes (57% noise)
0.69
reliability at 1 hour
5.4%
of 5-minute estimates have the wrong sign

Is lambda constant within a day? I tested "one lambda for all 24 hours" on every day with a permutation test. If lambda were constant, about 1% of days would reject at the 1% level.

On the development pairs, 27% to 53% of days rejected, depending on pair and estimator. On the holdout pairs, 7% to 31% did (Kyle estimator). The AMZN day rejected as well (p = 0.001).

So lambda moves. On many days, though, an hourly lambda is still within sampling noise of the day's constant. The variation is real, frequent and moderate.

Share of days rejecting constant intraday lambda at the 1% level
PairSetDays rejecting at 1%5-min rel.
Method details split-half reliability and the permutation test

For every window I estimate λ from its odd bars and from its even bars. Across windows, corr(λ_odd, λ_even) is the share of variance the halves share, which is the true λ. Spearman-Brown turns it into the reliability of the full-window estimate, ρ = 2r / (1 + r). Estimates are winsorized at the 1st and 99th percentiles first, because order flow is fat-tailed. On synthetic markets with a known λ path, the method recovers the true reliability to within 0.06 (tests/test_estimators.py).

The constancy test shuffles the hour labels of 10-second bars within a day and compares the precision-weighted dispersion of hourly slopes against 199 shuffles. A test on synthetic data with heteroskedastic noise checks that it rejects at close to its nominal rate when λ really is constant.

Full grid: 4 estimators × 4 bar sizes × 6 window lengths × regime splits, in results/phase1_reliability.csv.

Phase 2 Forecastability

Lambda is forecastable, and not only from the time of day.

On BTCUSDT 5-minute windows my conditional model explains 9.9% of next-window variation, against 7.9% for the best trailing estimate I could tune and 5.1% for the intraday pattern alone. Diebold-Mariano t-statistics are about 20.

Out-of-sample R² vs Kyle's constant, by horizon

  • Conditional (HAR)
  • Trailing λ, tuned and calibrated
  • Time-of-day pattern, calibrated
Development pairs. R² here is small in absolute terms, and that is expected: the target is itself 57% noise, so the ceiling for any forecast is about 43%. Read it as an improvement over fair baselines.

The raw rolling estimate does worse than assuming a constant. Using the last 5-minute estimate as the forecast gives R² = −64% on BTCUSDT. If you plug a 5-minute lambda straight into a cost model, you are better off using the weekly median.

The forecast tracks what actually happens next. I also score it by information coefficient (IC): the rank correlation, within each day, between the forecast and the next window's realized lambda. Higher forecasts line up with higher realized lambda on every pair, IC 0.10 to 0.17 on the holdout. Trailing lambda manages at most 0.05, and it is negative on three holdout pairs.

Holdout verified The conditional model beat both calibrated baselines with Diebold-Mariano t above 2 on all four held-out pairs.

Model and evaluation HAR, walk-forward, baselines, DM test

Target. The window's λ estimate divided by its trailing 7-day median, so 1.0 means "usual" and Kyle's constant is the zero-skill benchmark.

Model. OLS with a small ridge penalty on HAR-style lags of λ (the last window, and means over about 30 min, 2 h, 6 h and 1 day), the seasonal term, the other two estimators, and lagged market state: realized variance, volume, order count, spread, order-flow imbalance, absolute return, a weekend flag.

Walk-forward. Refit monthly on all earlier data with a one-day embargo; 60-day burn-in. Winsorization bounds come from training data only. A test scrambles the future and checks that no earlier forecast changes.

Fair baselines. The trailing EWMA gets its half-life tuned in-sample; both it and the seasonal profile are then linearly calibrated on the same training rows, and all forecasts are clipped to the same bounds.

Inference. Diebold-Mariano on squared error with Newey-West variance. Across every window length, horizon, estimator and development pair (60 configurations), DM against the seasonal model ranges from 3.7 to 60; against the trailing model it is above 3 in 58 of 60.

I could predict lambda. I could not predict maker adverse selection with it.

Supported
Forecasts next-window lambda
IC 0.10 to 0.17 on the 4 held-out pairs, well above trailing lambda.
Not supported
Forecasts maker adverse selection
0 of 4 held-out pairs. Trailing volatility does it better on all 6.
Not supported
Improves quote gating
0 of 4 held-out pairs. Knowing the realized lambda does not help either.
Partial
Lowers execution cost
Significant on 2 of 4 held-out pairs. I had required 3.

The statistical result survived. The trading thesis did not.

Phase 3 Adverse selection

Forecast lambda did not tell me which passive fills would lose money.

My original thesis: if lambda reflects the market maker's fear of informed traders, then a high forecast should flag the windows where passive orders get picked off. On all four held-out pairs, it did not, in the direction I pre-registered. Trailing volatility did, on every pair (IC 0.08 to 0.12, t above 16).

What each signal predicts, next 5-minute window (mean daily rank IC)

  • Forecast λ → realized λ
  • Trailing λ → realized λ
  • Forecast λ → maker adverse selection
  • Trailing volatility → maker adverse selection
Adverse selection is the price move against a passive fill over the next 60 seconds, notional-weighted per window. IC is computed within each day so that a volatile day, with high lambda and large losses, cannot fake the result. The development pairs are shown for completeness; the verdict uses only the four held-out pairs.

My interpretation

In this data, lambda is highest in thin windows, and thin windows are not where passive fills lose the most per dollar. The losses concentrate in busy, volatile windows where flow keeps arriving in one direction.

This also fits Kyle's own logic. Lambda is the market maker's price for adverse selection. If makers set it well, it should price the risk instead of predicting who gets hurt. That is an interpretation from one exchange and one period, not a general law.

Exploratory

Two observations came after I had seen the development results, so I don't treat them as findings. First, on the development pairs, higher forecast lambda went with slightly better maker realized spreads, as if makers over-widen in thin windows. Second, the reversed quoting rule (step away when lambda is low) helped on BTCUSDT and hurt on BNBUSDT. Neither was tested on the holdout.

Phase 4a Quote gating

Knowing the next window's realized lambda did not rescue the quoting rule.

I simulated a passive liquidity provider that steps away from the 20% of windows with the highest lambda score. Using my forecast as the score did not beat using trailing lambda. Then I gave the rule the answer: the realized lambda of the next window. It still did worse than quoting all the time, on 5 of 6 pairs.

Maker net edge vs always quoting, bps per $ traded

  • Gate on trailing λ
  • Gate on forecast λ
  • Gate on realized λ (oracle)
Zero is always quoting. Edge is half-spread earned minus adverse selection at 60 s, fee 0; every gate is active on the same share of windows. The exception to the oracle result is QTUMUSDT, the thinnest pair.

The oracle result matters because it rules out the easy excuse. If the rule had failed only with my forecast, I could blame forecast accuracy. It fails with perfect knowledge of lambda too, so the premise is wrong: in this data, avoiding high-lambda windows is not how a maker avoids losses.

On the held-out pairs, the forecast gate minus the trailing gate was not positive with a confidence interval above zero on any pair. On LTCBTC it was significantly worse (−0.043 bps, 95% CI −0.083 to −0.003).

Simulation setup and its limits

The provider takes a fixed 1% share of all maker fills in the windows where it quotes. The threshold is the trailing 7-day quantile of each score, so every gate is active on the same share of windows. Net edge per dollar is half-spread minus κ × adverse selection at the hedge horizon τ, minus fee. Grid: fee −0.5 / 0 / +1 bp, κ 1 / 1.5, τ 10 / 60 / 300 s. Confidence intervals are day-clustered bootstrap.

The limit: it assumes my fills look like the average maker's. κ only partly corrects for queue position.

Phase 4b Execution

On the execution side the forecast helped on liquid pairs, but not often enough to pass my own bar.

Lambda is the price of impact, so the forecast should be worth most to whoever has to take liquidity. A Kyle-style schedule that trades less when lambda is expected to be high saved about 1 to 2% of cost against the same schedule on trailing lambda on the most liquid pairs (8% on BNBUSDT). It was significant on BTCUSDT, BNBUSDT, ETHBTC and LTCBTC. On the held-out pairs that is 2 of 4. I had required 3 of 4, so the verdict is: promising, failed the replication criterion.

Cost saved vs the trailing-λ schedule, 5% participation

2 of 4 held-out pairs · 3 required
Bars show saving as a percent of the trailing schedule's cost; whiskers are 95% day-clustered bootstrap intervals. A pair counts toward the bar only if its whole interval is above zero. QTUMUSDT's median 5-minute window trades about $2,500, and its interval spans −53% to +222%.
Execution cost by schedule, 5% participation
PairSetForecast schedule, bpsTrailing schedule, bpsDifference, bps [95% CI]SavingCounts?
How the schedule and its cost are defined and why these are upper bounds

Under Kyle's linear impact, a child order of q dollars in window w costs HS_w + λ_w · q / 2 bps, where HS is the effective half-spread. For a fixed total, the cheapest split is q proportional to 1/λ. Every schedule uses that rule and differs only in which λ goes in: a constant (TWAP), trailing λ, the seasonal profile, or my forecast.

Base child size is a fixed share of the trailing median window volume. Each schedule's tilt is normalized by its own trailing mean, so all of them trade the same expected volume (realized volumes match within 4% on every pair except QTUMUSDT, where they differ by 9%). Costs are scored with the next window's realized λ, whose estimation noise is independent of anything the schedules saw.

The saving is measured with the same linear cost model the schedule optimizes. A live test would use real fills and a nonlinear impact model with decay, so I treat these numbers as upper bounds.

Pre-registration and holdout

Before I analysed the four held-out pairs, I committed the code, the hypotheses and the pass thresholds.

Once I had seen the development results, any reframing I chose was fitted to them. The only way to find out whether my revised claims were real was to write them down and test them on data I had not analysed. I kept the two original thesis claims in the scorecard even though I expected them to fail.

  1. Development
    All phases on BTCUSDT and BNBUSDTEvery design choice, including the execution test added after the quoting thesis failed.
  2. 25 Sep 18:33:59
    Freeze: 8f3e63bCode, dev results and docs/PREREGISTRATION.md: 7 predictions, each with a numeric pass rule, and a 3-of-4 bar.
  3. 25 Sep 18:34:27
    Deviation: 2f28695A .gitignore pattern had left the loader modules out of the freeze. I committed them unchanged, plus an I/O fix so results merge instead of overwrite, before any holdout analysis. Logged in the pre-registration.
  4. Holdout run
    Same commands on ETHBTC, LTCBTC, NEOUSDT, QTUMUSDTPhases 1 to 4, then the scorecard script.
  5. 25 Sep 19:03:28
    Results and correction: a2b39e8Holdout scorecard, the units fix, and the as-run scorecard kept beside the corrected one. Times are UTC.

Each prediction is scored per pair. "Holds" means it held on at least 3 of the 4 held-out pairs.

Pre-registered predictions scored on the four held-out pairs
PredictionETHBTCLTCBTCNEOUSDTQTUMUSDTVerdict

results/scorecard.csv · docs/PREREGISTRATION.md

After the first holdout run I found a units bug. I fixed it, kept both scorecards, and no verdict changed.

Symptom
The holdout execution numbers for ETHBTC and LTCBTC looked wrong: a half-spread of about 490 bps on a liquid pair, and execution differences of several bps with enormous intervals.
Root cause
For BTC-quoted pairs, my USD conversion rescaled signed notional but not signed base volume. The half-spread term sn − mid · sv then mixed dollars with bitcoin. It affected predictions P6 and P7 on those two pairs only; lambda, adverse selection and P1 to P5 never use that term.
What I did
  • Kept the conversion rate as a column and applied it
  • Added a regression test for the converted case
  • Preserved the as-run scorecard next to the corrected one
  • Left every threshold and parameter unchanged
  • Logged it in the pre-registration's deviations section
Outcome
No verdict changed. P6 failed before and after. P7 moved from 1 of 4 to 2 of 4, still short of 3.

As-run vs corrected, held-out pairs

PredictionAs runCorrectedVerdict
Counts are held-out pairs passing, out of 4. Both files are in the repository: scorecard_as_run.csv and scorecard.csv.

Other corrections I made before the holdout

Five things I believed, or might have claimed, that the data did not support.

Phase 5 Other venues

One Nasdaq day behaves like Binance. The Coinbase samples are too thin to say anything.

VenueTrades5-min reliability: Kyle / OFIConstant λ?Status

On the AMZN day (LOBSTER, 2012-06-21), lambda moved within the session (permutation p = 0.001) and was persistent from window to window. The order-flow-imbalance slope at the best quotes was the most reliable depth measure I tested: 0.78 at 5 minutes, against 0.57 for trade-based lambda. It is one day of one stock, so I treat it as consistent with the crypto results, not as equity evidence.

I replayed both Coinbase full-channel samples order by order: one million events each, zero crossed books, zero references to unknown orders. They are USDT-quoted pairs with very little flow, so every estimate is noise. That is a null result, not a negative one.

Implementation

What I built

A Python package with a script per phase. Each script writes CSVs to results/, and every number on this page is exported from those files.

data/binance.py

Reads daily trade zips straight out of a partial git clone, without a working tree. Handles truncated files and collapses fills into parent taker orders.

bars.py

One-second bars on a full 86,400-row calendar grid, with a bounce-corrected mid; resampling to any bar size.

estimators.py

Window λ by Kyle, square-root, count and OFI forms, with odd/even half-sample estimates and outage masks, vectorized with sufficient statistics.

audit.py

Split-half reliability and the permutation test of intraday constancy.

forecast.py

Seasonal, trailing and HAR forecasters; monthly walk-forward with embargo; calibrated baselines.

markout.py

Maker half-spread and adverse selection at 1 s to 300 s horizons, from per-second aggregates.

signal.py

Daily rank IC with Newey-West t-stats, and incremental regressions against trailing lambda and volatility.

policy.py

The quote-gating simulator and the Kyle-optimal execution schedules, with day-clustered bootstrap intervals.

kyle.py

A numerical solver for the Kyle (1985) sequential-auction equilibrium, plus synthetic markets with a known λ path for tests.

data/l1.py

The LOBSTER loader and an order-by-order Coinbase L3 book replay, reduced to one common bar schema.

scripts/06_scorecard.py

Scores all 7 predictions on every pair from the result files, with no manual step.

collector/ws.py

A websocket recorder for public order-flow feeds, with sequence-gap markers and reconnect. Offline only Tested offline; I have never run it live, and no result on this page comes from it.

What protects the result, and what each check guards against

CheckGuards against
21 automated tests passErrors in the parts every result depends on. Details in the rows below.
Kyle closed-form testsA solver that looks plausible but is wrong: one auction must give λ = σv/(2σu) exactly.
Estimator recovery on synthetic marketsA reliability measure that reports noise as signal, or signal as noise, when the true λ path is known.
Permutation-test size and powerA constancy test that rejects too often under heteroskedastic noise, which would overstate "lambda moves".
No-look-ahead testsForecasts or gating thresholds that change when future data changes.
Execution sanity testsA schedule that differs from TWAP when every score is flat, or tilting on a useless score that comes out looking free.
Units regression testThe post-holdout bug coming back for USD-converted pairs.
Frozen commit before holdoutMoving the goalposts after seeing the answer.
As-run and corrected scorecards keptA quiet fix that changes a verdict without anyone seeing it.
Calibrated, clipped baselinesA model that looks good only because its competitors were handicapped.
Scripted scorecard and site dataTranscription errors between the results and what this page says. Every chart value is exported from results/.

How much weight each claim can carry

Holdout verified

Passed the pre-registered rule on at least 3 of 4 held-out pairs: λ moves within the day, rolling λ is noisy, λ is forecastable, forecast beats trailing λ at predicting λ.

Not supported

Failed the pre-registered rule: forecast λ predicts adverse selection; forecast-gated quoting beats trailing-gated quoting.

Partial

Right direction, not enough replication: execution savings (significant on 4 of 6 pairs overall, 2 of 4 held out).

Development only

Measured only on BTCUSDT and BNBUSDT: R² by horizon beyond one window, the bar-size and regime breakdowns, the estimator comparison at every window length.

Exploratory

Noticed after seeing results and never tested out of sample: makers over-widening in thin windows; the reversed quoting rule.

Insufficient data

Coinbase L3. One AMZN day is supporting evidence only.

Offline only

The websocket collector: written and tested, never run live.

Everything here is public, and every number traces to a file.

pip install -e ".[dev]"
pytest

# rebuild bars from the public trade mirror (see data/README.md)
python scripts/00_build_bars.py --mirror /data/ticks \
  --symbols BTCUSDT BNBUSDT ETHBTC LTCBTC NEOUSDT QTUMUSDT
python scripts/00b_quote_to_usd.py --symbols ETHBTC LTCBTC

# phases 1-4, dev pairs; holdout commands are in
# docs/PREREGISTRATION.md
python scripts/01_audit.py     --symbols BTCUSDT BNBUSDT
python scripts/02b_markouts.py --symbols BTCUSDT BNBUSDT
python scripts/02_dynamics.py  --symbols BTCUSDT BNBUSDT
python scripts/03_signal.py    --symbols BTCUSDT BNBUSDT
python scripts/04_economic.py  --symbols BTCUSDT BNBUSDT
python scripts/05_book_venues.py --raw /data/raw
python scripts/06_scorecard.py

What I did

I framed the research question and designed the pipeline. I implemented the estimators, the reliability audit, the forecasting models and the walk-forward evaluation. I built the quoting and execution simulations and the holdout scorecard.

I found and corrected the confounds that favoured my own model, pre-registered the holdout predictions before running them, and kept the original thesis claims in the scorecard when I expected them to fail. After the first holdout run I found a units bug, fixed it with a regression test, preserved the original scorecard next to the corrected one, and kept the original verdicts.

I treated the repository as a research record, not a demo: I kept the failed hypotheses, preserved the as-run and corrected scorecards, documented deviations, and built the site so every displayed number traces back to a result file. Data provenance and known limitations are documented in data/README.md.

Nothing here is investment advice, and nothing was traded live.