Order Types & Validation
IOC, FOK, post-only — and making invalid orders unrepresentable.
Foundation · Concurrency, Memory & the Core Engine
Why this week
Your engine trades limit and market orders. Real participants need more control than that: "trade what you can right now and cancel the rest", "trade all of it or none of it", "only ever add liquidity, never take it". These order types and time-in-force instructions look like small flags, but each changes what the engine must do before it touches the book, and two of them (FOK and post-only) require the engine to decide something without changing anything.
The other half of the week is about what reaches the engine at all. An order with a price that is not a multiple of the tick, a quantity of zero, or a price ten times the last trade must be rejected with a precise reason, and it must never get far enough to corrupt the book. The technique is to parse, not validate: convert raw input into types that can only hold valid values, at the boundary, once — so the engine's signature itself proves it never sees a malformed order.
Day 1 — A tour of order instructions
Time-in-force and friends
What each instruction asks the engine to do
Re-read 'Orders and Order Properties' with this week's list in mind: market and limit orders, validity (time-in-force) instructions, and why traders want each. Harris explains the trading motive; you are about to implement the mechanics.
The instructions your engine will support, and how FIX 4.4 encodes them:
- Limit (
OrdType40=2) and market (40=1). A market order has no price, so it can never rest. - Day (
TimeInForce59=0): rests until the end of the trading session. - Good-till-cancelled, GTC (59=1): rests until cancelled (venues often purge them eventually).
- Immediate-or-cancel, IOC (59=3): trade whatever crosses right now, cancel the remainder. Never rests.
- Fill-or-kill, FOK (59=4): trade the entire quantity right now, or nothing at all. Never rests, never partially fills.
- Good-till-date, GTD (59=6): rests until a stated time.
- Post-only (also called add-liquidity-only; in FIX,
ExecInst18=6, "participate don't initiate"): the order may only rest. If it would trade on arrival, the venue rejects it — or, on some venues, reprices it one tick away from the opposite side.
Some combinations are meaningless: a market order cannot be Day or GTC (it can never rest), post-only cannot be IOC or FOK (an order that must rest cannot also be forbidden from resting). Your types should make those combinations impossible to construct, not merely rejected later.
An IOC buy for 100 and an FOK buy for 100 both arrive, each limited at 50, when 60 is offered at 50 or better. What happens to each?
Day 2 — Fill-or-kill: decide without touching
Feasibility first, then act
Drill: an FOK check that cannot mutate the book
FOK is all-or-nothing, and partially executing then rolling back is not an option: trades are facts the moment they are emitted. So the engine first answers a read-only question — is there at least qty available at prices that cross my limit? — and only if the answer is yes does it run the normal matching loop.
Three properties make the check right:
- It walks levels, not orders. Each level already knows its aggregate quantity (week 6), so the check costs one step per price level, however many orders each holds.
- It stops early. Count down from the requested quantity and stop at the first level that covers what is left. A small FOK against a deep book should touch one or two levels.
- It cannot overflow. Summing level quantities can exceed
u64::MAXon adversarial input; subtracting from what you still need cannot.
Writing it as fn fok_feasible(&self, …) is the parse-don't-validate idea applied to code: the &self receiver is a compiler-checked promise that the check has no side effects. And because one thread owns the book, nothing can change between the check and the sweep, so check-then-act is atomic for free. On a sharded or multi-threaded design that guarantee would cost you a lock or a transaction.
Fill-or-kill feasibility without mutating the book
use std::collections::BTreeMap;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Side {
Buy,
Sell,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Killed;
/// Aggregated book: price → total resting quantity at that price.
/// Invariant: no level holds 0.
#[derive(Clone, Default, PartialEq, Eq, Debug)]
pub struct LevelBook {
pub bids: BTreeMap<i64, u64>,
pub asks: BTreeMap<i64, u64>,
}
impl LevelBook {
/// Could an order for `qty` at `limit` fill completely right now?
/// Buy: asks at prices ≤ limit. Sell: bids at prices ≥ limit.
/// `&self` means the type system guarantees the check cannot mutate the
/// book. Stop walking as soon as you know the answer — and do not let a
/// sum of large levels overflow.
pub fn fok_feasible(&self, side: Side, limit: i64, qty: u64) -> bool {
todo!()
}
/// Fill-or-kill. If feasible, take liquidity best price first and return
/// the (price, qty) taken per level, removing levels that empty.
/// Otherwise return Err(Killed) and leave the book exactly as it was.
pub fn execute_fok(&mut self, side: Side, limit: i64, qty: u64) -> Result<Vec<(i64, u64)>, Killed> {
todo!()
}
}
Why is 'check feasibility, then execute' safe for FOK in your engine, and what would break it?
Day 3 — Newtypes and fixed-point prices
Parse, don't validate
Drill: a Price that is always on a tick
Written about Haskell, applies directly to Rust. The core idea: a validation function that returns () throws away what it learned; a parser that returns a more precise type keeps it, and every later function can rely on it without re-checking.
C-NEWTYPE: newtypes provide static distinctions, so a Qty can never be passed where a Price is expected, at zero runtime cost. Also read the guidance on using custom types instead of bool arguments.
Three newtypes carry most of the weight:
Price(i64): a fixed-point count of some small unit, guaranteed to be a multiple of the instrument's tick. The only constructors are parsers that check.Qty(NonZeroU64): a quantity that cannot be zero, which also makesOption<Qty>the same size asQtythanks to the niche.OrderId(u64): so you cannot pass a quantity as an id by swapping two arguments.
With #[repr(transparent)] they cost nothing at runtime. What they buy is that the engine's signature, fn submit(&mut self, order: ValidOrder, …), cannot be called with an off-tick price or a zero quantity, because no such ValidOrder can exist.
Why not floats. Every price on the wire is a decimal string or a scaled integer. Converting through f64 loses exactness in ways that are easy to miss in tests: (4.35 * 100.0) as i64 is 434, not 435, and (19.99 * 100.0) as i64 is 1998. Parse the digits directly into an integer and you never have to reason about rounding again.
One more design point: positivity is an instrument property, not a universal law. The drill requires prices above zero, as for an equity. Futures spreads, and occasionally outright futures (the May 2020 WTI crude contract settled at −$37.63 a barrel), trade at negative prices, so a futures venue's Price must be signed and its validation must come from per-instrument reference data.
Tick-size-aware Price newtype
use std::fmt;
/// Prices are fixed-point integers: 1 unit = 10^-DECIMALS of the currency.
pub const DECIMALS: u32 = 4;
pub const SCALE: i64 = 10_000; // 10^DECIMALS
/// The instrument's minimum price increment, in units (100 = 0.01).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct TickSize(pub i64);
/// A validated price: strictly positive and a whole number of ticks. The
/// field is private — `parse` and `from_ticks` are the only ways to get one.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct Price(i64);
/// When several problems apply, report the first in this order.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PriceError {
/// The input is "".
Empty,
/// Anything but ASCII digits with at most one '.', which must have a
/// digit on both sides ("12", "12.5"). No sign, spaces or exponent.
Malformed,
/// More than DECIMALS digits after the '.', even if they are zeros.
TooPrecise,
/// Does not fit in i64 units.
Overflow,
/// Zero (or, for from_ticks, negative).
NotPositive,
/// Not a multiple of the tick size.
OffTick,
}
impl Price {
/// Parse a decimal price string into units — integers only, never f64.
pub fn parse(s: &str, tick: TickSize) -> Result<Price, PriceError> {
todo!()
}
/// `ticks` whole ticks. Overflow / NotPositive as for parse.
pub fn from_ticks(ticks: i64, tick: TickSize) -> Result<Price, PriceError> {
todo!()
}
pub fn units(self) -> i64 {
self.0
}
pub fn ticks(self, tick: TickSize) -> i64 {
todo!()
}
}
impl fmt::Display for Price {
/// Always DECIMALS fractional digits: 1_012_500 units → "101.2500".
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
todo!()
}
}
Why should a price parser on an order-entry path reject '1.00000' (five decimals, all zeros) and '+5' rather than accepting them as equivalent to valid prices?
Day 4 — The validation pipeline and reject reasons
Where each check lives
Syntax, reference data, book state
Validation is not one function; it is a pipeline whose stages need different information:
- Syntax and session (gateway): the message parses, required fields are present, the session is logged on, the client's order id is not a duplicate. Needs nothing but the message and the session.
- Reference data (gateway or engine entry): the instrument exists and is open for trading; the price is a multiple of its tick; the quantity is a multiple of its lot size and within its maximum order size. Needs per-instrument static data.
- Account risk (week 15): position, notional and rate limits. Needs per-account state.
- Book-dependent checks (engine): the price is inside the band around the reference price; a post-only order would not cross; an FOK is feasible. Needs the current book and is therefore the engine's job.
Stages 1 and 2 are where parsing produces your newtypes, and by stage 4 the engine only sees well-typed orders.
Price bands stop fat-finger orders from sweeping a book. A static band is a fixed percentage around a reference price (the previous close or an auction price); a dynamic band moves with the last trade. US equities' Limit Up-Limit Down plan, for example, sets percentage bands (5% for the most liquid stocks, wider for others) around a reference price averaged over the preceding five minutes. In the repo's engine, a limit order outside the band is rejected (PriceBand), and the band is dynamic: it is measured from the last trade, or from a configured reference price before the first trade. Market orders have no price to check and are not band-limited. Letting a market order trade only up to the band edge is a common venue choice, though venues differ in the details.
Reject reasons must be typed and specific: OffTick, OffLot, QtyTooLarge, OutsideBand, WouldCross, DuplicateId, UnknownOrder, InstrumentHalted. They map onto FIX's OrdRejReason (tag 103) for new orders and CxlRejReason (tag 102) for cancels, but internally an enum with data (OutsideBand { limit, low, high }) is far more useful in logs and tests than a code and a string. The repo's types::RejectReason spells them InvalidTick, InvalidLot, PriceBand, WouldTake, DuplicateOrderId, UnknownOrder and Halted (plus ZeroQuantity, FokUnfillable, NoLiquidity, InvalidReduce and Risk(..)), and keeps them as plain variants without data (on the wire, a reject reason is a single code byte).
What is the difference between rejecting an order and accepting it and then cancelling it? Give one example of each from this week.
Order the checks in your engine's entry path. Do you run the cheapest first, or the one whose reject reason is most useful to the client? What should happen when an order fails two checks at once — and how do you keep the chosen reason deterministic?
Day 5 — Post-only and the types that forbid nonsense
Make invalid combinations unrepresentable
An order type is an enum, not a bag of flags
Post-only is the market maker's instruction: "I am quoting, I never want to pay the taker fee or trade by accident." On arrival the engine checks whether the order would cross the opposite best price. If it would, the venue either rejects it or slides it to one tick behind the opposite touch. Either way it must not trade.
A flag-based order type lets you write things the engine cannot meaningfully execute:
struct NewOrder { price: Option<i64>, tif: Tif, post_only: bool /* ... */ }
// Market + GTC? Post-only + IOC? Post-only market? All representable.
An enum makes them impossible:
enum OrderKind {
Limit { price: Price, tif: LimitTif }, // LimitTif: Day | Gtc | Gtd(..)
Ioc { price: Price }, // limit IOC; remainder cancelled
Fok { price: Price }, // all or nothing
PostOnly { price: Price, tif: LimitTif },
Market { protection: Option<Price> }, // never rests
}
Each variant carries exactly the data that makes sense for it, and the engine's match is exhaustive: adding a new type later is a compile error at every place that needs a decision.
Write the OrderKind enum you would want, and compare it with the repo's flatter types::OrderKind (see the capstone). Which combinations did you forbid by construction, and which do you still need runtime validation for (because they depend on reference data or the book)?
Your venue must choose between rejecting a crossing post-only order and sliding it one tick behind the opposite best price. Who benefits from each choice, and what new events or fields does sliding need?
Day 6 — Testing the edges
Table tests for every reject
And the cases between order types
Look up tags 59, 40, 18 and 103 in FIX 4.4. Compare the enumerated values with your own enums and note which FIX values you deliberately do not support; your gateway (week 14) will reject them with a precise reason.
Validation code is mostly edge cases, so test it as a table: one row per input, one expected outcome per row, including every reject reason and every boundary (tick multiple exactly at the band edge, quantity equal to the maximum, FOK quantity exactly equal to the available liquidity). Then test the interactions the table cannot express: an IOC that partially fills then cancels, a FOK that is killed and leaves the book byte-for-byte unchanged, a post-only that would rest exactly at the opposite best price (it would cross; it must not trade).
Capstone
Extend crates/engine with order instructions and validation.
What to build. The week 8 block of Engine in src/engine.rs: validate(&order) -> Result<(), RejectReason>, fillable(side, limit, want) -> Qty and reference_price(), plus the submit changes that use them. GTC limits keep the week 7 behaviour. IOC matches, then ends in Expired with its remainder. FOK checks feasibility with fillable, a read-only walk over level aggregates from the best price that stops at the first level that does not cross or once want is covered, and then either matches in full or is rejected with FokUnfillable without touching the book. Post-only is rejected with WouldTake when it would cross; the repo does not slide. Validation checks the instrument's InstrumentSpec (tick, lot, band_ticks): a limit price more than band_ticks × tick from the reference price (the last trade, else EngineConfig::reference_price) is rejected. Every reject is a Rejected { id, reason } event in the same ordered stream as everything else, and when several checks fail, the first in the documented order wins: ZeroQuantity, InvalidLot, InvalidTick, PriceBand, DuplicateOrderId, WouldTake, NoLiquidity, FokUnfillable. Maximum order size is not an engine check: it is RiskReason::MaxOrderQty, in week 15's pre-trade risk layer.
Newtypes in the repo. The shared types crate keeps Price(i64), Qty(u64) and OrderId(u64) as plain newtypes with public fields. Every crate from the wire codec to the journal builds and encodes them, and whether a price is valid depends on the instrument and the book, so Engine::validate is the one place that decides. Parsing decimal text into an integer price without floats, the Day 3 drill, happens at the FIX boundary in week 14 (fix::Decimal). Likewise types::OrderKind is flatter than the Day 5 enum: Limit { price, post_only } or Market, with a separate TimeInForce of Gtc, Ioc or Fok (no Day or GTD yet). The engine defines every combination instead of forbidding it: market orders never rest whatever their time-in-force, a post-only IOC that does not cross is accepted and then expires untouched, and a post-only FOK is always rejected.
Key decisions. Which checks happen before Accepted and which after. In the repo every validation failure is a lone Rejected with no Accepted, and that includes an unfillable FOK: its check runs before anything is accepted and leaves the book untouched, so it is reported as a reject rather than as an accepted order that is then killed (venues do both; document whichever you choose). IOC remainders are accepted and then Expired. Whether "Day" exists in your engine yet: if it does, end-of-session must be an input command in the sequenced stream, never a timer firing inside the engine, or replay in week 9 will diverge. How the reference data (tick, lot, band) reaches the engine: as configuration loaded before the first order, and later as sequenced commands so changes are replayable too.
Pitfalls. An FOK check that walks orders instead of levels; a post-only check against the wrong side; IOC that rests a remainder of zero; validation that panics on hostile input instead of returning a reason; reject reasons that depend on HashMap iteration or check order that varies.
Benchmark. w08.fok_check times fillable alone on a deep book (1,000 levels a side, 1–5 orders each), with a random side, a limit 1–100 levels through the touch and a random size, so a walk covers anywhere from one level to about a hundred and about half the checks are feasible. The target is 700 ns at p99. With per-level aggregates and an early exit, a typical FOK touches a few levels and costs well under that; the target exists to catch implementations that walk orders, or walk the whole side before deciding.
crates/engineTime-in-force, post-only and typed rejects
GTC, IOC, FOK and post-only in the engine, behind validation that turns every malformed order into a typed reject.
cargo xtask test --week 8 # tests named w08_*
cargo xtask bench --week 8 # p50 / p99 / p99.9 on your machine
cargo xtask report --upload # update this page and your lab- All week 8 tests pass w08_*
- FOK feasibility check, deep book w08.fok_checktarget p99 ≤ 700 ns
Feeds into
- Week 12 throws thousands of random order streams, including every order type, at the engine and checks invariants against a naive model.
- Week 15 adds the account-level stage of the pipeline: pre-trade risk checks that run in nanoseconds before an order reaches the book.