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. The engine rejects
129/// schedules outside that range (`Exchange::set_fee_schedule`).
130///
131/// The executable form of the formula lives in `fee_from_bps` in the
132/// exchange-core crate's execute module — it is deliberately written
133/// without a 128-bit division (hot-path cost) and with exact truncating
134/// semantics. Any new fee-computing site must call it (or replicate its
135/// truncation exactly): a floor-based or wider reimplementation would
136/// diverge by one unit on rebates and break fee reconciliation.
137#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
138pub struct FeeSchedule {
139 /// Maker fee in basis points (-10000..10000). Negative = rebate.
140 pub maker_fee_bps: i16,
141 /// Taker fee in basis points (-10000..10000). Negative = rebate.
142 pub taker_fee_bps: i16,
143}
144
145/// Price in ticks (fixed-point). A tick is the smallest price increment
146/// for a given instrument.
147///
148/// Uses `NonZeroU64` rather than `u128` because: u64 supports prices up to
149/// 18.4 quintillion ticks (sufficient for any real-world instrument), fits in
150/// a single register, and keeps structs cache-line friendly.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
152pub struct Price(pub NonZeroU64);
153
154/// Quantity in lots.
155///
156/// Uses `NonZeroU64` because zero-quantity orders are invalid by definition.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
158pub struct Quantity(pub NonZeroU64);
159
160impl Quantity {
161 pub fn get(self) -> u64 {
162 self.0.get()
163 }
164
165 /// Returns the remaining quantity after subtracting, or `None` if fully filled.
166 pub fn checked_sub(self, other: Quantity) -> Option<Quantity> {
167 self.0
168 .get()
169 .checked_sub(other.0.get())
170 .and_then(NonZeroU64::new)
171 .map(Quantity)
172 }
173
174 pub fn min(self, other: Quantity) -> Quantity {
175 Quantity(self.0.min(other.0))
176 }
177}
178
179impl Price {
180 pub fn get(self) -> u64 {
181 self.0.get()
182 }
183}
184
185/// Instrument lifecycle status, managed by operator commands.
186///
187/// `#[repr(u8)]` for stable wire encoding (1 byte in snapshot/protocol).
188/// Three-state lifecycle: Enabled (normal trading) → Disabled (no new orders,
189/// all resting orders cancelled) → Removed (slot freed for reuse).
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191#[repr(u8)]
192pub enum InstrumentStatus {
193 Enabled = 0,
194 Disabled = 1,
195 Removed = 2,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum Side {
200 Buy,
201 Sell,
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum OrderType {
206 /// Execute immediately at the best available price.
207 Market,
208 /// Execute at the specified price or better.
209 /// When `post_only` is true, the order is rejected if it would
210 /// immediately match (cross the spread) — guarantees maker-only execution.
211 Limit { price: Price, post_only: bool },
212 /// Becomes a market order when the last trade price reaches the trigger.
213 /// Stop buy triggers when price >= trigger; stop sell when price <= trigger.
214 Stop { trigger_price: Price },
215 /// Becomes a limit order when the last trade price reaches the trigger.
216 StopLimit {
217 trigger_price: Price,
218 limit_price: Price,
219 },
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum TimeInForce {
224 /// Good-Til-Cancelled: remains on the book until filled or cancelled.
225 GTC,
226 /// Immediate-Or-Cancel: fill what you can, cancel the rest.
227 IOC,
228 /// Fill-Or-Kill: fill entirely or cancel entirely.
229 FOK,
230 /// Day: rests on the book like GTC, but automatically cancelled when
231 /// an `EndOfDay` event is processed.
232 Day,
233 /// Good-Till-Date: rests on the book until the specified expiry time,
234 /// then automatically cancelled by the engine's scheduler when a `Tick`
235 /// event arrives with `now_ns >= expiry_ns`.
236 GTD,
237}
238
239/// Self-trade prevention mode, set per order.
240///
241/// Determines behavior when an incoming (taker) order would match against
242/// a resting (maker) order from the same account.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
244pub enum SelfTradeProtection {
245 /// Self-trades are allowed — no prevention.
246 Allow,
247 /// Cancel the incoming taker order's remaining quantity.
248 /// The resting maker order stays on the book.
249 #[default]
250 CancelNewest,
251 /// Cancel the resting maker order and continue matching the taker
252 /// against remaining orders.
253 CancelOldest,
254 /// Cancel both the resting maker and the incoming taker's remaining quantity.
255 CancelBoth,
256}
257
258/// An incoming order request.
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260pub struct Order {
261 pub id: OrderId,
262 pub account: AccountId,
263 pub side: Side,
264 pub order_type: OrderType,
265 pub time_in_force: TimeInForce,
266 pub quantity: Quantity,
267 /// Self-trade prevention mode.
268 pub stp: SelfTradeProtection,
269 /// Expiry time in nanoseconds since Unix epoch. Only meaningful when
270 /// `time_in_force` is `GTD`. Zero for all other TIF variants.
271 /// Compared against the `now_ns` of `Tick` events to drive scheduler
272 /// cancellation.
273 pub expiry_ns: u64,
274}
275
276/// Events emitted by the matching engine's hot path (order placement,
277/// fills, cancels, etc.).
278///
279/// Kept small so the per-event scratch `Vec<ExecutionReport>` stays
280/// cache-friendly. Query responses (`Stats`, `Position`) live in
281/// [`QueryResponse`] and bypass the scratch vec entirely — they are
282/// returned directly from `Application::apply` and written to the
283/// output ring as `OutputPayload::QueryResponse`.
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub enum ExecutionReport {
286 /// Order was placed on the book (resting).
287 Placed {
288 order_id: OrderId,
289 symbol: Symbol,
290 account: AccountId,
291 side: Side,
292 price: Price,
293 quantity: Quantity,
294 },
295 /// A trade occurred between two orders.
296 Fill {
297 maker_order_id: OrderId,
298 taker_order_id: OrderId,
299 symbol: Symbol,
300 maker_account: AccountId,
301 taker_account: AccountId,
302 price: Price,
303 quantity: Quantity,
304 /// Fee charged to the maker in quote currency. Positive = fee
305 /// deducted from proceeds, negative = rebate credited to the maker.
306 maker_fee: i64,
307 /// Fee charged to the taker in quote currency. Positive = fee
308 /// deducted from proceeds, negative = rebate credited to the taker.
309 taker_fee: i64,
310 },
311 /// Order was cancelled (or remainder cancelled for IOC).
312 Cancelled {
313 order_id: OrderId,
314 symbol: Symbol,
315 account: AccountId,
316 remaining_quantity: Quantity,
317 },
318 /// A stop order was triggered by a trade at the given price.
319 Triggered {
320 order_id: OrderId,
321 symbol: Symbol,
322 account: AccountId,
323 trigger_price: Price,
324 },
325 /// Order was rejected (e.g., market order on empty book, FOK can't fill).
326 Rejected {
327 order_id: OrderId,
328 symbol: Symbol,
329 account: AccountId,
330 reason: RejectReason,
331 },
332 /// Order was amended via cancel-replace. Emitted on success.
333 Replaced {
334 order_id: OrderId,
335 symbol: Symbol,
336 account: AccountId,
337 side: Side,
338 old_price: Price,
339 new_price: Price,
340 old_remaining: Quantity,
341 new_remaining: Quantity,
342 },
343 /// Instrument lifecycle status changed (disabled, enabled, or removed).
344 InstrumentStatusChanged {
345 symbol: Symbol,
346 status: InstrumentStatus,
347 },
348}
349
350/// 1:1 query responses returned directly from `Application::apply`,
351/// bypassing the fan-out scratch vec. Routed through
352/// `OutputPayload::QueryResponse` so the response stage can translate
353/// them to the public wire format.
354///
355/// Kept separate from `ExecutionReport` to avoid inflating that enum's
356/// size with the large `Position` balance array (392 B vs ~64 B).
357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
358#[allow(clippy::large_enum_variant)]
359pub enum QueryResponse {
360 /// Transport stats snapshot emitted in response to a `QueryStats`
361 /// event. Internal — never journaled, never sent on the wire
362 /// directly. The response stage translates this to
363 /// `ResponseKind::StatsHeader` for the client.
364 Stats {
365 active_connections: u64,
366 events_processed: u64,
367 journal_sequence: u64,
368 },
369 /// Account balance snapshot emitted in response to `QueryPosition`.
370 /// Internal — translated to `ResponseKind::PositionSnapshot` on the
371 /// wire. Fixed array sized for the maximum number of currencies per
372 /// account; `count` reports how many entries are populated.
373 Position {
374 account: AccountId,
375 balances: [AccountBalance; 16],
376 count: u8,
377 },
378 /// Per-key request_seq HWM snapshot emitted in response to
379 /// `QueryRequestSeq`. The engine returns the value its dedup gate
380 /// has currently advanced to for the calling connection's key;
381 /// reconnecting clients should set their next outbound seq to
382 /// `hwm + 1` so subsequent requests bypass dedup. `0` for a key
383 /// that has never authenticated before.
384 RequestSeqHwm { hwm: u64 },
385}
386
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub enum RejectReason {
389 /// Market order with no liquidity on the opposite side.
390 NoLiquidity,
391 /// FOK order cannot be fully filled.
392 FOKCannotFill,
393 /// Account does not have sufficient available balance.
394 InsufficientBalance,
395 /// The account is not registered.
396 UnknownAccount,
397 /// The instrument is not registered.
398 UnknownSymbol,
399 /// Self-trade prevention triggered — order would match against
400 /// the same account.
401 SelfTradePrevented,
402 /// Duplicate order ID — another order with this ID is currently live
403 /// (resting or pending) for this account. Cancel/replace look up by
404 /// `(account, order_id)`, so two live orders sharing it would be
405 /// ambiguous. Reusing an ID after its original closes is permitted.
406 DuplicateOrderId,
407 /// Order quantity exceeds the instrument's configured maximum.
408 ExceedsMaxOrderQty,
409 /// Order notional (price × quantity) exceeds the instrument's
410 /// configured maximum.
411 ExceedsMaxNotional,
412 /// Trading is halted for this instrument (circuit breaker).
413 TradingHalted,
414 /// Order price is outside the instrument's configured price bands.
415 OutsidePriceBand,
416 /// Cancel-replace target order not found on the book.
417 UnknownOrder,
418 /// Cancel-replace new price would cross the opposite best price.
419 /// Cancel and submit a new order to aggress.
420 PriceWouldCross,
421 /// Post-only order would immediately match against resting liquidity.
422 PostOnlyWouldCross,
423 /// Withdrawal rejected because the account has resting orders.
424 /// Must CancelAll first.
425 HasRestingOrders,
426 /// Duplicate request — a request with this sequence number (or higher)
427 /// was already processed for this authentication key. Prevents
428 /// double-execution on retry after network failure.
429 DuplicateRequest,
430 /// Replication is enabled but the replica is disconnected. All
431 /// state-mutating operations are rejected until the replica reconnects
432 /// to preserve the durability guarantee.
433 ReplicaDisconnected,
434 /// This node was superseded by a higher-epoch primary (fenced) and is
435 /// self-demoting after a failover. State-mutating operations are
436 /// rejected because the node no longer owns the lineage; reconnect to
437 /// land on the new primary. Unlike `ReplicaDisconnected`, this can fire
438 /// while the node still has healthy replicas attached.
439 Superseded,
440 /// GTD order with expiry_ns == 0 (missing expiry), or non-GTD order
441 /// with expiry_ns != 0 (unexpected expiry).
442 InvalidExpiry,
443 /// Instrument is disabled — no new orders or amendments accepted.
444 InstrumentDisabled,
445 /// Account already has the maximum number of open orders (resting
446 /// limits plus pending stops, across all instruments). Configured by
447 /// the operator to bound order_index growth (SEC-03). Cancel an
448 /// existing order before placing a new one.
449 ExceedsMaxOpenOrders,
450 /// Account has exceeded its order-submission rate limit (token
451 /// bucket: sustained orders/sec + burst). Configured by the operator
452 /// to prevent a single client from monopolizing the matching stage
453 /// (SEC-04). Slow submission rate or wait for the bucket to refill.
454 ExceedsOrderRate,
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460
461 /// Helper to create a Quantity in tests.
462 fn qty(n: u64) -> Quantity {
463 Quantity(NonZeroU64::new(n).unwrap())
464 }
465
466 #[test]
467 fn quantity_checked_sub_partial() {
468 assert_eq!(qty(10).checked_sub(qty(3)), Some(qty(7)));
469 }
470
471 #[test]
472 fn quantity_checked_sub_exact_returns_none() {
473 // Exact fill returns None (not zero), since Quantity wraps NonZeroU64.
474 assert_eq!(qty(10).checked_sub(qty(10)), None);
475 }
476
477 #[test]
478 fn quantity_checked_sub_overflow_returns_none() {
479 assert_eq!(qty(3).checked_sub(qty(10)), None);
480 }
481
482 #[test]
483 fn niche_optimization() {
484 // Option<Price/Quantity> must be the same size as the inner type
485 // thanks to NonZeroU64 — this is a design invariant we rely on.
486 assert_eq!(
487 std::mem::size_of::<Option<Price>>(),
488 std::mem::size_of::<Price>()
489 );
490 assert_eq!(
491 std::mem::size_of::<Option<Quantity>>(),
492 std::mem::size_of::<Quantity>()
493 );
494 }
495}