vike
Backtesting

The cost model

The three canonical cost scalars the backtest engines charge through — adverse fill price, fee and funding, the first two shared with the paper exchange — with the one deliberate deviation from the port, a floor that keeps an adverse move from inverting a price.

crates/vike-fills/src/broker_sim.rs is, in its own words, the canonical cost model: one definition of fills, fees and funding, an exact port of the Python oracle's core/broker_sim.py. Three functions, three conventions, and one floor. The sim broker and the vector engine charge through all three; the paper exchange charges through the adverse fill price and the fee only, and has no funding path at all. One shared definition behind all three consumers is why the scalars live in vike-fills — a crate below both the simulator and the paper client rather than a copy in each.

The conventions come from the header: side_sign is +1 for a buy and −1 for a sell; multiplier scales every notional term; slippage is adverse — buys fill up, sells fill down.

The three scalars

adverse_fill_price(raw_price, side_sign, slippage) is multiplicative: raw × (1 + side_sign × slippage), with the factor floored at MIN_ADVERSE_FACTOR so it can never carry the price through zero.

fee(size, price, rate, multiplier) is rate on the multiplier-scaled notional: size × price × rate × multiplier.

funding_charge(pos_size, mark_price, funding_rate, multiplier) is the perp funding cash flow for a held position, pos_size × mark_price × funding_rate × multiplier — longs pay positive funding, shorts receive.

Where the slippage number comes from

The engine hands adverse_fill_price a fraction, and SimBroker::slippage_for is the site that decides it. Its doc promises a byte-identical default: with no impact model configured it returns the slippage field itself — the same f64, with no arithmetic performed on it — because adding a zero is not the identity on −0.0, and a parity gate is not the place to find that out. With a market-impact model configured, the size-dependent estimate is added to the flat slippage for taker fills. Maker fills are exempt from the addend: an order that rested and got hit was supplying liquidity, and charging it impact would fill a resting limit through its own limit price. The flat slippage still applies to makers exactly as it did.

Fees have their own precedence on EngineParams: maker_fee and taker_fee fall back to the flat fee_rate, and an optional fee_schedule overrides that whole chain — with one shape, ProbabilityScaled, routed to the exact per-fill FeeSchedule::commission because it has no flat equivalent. A run profile refuses [engine.fee] beside a non-zero fee_rate: two cost models would be configured and only one could win.

The one deviation from the port

The header devotes most of its length to a single change, because it is the only place the Rust arithmetic departs from the Python original. The original bounded neither operand of the multiplication, and an adverse fraction at or past 1.0 on the losing side does not saturate — it inverts: a sell of a positive quote comes back negative (raw 100, side_sign −1, slippage 1.5 → −50). vike_model::round_to preserves the sign, and the wrong-signed price then flows into the notional floor, the fee, the cash flow, the position's average price and the equity curve. Nothing downstream re-checks it.

That regime is reachable, not hypothetical

SimBroker::slippage_for returns slippage + impact_frac(..), and the impact addend is bounded below — a cost is never negative — but never above. The header quotes the measurement: the Almgren–Chriss model crosses 1.0 at roughly 18× the window's mean bar volume once the measured per-bar sigma reaches 20 %, and a thin resampled series produces both halves of that routinely.

The fix is a floor on the factor: MIN_ADVERSE_FACTOR = 0.01, the smallest factor the function will ever multiply a quoted price by, so the adverse move saturates once it has consumed 99 % of the price. Its doc says which property is load-bearing — it is strictly positive, which is the whole reason the fill cannot change sign — and that the magnitude is a policy choice: far above any move that could still be economically meaningful, so it binds only on an estimate that has already stopped meaning anything, and far enough from zero that the saturated fill is a usable double rather than dust that would silently re-route through SimBroker::apply_fill's min-notional floor and turn a saturation into a dropped order.

The floor lives here and nowhere upstream because this is the only function that holds both operands of the inversion, and every producer of a slippage number funnels through it — the flat field, slippage_for's impact sum, the engine's cash pre-check, the vector kernel and the paper exchange. A floor inside an impact model could not work at all: a model never sees the flat slippage it is added to, nor the side that decides which way the move points.

What the floor does and does not do

It saturates rather than repairs. A run that reaches the floor has a cost model whose output stopped being a cost, and the fill it then books — 1 % of the quote — should look ruinous, because it is. SimBroker::slippage_saturations counts the fills where the floor bound, so such a run can say so; its doc notes that the counter is not yet mirrored onto BacktestResult the way the deferral counters are, so the harness and the backtest bin cannot read it — a strategy reads it off its context.

It floors the factor, not the price, so the fill keeps the sign of the quote it was priced off: a genuinely negative quoted price — this workspace ships futures and EOD backfills, and vike_model::gross_notional refuses to .abs() such prices away — passes through untouched. It is byte-identical wherever it does not bind: the same two f64 operations in the same order, never contracted into a fused multiply-add, with the branch taken only where the frozen path produced a zero or sign-flipped fill. And NaN is deliberately not caught — factor <= 0.0 is false for NaN, so a NaN slippage still yields a NaN price, because a NaN is a different defect from an inversion and swallowing it would reintroduce the silent behaviour the floor exists to remove. adverse_move_saturates exposes the predicate so a caller can count a saturation without re-spelling the factor.

On this page