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. 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}