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 CLOBclob-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 agame_start_timereportseconds_delay: 3, while 191 of 192 futures and props report0— 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.