vike
Backtesting

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 + temporary

with 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 bars

ImpactCfg::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.

On this page