melin_types/types.rs
1//! Shared trading types: the wire-level data model used by the matching
2//! engine, the protocol codec, and the no-op transport binary.
3//!
4//! Prices use fixed-point integer representation (ticks) to avoid
5//! floating-point non-determinism. One tick = smallest price increment.
6
7use std::num::NonZeroU64;
8
9/// Instrument/pair identifier.
10///
11/// Uses a `u32` rather than a string to avoid heap allocation and enable
12/// fast hashing/comparison on the hot path. The mapping from human-readable
13/// symbol names to numeric IDs is managed outside the matching engine.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub struct Symbol(pub u32);
16
17/// Client-assigned order identifier.
18///
19/// Uses `u64` — fits in a register, supports 18.4 quintillion unique IDs.
20/// Assigned by the client, not the server. Must be **monotonically
21/// increasing per account** — the exchange tracks a per-account high-water
22/// mark and rejects any `OrderId <= max_seen` as a duplicate (see
23/// `Exchange::max_order_id`). This prevents double-execution on
24/// crash-recovery retry.
25///
26/// Used as a HashMap key throughout the engine (order_sides, order_index,
27/// reservations), so cheap hashing matters.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub struct OrderId(pub u64);
30
31/// Account/trader identifier.
32///
33/// Uses `u32` — same rationale as `Symbol`: no heap allocation, fast
34/// hashing. Supports ~4 billion accounts, sufficient for any single exchange.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
36pub struct AccountId(pub u32);
37
38/// Currency identifier (e.g., USD, BTC, ETH).
39///
40/// Uses `u32` — same pattern as `Symbol` and `AccountId`. The mapping from
41/// human-readable currency codes to numeric IDs is managed outside the engine.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub struct CurrencyId(pub u32);
44
45/// One currency slot of an account's balance snapshot.
46///
47/// `Copy` so the fixed-size `[AccountBalance; 16]` array used in position
48/// queries stays trivially clonable; the struct replaces an earlier
49/// `(CurrencyId, u64, u64)` tuple so call sites read with named fields
50/// instead of opaque positional access.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct AccountBalance {
53 pub currency: CurrencyId,
54 /// Spendable balance (not reserved by any open order).
55 pub free: u64,
56 /// Balance reserved by open orders. `free + reserved` is the total holding.
57 pub reserved: u64,
58}
59
60impl AccountBalance {
61 /// Zero-valued placeholder used to pad the fixed-size balance array
62 /// up to its declared length when an account holds fewer currencies.
63 pub const ZERO: Self = Self {
64 currency: CurrencyId(0),
65 free: 0,
66 reserved: 0,
67 };
68}
69
70/// Maps an instrument to its base and quote currencies.
71///
72/// Example: BTC/USD → base = BTC (what you buy/sell), quote = USD (what you pay with).
73/// The account manager uses this to determine which balances to reserve and credit.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct InstrumentSpec {
76 pub symbol: Symbol,
77 pub base: CurrencyId,
78 pub quote: CurrencyId,
79}
80
81/// Per-instrument risk limits for fat finger checks. Checked in
82/// `Exchange::execute()` before balance reservation and matching.
83///
84/// `Option` fields: `None` means "no limit" (unconfigured instruments
85/// pass all checks). Both fields use `Copy`-friendly types for zero-cost
86/// hot-path access.
87#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
88pub struct RiskLimits {
89 /// Maximum order quantity (in lots). Rejects orders where
90 /// `quantity > max_order_qty`.
91 pub max_order_qty: Option<Quantity>,
92 /// Maximum order notional value (price × quantity, in ticks).
93 /// Uses `u64` for the configured ceiling — the actual comparison
94 /// uses `u128` to avoid overflow on `price.get() * quantity.get()`.
95 /// Applies only to orders with a known price (Limit, StopLimit);
96 /// Market and Stop orders skip this check.
97 pub max_order_notional: Option<u64>,
98}
99
100/// Per-instrument circuit breaker configuration. Checked in
101/// `Exchange::execute()` after dedup and before fat finger checks.
102///
103/// Static price bands reject orders with a limit price outside
104/// `[lower, upper]`. The `halted` flag rejects all new orders.
105/// `Copy`-friendly for zero-cost hot-path access.
106#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
107pub struct CircuitBreakerConfig {
108 /// Inclusive lower bound for limit order prices. `None` = no lower bound.
109 pub price_band_lower: Option<Price>,
110 /// Inclusive upper bound for limit order prices. `None` = no upper bound.
111 pub price_band_upper: Option<Price>,
112 /// When true, reject all new orders for this instrument.
113 pub halted: bool,
114}
115
116/// Per-instrument maker/taker fee schedule.
117///
118/// Fees are in basis points (1 bp = 0.01%), charged in quote currency
119/// (cost-based): `fee = price * quantity * bps / 10_000`. The buyer's
120/// fee is deducted from their reservation; the seller's fee is deducted
121/// from their proceeds.
122///
123/// Negative values represent rebates — the exchange pays the trader.
124/// Example: `maker_fee_bps = -10, taker_fee_bps = 20` means the maker
125/// receives a 0.10% rebate while the taker pays 0.20%.
126///
127/// Uses `i16` to support the range -10000..10000, covering both fees
128/// and rebates within basis-point precision.
129#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
130pub struct FeeSchedule {
131 /// Maker fee in basis points (-10000..10000). Negative = rebate.
132 pub maker_fee_bps: i16,
133 /// Taker fee in basis points (-10000..10000). Negative = rebate.
134 pub taker_fee_bps: i16,
135}
136
137/// Price in ticks (fixed-point). A tick is the smallest price increment
138/// for a given instrument.
139///
140/// Uses `NonZeroU64` rather than `u128` because: u64 supports prices up to
141/// 18.4 quintillion ticks (sufficient for any real-world instrument), fits in
142/// a single register, and keeps structs cache-line friendly.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
144pub struct Price(pub NonZeroU64);
145
146/// Quantity in lots.
147///
148/// Uses `NonZeroU64` because zero-quantity orders are invalid by definition.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
150pub struct Quantity(pub NonZeroU64);
151
152impl Quantity {
153 pub fn get(self) -> u64 {
154 self.0.get()
155 }
156
157 /// Returns the remaining quantity after subtracting, or `None` if fully filled.
158 pub fn checked_sub(self, other: Quantity) -> Option<Quantity> {
159 self.0
160 .get()
161 .checked_sub(other.0.get())
162 .and_then(NonZeroU64::new)
163 .map(Quantity)
164 }
165
166 pub fn min(self, other: Quantity) -> Quantity {
167 Quantity(self.0.min(other.0))
168 }
169}
170
171impl Price {
172 pub fn get(self) -> u64 {
173 self.0.get()
174 }
175}
176
177/// Instrument lifecycle status, managed by operator commands.
178///
179/// `#[repr(u8)]` for stable wire encoding (1 byte in snapshot/protocol).
180/// Three-state lifecycle: Enabled (normal trading) → Disabled (no new orders,
181/// all resting orders cancelled) → Removed (slot freed for reuse).
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183#[repr(u8)]
184pub enum InstrumentStatus {
185 Enabled = 0,
186 Disabled = 1,
187 Removed = 2,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum Side {
192 Buy,
193 Sell,
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum OrderType {
198 /// Execute immediately at the best available price.
199 Market,
200 /// Execute at the specified price or better.
201 /// When `post_only` is true, the order is rejected if it would
202 /// immediately match (cross the spread) — guarantees maker-only execution.
203 Limit { price: Price, post_only: bool },
204 /// Becomes a market order when the last trade price reaches the trigger.
205 /// Stop buy triggers when price >= trigger; stop sell when price <= trigger.
206 Stop { trigger_price: Price },
207 /// Becomes a limit order when the last trade price reaches the trigger.
208 StopLimit {
209 trigger_price: Price,
210 limit_price: Price,
211 },
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub enum TimeInForce {
216 /// Good-Til-Cancelled: remains on the book until filled or cancelled.
217 GTC,
218 /// Immediate-Or-Cancel: fill what you can, cancel the rest.
219 IOC,
220 /// Fill-Or-Kill: fill entirely or cancel entirely.
221 FOK,
222 /// Day: rests on the book like GTC, but automatically cancelled when
223 /// an `EndOfDay` event is processed.
224 Day,
225 /// Good-Till-Date: rests on the book until the specified expiry time,
226 /// then automatically cancelled by the engine's scheduler when a `Tick`
227 /// event arrives with `now_ns >= expiry_ns`.
228 GTD,
229}
230
231/// Self-trade prevention mode, set per order.
232///
233/// Determines behavior when an incoming (taker) order would match against
234/// a resting (maker) order from the same account.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
236pub enum SelfTradeProtection {
237 /// Self-trades are allowed — no prevention.
238 Allow,
239 /// Cancel the incoming taker order's remaining quantity.
240 /// The resting maker order stays on the book.
241 #[default]
242 CancelNewest,
243 /// Cancel the resting maker order and continue matching the taker
244 /// against remaining orders.
245 CancelOldest,
246 /// Cancel both the resting maker and the incoming taker's remaining quantity.
247 CancelBoth,
248}
249
250/// An incoming order request.
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub struct Order {
253 pub id: OrderId,
254 pub account: AccountId,
255 pub side: Side,
256 pub order_type: OrderType,
257 pub time_in_force: TimeInForce,
258 pub quantity: Quantity,
259 /// Self-trade prevention mode.
260 pub stp: SelfTradeProtection,
261 /// Expiry time in nanoseconds since Unix epoch. Only meaningful when
262 /// `time_in_force` is `GTD`. Zero for all other TIF variants.
263 /// Compared against the `now_ns` of `Tick` events to drive scheduler
264 /// cancellation.
265 pub expiry_ns: u64,
266}
267
268/// Events emitted by the matching engine's hot path (order placement,
269/// fills, cancels, etc.).
270///
271/// Kept small so the per-event scratch `Vec<ExecutionReport>` stays
272/// cache-friendly. Query responses (`Stats`, `Position`) live in
273/// [`QueryResponse`] and bypass the scratch vec entirely — they are
274/// returned directly from `Application::apply` and written to the
275/// output ring as `OutputPayload::QueryResponse`.
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub enum ExecutionReport {
278 /// Order was placed on the book (resting).
279 Placed {
280 order_id: OrderId,
281 symbol: Symbol,
282 account: AccountId,
283 side: Side,
284 price: Price,
285 quantity: Quantity,
286 },
287 /// A trade occurred between two orders.
288 Fill {
289 maker_order_id: OrderId,
290 taker_order_id: OrderId,
291 symbol: Symbol,
292 maker_account: AccountId,
293 taker_account: AccountId,
294 price: Price,
295 quantity: Quantity,
296 /// Fee charged to the maker in quote currency. Positive = fee
297 /// deducted from proceeds, negative = rebate credited to the maker.
298 maker_fee: i64,
299 /// Fee charged to the taker in quote currency. Positive = fee
300 /// deducted from proceeds, negative = rebate credited to the taker.
301 taker_fee: i64,
302 },
303 /// Order was cancelled (or remainder cancelled for IOC).
304 Cancelled {
305 order_id: OrderId,
306 symbol: Symbol,
307 account: AccountId,
308 remaining_quantity: Quantity,
309 },
310 /// A stop order was triggered by a trade at the given price.
311 Triggered {
312 order_id: OrderId,
313 symbol: Symbol,
314 account: AccountId,
315 trigger_price: Price,
316 },
317 /// Order was rejected (e.g., market order on empty book, FOK can't fill).
318 Rejected {
319 order_id: OrderId,
320 symbol: Symbol,
321 account: AccountId,
322 reason: RejectReason,
323 },
324 /// Order was amended via cancel-replace. Emitted on success.
325 Replaced {
326 order_id: OrderId,
327 symbol: Symbol,
328 account: AccountId,
329 side: Side,
330 old_price: Price,
331 new_price: Price,
332 old_remaining: Quantity,
333 new_remaining: Quantity,
334 },
335 /// Instrument lifecycle status changed (disabled, enabled, or removed).
336 InstrumentStatusChanged {
337 symbol: Symbol,
338 status: InstrumentStatus,
339 },
340}
341
342/// 1:1 query responses returned directly from `Application::apply`,
343/// bypassing the fan-out scratch vec. Routed through
344/// `OutputPayload::QueryResponse` so the response stage can translate
345/// them to the public wire format.
346///
347/// Kept separate from `ExecutionReport` to avoid inflating that enum's
348/// size with the large `Position` balance array (392 B vs ~64 B).
349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
350#[allow(clippy::large_enum_variant)]
351pub enum QueryResponse {
352 /// Transport stats snapshot emitted in response to a `QueryStats`
353 /// event. Internal — never journaled, never sent on the wire
354 /// directly. The response stage translates this to
355 /// `ResponseKind::StatsHeader` for the client.
356 Stats {
357 active_connections: u64,
358 events_processed: u64,
359 journal_sequence: u64,
360 },
361 /// Account balance snapshot emitted in response to `QueryPosition`.
362 /// Internal — translated to `ResponseKind::PositionSnapshot` on the
363 /// wire. Fixed array sized for the maximum number of currencies per
364 /// account; `count` reports how many entries are populated.
365 Position {
366 account: AccountId,
367 balances: [AccountBalance; 16],
368 count: u8,
369 },
370 /// Per-key request_seq HWM snapshot emitted in response to
371 /// `QueryRequestSeq`. The engine returns the value its dedup gate
372 /// has currently advanced to for the calling connection's key;
373 /// reconnecting clients should set their next outbound seq to
374 /// `hwm + 1` so subsequent requests bypass dedup. `0` for a key
375 /// that has never authenticated before.
376 RequestSeqHwm { hwm: u64 },
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq)]
380pub enum RejectReason {
381 /// Market order with no liquidity on the opposite side.
382 NoLiquidity,
383 /// FOK order cannot be fully filled.
384 FOKCannotFill,
385 /// Account does not have sufficient available balance.
386 InsufficientBalance,
387 /// The account is not registered.
388 UnknownAccount,
389 /// The instrument is not registered.
390 UnknownSymbol,
391 /// Self-trade prevention triggered — order would match against
392 /// the same account.
393 SelfTradePrevented,
394 /// Duplicate order ID — an order with this ID (or a higher one) was
395 /// already submitted by this account. Prevents double-execution on
396 /// crash-recovery retry.
397 DuplicateOrderId,
398 /// Order quantity exceeds the instrument's configured maximum.
399 ExceedsMaxOrderQty,
400 /// Order notional (price × quantity) exceeds the instrument's
401 /// configured maximum.
402 ExceedsMaxNotional,
403 /// Trading is halted for this instrument (circuit breaker).
404 TradingHalted,
405 /// Order price is outside the instrument's configured price bands.
406 OutsidePriceBand,
407 /// Cancel-replace target order not found on the book.
408 UnknownOrder,
409 /// Cancel-replace new price would cross the opposite best price.
410 /// Cancel and submit a new order to aggress.
411 PriceWouldCross,
412 /// Post-only order would immediately match against resting liquidity.
413 PostOnlyWouldCross,
414 /// Withdrawal rejected because the account has resting orders.
415 /// Must CancelAll first.
416 HasRestingOrders,
417 /// Duplicate request — a request with this sequence number (or higher)
418 /// was already processed for this authentication key. Prevents
419 /// double-execution on retry after network failure.
420 DuplicateRequest,
421 /// Replication is enabled but the replica is disconnected. All
422 /// state-mutating operations are rejected until the replica reconnects
423 /// to preserve the durability guarantee.
424 ReplicaDisconnected,
425 /// This node was superseded by a higher-epoch primary (fenced) and is
426 /// self-demoting after a failover. State-mutating operations are
427 /// rejected because the node no longer owns the lineage; reconnect to
428 /// land on the new primary. Unlike `ReplicaDisconnected`, this can fire
429 /// while the node still has healthy replicas attached.
430 Superseded,
431 /// GTD order with expiry_ns == 0 (missing expiry), or non-GTD order
432 /// with expiry_ns != 0 (unexpected expiry).
433 InvalidExpiry,
434 /// Instrument is disabled — no new orders or amendments accepted.
435 InstrumentDisabled,
436 /// Account already has the maximum number of open orders (resting
437 /// limits plus pending stops, across all instruments). Configured by
438 /// the operator to bound order_index growth (SEC-03). Cancel an
439 /// existing order before placing a new one.
440 ExceedsMaxOpenOrders,
441 /// Account has exceeded its order-submission rate limit (token
442 /// bucket: sustained orders/sec + burst). Configured by the operator
443 /// to prevent a single client from monopolizing the matching stage
444 /// (SEC-04). Slow submission rate or wait for the bucket to refill.
445 ExceedsOrderRate,
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 /// Helper to create a Quantity in tests.
453 fn qty(n: u64) -> Quantity {
454 Quantity(NonZeroU64::new(n).unwrap())
455 }
456
457 #[test]
458 fn quantity_checked_sub_partial() {
459 assert_eq!(qty(10).checked_sub(qty(3)), Some(qty(7)));
460 }
461
462 #[test]
463 fn quantity_checked_sub_exact_returns_none() {
464 // Exact fill returns None (not zero), since Quantity wraps NonZeroU64.
465 assert_eq!(qty(10).checked_sub(qty(10)), None);
466 }
467
468 #[test]
469 fn quantity_checked_sub_overflow_returns_none() {
470 assert_eq!(qty(3).checked_sub(qty(10)), None);
471 }
472
473 #[test]
474 fn niche_optimization() {
475 // Option<Price/Quantity> must be the same size as the inner type
476 // thanks to NonZeroU64 — this is a design invariant we rely on.
477 assert_eq!(
478 std::mem::size_of::<Option<Price>>(),
479 std::mem::size_of::<Price>()
480 );
481 assert_eq!(
482 std::mem::size_of::<Option<Quantity>>(),
483 std::mem::size_of::<Quantity>()
484 );
485 }
486}