Market impact
The opt-in size-dependent slippage model for bar-mode backtests — the Almgren–Chriss cost function, its published constants and units, what it charges and what it deliberately does not, and where its output is bounded.
The frozen cost model applies one flat adverse move per fill: a 5 bp slippage costs the
same whether the order is one share or 10 % of the day's volume. crates/vike-backtest/src/impact.rs
opens by naming the direction of that error — it flatters a backtest, because size is exactly
what a real book charges for — and supplies the missing size-dependent cost for the lanes where
no book exists, estimated from bar OHLCV alone. Where L2 depth is recorded the engine already
does better than any model — run_ticks walks the real replayed book and
the queue lane gates resting makers — which is why a
run profile that sets [engine.impact] in tick mode is
rejected at validation rather than ignored.
It is opt-in through EngineParams::impact = Some(model). With None the wire-in site,
SimBroker::slippage_for, returns the flat slippage field unchanged with no arithmetic
performed on it, so every parity and golden fixture is untouched. With a model, the estimate is
added to slippage for each taker fill and the sum goes to adverse_fill_price —
the cost model.
Taker-only
The estimate is charged on aggressive fills only. A resting limit that was hit supplied the
liquidity this cost pays for, so slippage_for returns the flat slippage alone for maker
fills — charging otherwise would execute a maker through its own limit price. The gate lives at
the wire-in site, not in the models, so every ImpactModel inherits it; EngineParams::impact's
doc draws the consequence that the knob is safe to leave on for a maker strategy: it prices the
aggressive legs and leaves the passive ones alone.
The model
AlmgrenChriss ports the published cost function of Almgren, Thum, Hauptmann and Li (2005),
Direct Estimation of Equity Market Impact — an empirical calibration fit to roughly 700 k US
equity orders, which is why its coefficients are quoted rather than tuned. Two additive terms,
both a fraction of price:
nu = qty / (exec_time * avg_volume) -- participation rate
permanent = gamma * sigma * (nu * exec_time)^alpha == gamma * sigma * (qty/avg_volume)^alpha
temporary = eta * sigma * nu^beta
impact = permanent + temporarywith the published exponents alpha = 0.891, beta = 0.600 and coefficients gamma = 0.314,
eta = 0.142 (AC_ALPHA, AC_BETA, AC_GAMMA, AC_ETA). Permanent impact is the shift the
order leaves in the market after it is done; temporary impact is the concession paid for
demanding liquidity now, and decays once trading stops. The header flags where exec_time does
and does not appear, because it is the term a transcription slip reaches for: the permanent term
is a function of the total fraction of volume traded and is horizon-independent — working an
order more slowly does not change the footprint it leaves — while only the temporary term sees
the horizon, through the participation rate.
One term of the paper is deliberately omitted: the fundamental-data liquidity adjustment on the
temporary term, (shares_outstanding / avg_volume)^delta with delta = 0.267 (AC_DELTA,
recorded for provenance and read by nothing). It needs a per-issuer shares-outstanding series
this workspace has no source for and that does not exist at all for crypto and perp symbols; its
absence makes the temporary term the paper's value at the calibration sample's median liquidity.
Units — read this before trusting a number
The published coefficients are calibrated on daily units: sigma is a daily return
volatility and avg_volume a daily share volume, so exec_time is measured in days.
window_stats derives both from the bar series being backtested, which means the calibration is
literally right only on daily bars. On finer bars the shape — monotone in size, concave in
participation — still holds, but the absolute level is a per-bar quantity: treat it as a tunable
cost, not a published estimate, and scale exec_time accordingly.
The market context
ImpactInputs is what one fill is priced against: qty, an absolute order size in the same unit
as avg_volume; avg_volume, the average traded volume per period; sigma, a fractional return
volatility over that same period. window_stats(bars, window) measures the last two over the
last window bars — the sample standard deviation of simple returns and the mean volume, both
summed through py_sum so the result is independent of how the window was sliced — and returns
None when the window cannot support an estimate: fewer than three bars, a non-positive close,
or non-positive mean volume. DEFAULT_IMPACT_WINDOW is 21 bars, one trading month of daily
bars: long enough that a single outlier cannot dominate the volatility estimate, short enough to
track a regime change.
slippage_for's doc pins the window free of lookahead on all three bar lanes: the context is
measured over the bars that closed before the fill's timestamp, excluding the bar the
timestamp falls inside, because its own close and volume are not knowable at the moment the
order prices and its volume would in part be this very order. A window too short or degenerate
to measure charges the flat slippage alone rather than guessing.
What a model must promise, and what it need not
ImpactModel::impact_frac must return a finite, non-negative fraction, non-decreasing in qty —
bigger orders never cost less — and a model with insufficient or degenerate inputs must return
0.0 rather than a NaN or infinity that would poison the equity curve; AlmgrenChriss charges
nothing on a non-positive or non-finite sigma or a participation rate the inputs cannot support.
No upper bound is required, and the trait doc calls that a decision rather than an omission. A
fraction of 1.0 means the move ate the whole price, and past it the multiplicative haircut
inverts the sign — but a model cannot see the two things that decide whether that happens: the
flat slippage it is added to (a per-model cap of 1.0 would still let 0.5 + 0.9 cross) and
the side the engine will point the move in. The bound lives where both operands are in hand,
adverse_fill_price's MIN_ADVERSE_FACTOR, and the fill site counts each saturation in
SimBroker::slippage_saturations.
Both ^ in the formula are libm::pow, never f64::powf: base and exponent are arbitrary
runtime f64s, the platform's libm decides the last bit of such a power, and the number is
multiplied into a fill price — two boxes replaying one tape must not report different PnL.
In a profile
[engine.impact]
model = "almgren_chriss" # the only model name today
exec_time = 1.0 # execution horizon, in bar-periods
window = 21 # rolling sigma/volume lookback, in barsImpactCfg::build fails the profile on an unknown model name, a non-positive or non-finite
exec_time, or a window under 3 — a typo must fail loudly rather than silently price fills at
zero impact. The vector engine has no per-fill market
context and never consults the model, so engine-versus-kernel reconciliation is meaningful only
with impact: None, which is what the parity gate runs. This is the backtest cost estimate;
the live pre-trade veto against displayed liquidity is a different primitive,
vike_model::impact, which walks a real book and denies rather than prices.
Staleness
The opt-in stale-price wait discipline — a market order that would fill against a forward-filled or otherwise stale price waits for a fresh print instead, with three rules, an event-time clock, and a counter that says when it bit.
Latency models
Order latency on the tick/book replay path — the entry and response legs, the constant and recorded-series models, the venue-enforced holds, what the response leg delays and what it deliberately does not.