vike
ConceptsInstruments

Symbol properties

The per-symbol placement grid every venue fills — tick, lot, minimums, contract size, tiered ticks and the venue's own order hold.

SymbolProperties is the venue-neutral half of an instrument. Its module calls it "the per-symbol order-placement bounds every venue REST parser fills from its own instrument endpoint", naming four of them by shape: Binance /exchangeInfo filters, Bybit lotSizeFilter, OKX tickSz/lotSz, Deribit min_trade_amount (crates/vike-model/src/instrument.rs). It lives in vike-model rather than in the bridge crates because it is pure domain data consumed on the venue-neutral side of the execution seam; each venue's parse function stays with its venue.

The fields, and what a zero means

tick_size, step_size, min_qty, max_qty and min_notional are the original five. Three more arrived later: contract_size, tick_scheme and taker_hold_ms.

Every field is Default-zero, and the convention is stated on the type: absent venue properties stay 0.0, so 0.0 means "the venue did not constrain this", not "the constraint is zero". crates/vike-model/src/scalar.rs's nz_step is where that convention becomes a type — it turns 0.0 into None and passes everything else through — and round_to then falls through to identity for None, a zero step or a negative one.

Never construct it with an exhaustive literal

Use functional-update syntax (SymbolProperties { tick_size, step_size, ..Default::default() }). The struct grows, and the type's own doc records the cost of the alternative: adding taker_hold_ms alone broke 14 crates / ~32 sites. Two families deliberately keep the exhaustive literal, because there the compile error is the feature — the vike-data kind=properties Parquet codec, and this module's own serde round-trip tests. #[non_exhaustive] was considered and rejected: it forbids cross-crate functional update too.

Contract size

contract_size is the notional multiplier — how much underlying one unit of qty controls. A Deribit BTC option is 1 BTC per contract and an ETH option 1 ETH, so true notional is |qty| * |price| * contract_size. It follows the absent-is-zero convention rather than defaulting to 1.0, which keeps "the venue did not tell us" distinguishable from "the venue said 1.0"; SymbolProperties::multiplier is the one place absent folds to the inert 1.0, and it treats non-finite and non-positive values as absent too, so a malformed venue value can never zero out or invert a notional cap.

Tiered ticks

tick_scheme is an optional tiered price grid — the minimum increment as a function of price. None, which its doc says is every venue but a Deribit option today, means the scalar tick_size is the whole grid. Deribit's public/get_instrument returns tick_size plus tick_size_steps (0.0005 above 0.005 for BTC options), and a limit price above that boundary snapped onto the base grid is venue-rejected. effective_tick(price) asks for the tick at a price; round_price(price) is the tier-aware rounder, exactly the flat expression when no scheme is attached.

Attaching a scheme is a caller invariant

with_tick_scheme requires the scheme's base_tick to equal the scalar tick_size, because once a scheme is attached, effective_tick and round_price read the scheme only — the scalar stops participating in price rounding. Nothing in the type ties them together, so a mismatch is a silently wrong grid (wrong-tick prices on the wire, venue rejects) rather than a compile error. Only a debug_assert catches it; a release build trusts the caller, because this is on the order path.

The venue's own hold

taker_hold_ms is how long the exchange deliberately sits on an incoming order before matching or booking it. Its doc is emphatic that this is not a network estimate, not a tunable and not latency: it is a property the venue declares per market. Polymarket declares it through two separate, independent mechanisms, both measured live on 2026-07-23:

  • itode → 250 ms on the crypto up/down markets. The flag lives only on the CLOB clob-markets/{condition_id} payload, not on /markets/{condition_id} and not on Gamma, so probing those makes crypto look delay-free. While the order is pending, a cancel is rejected; it survives a dropped connection, then is re-validated and either matched or placed on the book.
  • seconds_delay → 3 s on sports game markets. 400 of 400 markets carrying a game_start_time report seconds_delay: 3, while 191 of 192 futures and props report 0 — the discriminator is exactly "has a game start".

Who reads the grid

crates/vike-exec/src/risk.rs's RiskLimits::from_properties maps four fields into the live gate — tick_size to tick_size, step_size to lot_size, min_qty and min_notional unchanged — with every other knob inheriting the new() defaults. SymbolGrid::from_properties is the per-leg twin over the same four fields, and carries one asymmetry: there a 0.0 becomes None, and None means inherit the mount scalar, not unconstrained. See risk and arming.

Rounding to the grid is not the last step. crates/vike-bridge-core/src/format.rs notes that the risk gate rounds to tick and lot but leaves IEEE artifacts — 0.30000000000000004 — that trigger Binance -1111 BAD_PRECISION, so format_to_step quantizes down to the step's decimal places and emits a plain, non-exponent string. Down, never up, so a limit is never overshot.

The same module flags the other half: the truncation is (value / step).trunc(), so a value one ULP below a grid point emits a whole step less — silent, where an overshoot is what the venue rejects.

Properties are also point-in-time data, recorded on the kind=properties series, where crates/vike-data/src/hist.rs's properties_as_of reads back the most recent observation at or before a timestamp, so a replay rounds on the grid that was actually in force. One caveat survives there: a part written before the tick-scheme column existed reads back None, so an old part is not evidence that the parser emitted no scheme.

On this page