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.
Fill resolution
What happens when several resting orders trigger inside one bar — adverse-first ordering, the stop/take-profit bracket cap, and the both-hit count the result carries so the ambiguity is reported rather than hidden.
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.