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.
8f3e63b · 25 Sep 2026 18:33:59 UTCI 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
IllustrationDeep market · λ = 20
+10.0 bpsLots resting at each price. The push is absorbed.
Thin market · λ = 80
+40.0 bpsLittle resting. The same push walks the price.
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
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
A forecast can be statistically right and economically useless.
- Is lambda stable enough to estimate at all?
If a 5-minute estimate is mostly noise, reacting to it is reacting to noise.
- Can next-window lambda be forecast from the past?
And is that anything more than the time-of-day pattern everyone already knows about?
- 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.
Binance spot, raw trades with buyer and seller order ids, 2018-04-06 to 2019-11-17. Every design decision was made here.
Same exchange and period. Bars were built before the freeze; no analysis ran until the predictions were committed.
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
8f3e63b
Adverse-selection testPhase 3
Quote-gating testPhase 4a
Execution testPhase 4b
Holdout scorecard7 predictions × 4 pairs
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
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.
| Pair | Set | Days 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
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.
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
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)
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| Pair | Set | Forecast schedule, bps | Trailing schedule, bps | Difference, bps [95% CI] | Saving | Counts? |
|---|
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.
- DevelopmentAll phases on BTCUSDT and BNBUSDTEvery design choice, including the execution test added after the quoting thesis failed.
- 25 Sep 18:33:59Freeze:
8f3e63bCode, dev results anddocs/PREREGISTRATION.md: 7 predictions, each with a numeric pass rule, and a 3-of-4 bar. - 25 Sep 18:34:27Deviation:
2f28695A.gitignorepattern 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. - Holdout runSame commands on ETHBTC, LTCBTC, NEOUSDT, QTUMUSDTPhases 1 to 4, then the scorecard script.
- 25 Sep 19:03:28Results 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.
| Prediction | ETHBTC | LTCBTC | NEOUSDT | QTUMUSDT | Verdict |
|---|
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 · svthen 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
| Prediction | As run | Corrected | Verdict |
|---|
scorecard_as_run.csv and scorecard.csv.Other corrections I made before the holdout
Unfair baselines
My first forecast comparison clipped and calibrated the conditional model's inputs but not the baselines' forecasts, which made the baselines look far worse than they were. I clipped and calibrated the baselines the same way and reran everything. The results on this page are the corrected ones.
A volume confound in the execution test
My first execution comparison let the schedules trade different total volumes. Under convex impact, whoever trades less looks cheaper. I normalized each schedule to the same expected volume before drawing any conclusion.
The loader modules missing from the freeze
A
.gitignorepattern excluded them from the freeze commit. I committed them unchanged 28 seconds later, before any holdout analysis, and logged it.
Five things I believed, or might have claimed, that the data did not support.
- Not supported
Forecast lambda predicts maker adverse selection
0 of 4 held-out pairs. It was my original thesis.
- Not supported
Quote gating on forecast lambda beats trailing lambda
0 of 4 held-out pairs, and an oracle version also failed.
- Partial
Execution savings replicate out of sample
2 of 4 held-out pairs significant; the bar was 3. Promising, not established.
- Insufficient data
Crypto L3 at scale
The Coinbase samples have 1,588 and 734 trades. That supports no inference.
- Dropped
"Nobody has published this"
An early framing I did not keep. Estimator noise in liquidity measures (Goyenko, Holden and Trzcinka 2009) and flow toxicity (Easley, López de Prado and O'Hara 2012) are established literatures.
Phase 5 Other venues
One Nasdaq day behaves like Binance. The Coinbase samples are too thin to say anything.
| Venue | Trades | 5-min reliability: Kyle / OFI | Constant λ? | 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.pyReads 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.pyOne-second bars on a full 86,400-row calendar grid, with a bounce-corrected mid; resampling to any bar size.
estimators.pyWindow λ by Kyle, square-root, count and OFI forms, with odd/even half-sample estimates and outage masks, vectorized with sufficient statistics.
audit.pySplit-half reliability and the permutation test of intraday constancy.
forecast.pySeasonal, trailing and HAR forecasters; monthly walk-forward with embargo; calibrated baselines.
markout.pyMaker half-spread and adverse selection at 1 s to 300 s horizons, from per-second aggregates.
signal.pyDaily rank IC with Newey-West t-stats, and incremental regressions against trailing lambda and volatility.
policy.pyThe quote-gating simulator and the Kyle-optimal execution schedules, with day-clustered bootstrap intervals.
kyle.pyA numerical solver for the Kyle (1985) sequential-auction equilibrium, plus synthetic markets with a known λ path for tests.
data/l1.pyThe LOBSTER loader and an order-by-order Coinbase L3 book replay, reduced to one common bar schema.
scripts/06_scorecard.pyScores all 7 predictions on every pair from the result files, with no manual step.
collector/ws.pyA 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
| Check | Guards against |
|---|---|
| 21 automated tests pass | Errors in the parts every result depends on. Details in the rows below. |
| Kyle closed-form tests | A solver that looks plausible but is wrong: one auction must give λ = σv/(2σu) exactly. |
| Estimator recovery on synthetic markets | A reliability measure that reports noise as signal, or signal as noise, when the true λ path is known. |
| Permutation-test size and power | A constancy test that rejects too often under heteroskedastic noise, which would overstate "lambda moves". |
| No-look-ahead tests | Forecasts or gating thresholds that change when future data changes. |
| Execution sanity tests | A schedule that differs from TWAP when every score is flat, or tilting on a useless score that comes out looking free. |
| Units regression test | The post-holdout bug coming back for USD-converted pairs. |
| Frozen commit before holdout | Moving the goalposts after seeing the answer. |
| As-run and corrected scorecards kept | A quiet fix that changes a verdict without anyone seeing it. |
| Calibrated, clipped baselines | A model that looks good only because its competitors were handicapped. |
| Scripted scorecard and site data | Transcription errors between the results and what this page says. Every chart value is exported from results/. |
How much weight each claim can carry
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 λ.
Failed the pre-registered rule: forecast λ predicts adverse selection; forecast-gated quoting beats trailing-gated quoting.
Right direction, not enough replication: execution savings (significant on 4 of 6 pairs overall, 2 of 4 held out).
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.
Noticed after seeing results and never tested out of sample: makers over-widening in thin windows; the reversed quoting rule.
Coinbase L3. One AMZN day is supporting evidence only.
The websocket collector: written and tested, never run live.
Everything here is public, and every number traces to a file.
README.md: the full write-up, phase by phasedocs/PREREGISTRATION.md: predictions, pass rules, deviations logresults/scorecard.csvandscorecard_as_run.csvresults/: every phase's output as CSV, plusfigures/scripts/andtests/data/README.md: where every dataset comes from and its known issuessite/ACCURACY_AUDIT.md: every claim on this page mapped to its source
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.