The A-S maker on a paper book
Mount the Avellaneda-Stoikov SpreadMaker on the production live core with a paper exchange behind it, and see why the tick-to-bar bridge is the piece that makes it fill.
vike_mm::SpreadMaker is the reference market maker: a two-sided quoter priced by
Avellaneda-Stoikov, with inventory-skew size shaping, a per-side fill-rate circuit breaker, a
quote-style registry, own-order-book filtration and an anti-churn refresh gate stacked on top. This
tutorial mounts it with vike_paper::PaperExecutionClient as the ExecutionClient — the real
runtime, the real risk gate, the real order path, and no venue.
It is written impl<B: HftBroker> Strategy<B>, because it needs the tagged-resting-order verbs
(tagged submits, in-place modify, per-tag cancel, a signed-position read) that are not on the
portable Broker surface. vike_core::LiveBroker implements HftBroker, so the live core mounts
it; a plain Broker-only backtest engine cannot, which is the type system continuing to document
the requirement.
What it is quoting
crates/vike-mm/src/avellaneda.rs carries the closed forms, adapted for a bounded price domain:
reservation price r = s − q_norm · γ · V
optimal half-spread δ_half = ½·γ·V + (1/γ)·ln(1 + γ/κ)
effective variance V = min(σ̂²·H, p·(1−p))s is the fair mid, q_norm the size-normalised signed inventory, γ risk aversion, κ the
fill-intensity decay, and H the effective horizon — min(τ_hold, T−t) once a resolution_ts is
set, and the bare τ_hold otherwise, which is the default. The p(1−p) Bernoulli cap is the crux
for a 0..1 market: skew and spread vanish at the walls and peak at 0.5, which keeps A-S
from over-penalising inventory near a wall.
That geometry is one case of a PriceDomain knob. UnitInterval is the default and clamps quotes
to [tick, 1−tick]; Unbounded is the dollar-scale case with no wall clamp, where a quote is
bounded only by the standoff and the bid < s < ask straddle. Two pairings are load-bearing for a
dollar-scale mount: VarianceMode::RawLocal, because the Bernoulli cap goes negative for p > 1,
and an AsParams::min_half_spread_ticks floor, because the adverse-selection half-spread is
sub-tick at that scale and both sides would otherwise snap to one tick.
Every layer above the pricing is inert at its default, byte-identically: the skew intensity at
0.0 makes both size multipliers exactly 1.0; the breaker's window, threshold and cooldown all
at 0 mean it never engages; QuoteStyle::Mid reproduces the original midpoint-plus-half-spread
pricing bit for bit; own-order filtration is off, so the maker prices straight off the feed; and
RefreshTolerance is None, so every tick re-quotes both sides. Turn one on deliberately, and
know that filtration and the styles change pricing bit for bit when you do.
The mount, and the one non-obvious piece
vike_run::build_paper_maker_core is spawn_core plus a StrategyMount(SpreadMaker) plus
PaperExecutionClient. The wiring that makes it actually fill is the tick-to-bar bridge:
The maker QUOTES on ticks (on_quote_tick / on_order_book), but the paper client FILLS on
CLOSED BARS (on_bar, next-open discipline — the R7 paper law). A venue serving ticks rather
than candles would therefore produce a maker that quotes forever and never fills, so the mount
synthesises bars: TickBarSynthesizer folds the feed mid into an event-time OHLC window and emits
a closed vike_model::Bar on each interval boundary, and that bar drives the paper book. A maker
resting inside the spread is filled exactly when the market oscillates through its quote within a
bar — a dip-and-recover fills the resting bid, a spike-and-fall fills the resting ask. It is pure
wiring: neither the DataClient nor the ExecutionClient seam changes.
Why paper is faithful here
PaperExecutionClient fills its resting book with the backtest engine's OWN public primitives, so
backtest equals paper by construction and is gated bit for bit by vike-backtest's
tests/r7_gate.rs. build_paper_strategy_core_with also asserts that the mount declares no extra
legs: a single-symbol paper book stamps its own symbol onto every fill, so a multi-symbol
rehearsal would book both legs under one symbol and look perfectly correct while the live core
routed them apart — concealing the very thing the rehearsal exists to catch.
Running it against a real feed
crates/vike-run/src/bin/polymarket_maker_paper.rs, behind the polymarket feature, is the mount
above with the real Polymarket Feeds as the tick source. It picks a market with --auto
(universe selection), --query against the Gamma catalog, or a --token-id outright, reads the
market's end_date into the A-S resolution_ts, and runs for --duration-secs. It logs position,
working quotes, paper fills and realized PnL every few seconds, then flushes the last synth bar and
shuts the core down cleanly.
No API keys are needed: the market channel is public and unauthenticated, and the mount is paper — it never signs or sends an order. Polymarket's CLOB is US-geo-blocked, so the binary must run on, or egress through, a host outside that block; the mount is identical either way, and only the feed differs from the offline scripted test.
The daemon route, and the trap in it
vike-tradehub mounts the A-S maker as its DEFAULT: absent a [strategy] table it builds one from
the profile's own maker fields (qty, half_spread, tick_size, resolution_ts_ms, plus
interval / interval_ms for the synthesiser and seed_cash for the paper account) through
vike_run::build_maker.
Do not name the maker in a daemon profile's [strategy] table
SpreadMaker::from_params is a reader over [strategy.params] and nothing else, so naming
spread_maker there would mount qty = 1, tick_size = 0 and the [0,1] Bernoulli wall clamp —
the exact configuration vike-tradehub's own module doc records as having posted ZERO orders on a
five-figure hyperliquid asset. config.rs's AS_MAKER_NAMES therefore routes both maker spellings
to the same build_maker call the default path makes, and validate REFUSES a [strategy.params]
table under either name rather than dropping it. The two spellings cannot differ; an attempt to
make them differ is a startup error.
That refusal is why the maker's row in vike_strategy::PARAM_KEYS is allowed to decline to
enumerate its roughly sixty keys: its consumer carries a stricter rule of its own. On a
vike-backtest profile, where [strategy.params] is the only configuration that exists, the
reader is live — and it fails loudly on an ENUM key (spread_model, kappa_mode, style, …) that
is present and names no variant, so a value typo is a vike_mm::ParamError rather than a silent
default.
Where next
- Avellaneda-Stoikov, Inventory skew and Quote styles.
- Refresh tolerance and Quote emission.
- The spread-maker template.
- The grid template on paper — the same mount, a simpler strategy.
The grid template on paper
Mount the grid strategy on the production live core with the paper exchange behind it, and understand every refusal the profile has to pass first.
Tick replay with a queue model
Replay a recorded tape and make a resting maker order wait its turn — seeded from the book, gated by trades, and held long enough that the round trip could have happened.