Skip to main content

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}