vike
Tutorials

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

On this page