Skip to main content

orderbook_rs/orderbook/
reject_reason.rs

1//! Closed taxonomy of order-rejection reasons exposed on the wire.
2//!
3//! [`RejectReason`] is the canonical wire-side reject code surfaced on
4//! `OrderStatus::Rejected`. Each named variant carries a stable
5//! `#[repr(u16)]` discriminant — consumers that publish or parse the
6//! value over the wire can rely on those numbers staying stable across
7//! `0.7.x` and `0.7.x → 0.7.y` patch upgrades.
8//!
9//! Forward compatibility is preserved by:
10//!
11//! - `#[non_exhaustive]` so adding a new variant is non-breaking on
12//!   downstream `match` blocks (consumers must keep a wildcard arm).
13//! - [`RejectReason::Other`] as an escape hatch for application-side
14//!   extensions. Values `>= 1000` are reserved for caller use; the
15//!   library itself will never emit a value in that range.
16//!
17//! The [`From<&OrderBookError>`](RejectReason#impl-From<%26OrderBookError>-for-RejectReason)
18//! impl provides operational ergonomics for callers that already hold a
19//! typed [`OrderBookError`]: the typed error is the impl detail, the
20//! [`RejectReason`] is the stable public contract.
21
22use crate::orderbook::error::OrderBookError;
23use serde::{Deserialize, Deserializer, Serialize, Serializer};
24
25/// Closed taxonomy of reasons an order may be rejected at admission.
26///
27/// `RejectReason` is the stable wire-side reject code. Each variant has
28/// an explicit `#[repr(u16)]` discriminant — consumers that publish or
29/// parse the value over the wire can rely on those numbers staying
30/// stable across `0.7.x` and `0.7.x → 0.7.y` patch upgrades. Forward
31/// compatibility is preserved by:
32///
33/// - `#[non_exhaustive]` so adding a variant is non-breaking on
34///   downstream `match` blocks.
35/// - [`Self::Other`] as an escape hatch for application-side extensions.
36///   Values `>= 1000` are reserved for caller use; the library will
37///   never emit a value in that range.
38///
39/// # Discriminant table
40///
41/// | Variant                  | u16 |
42/// |--------------------------|-----|
43/// | `KillSwitchActive`       | 1   |
44/// | `RiskMaxOpenOrders`      | 2   |
45/// | `RiskMaxNotional`        | 3   |
46/// | `RiskPriceBand`          | 4   |
47/// | `PostOnlyWouldCross`     | 5   |
48/// | `SelfTradePrevention`    | 6   |
49/// | `InvalidPrice`           | 7   |
50/// | `InvalidQuantity`        | 8   |
51/// | `InvalidPriceLevel`      | 9   |
52/// | `OrderSizeOutOfRange`    | 10  |
53/// | `MissingUserId`          | 11  |
54/// | `DuplicateOrderId`       | 12  |
55/// | `InsufficientLiquidity`  | 13  |
56/// | `Other(code)`            | code|
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58#[non_exhaustive]
59#[repr(u16)]
60pub enum RejectReason {
61    /// New flow rejected because the operational kill switch is engaged.
62    KillSwitchActive = 1,
63    /// Per-account open-order limit would be breached by this admission.
64    RiskMaxOpenOrders = 2,
65    /// Per-account notional limit would be breached by this admission.
66    RiskMaxNotional = 3,
67    /// Submitted price exceeds the configured price band against the
68    /// reference price.
69    RiskPriceBand = 4,
70    /// Post-only order would cross the resting opposite side at the
71    /// time of admission.
72    PostOnlyWouldCross = 5,
73    /// Self-trade prevention rejected the incoming order.
74    SelfTradePrevention = 6,
75    /// Submitted price violates the configured tick-size validation.
76    InvalidPrice = 7,
77    /// Submitted quantity violates the configured lot-size validation.
78    InvalidQuantity = 8,
79    /// The targeted price level is invalid for the requested operation.
80    InvalidPriceLevel = 9,
81    /// Submitted quantity is outside the configured min/max range.
82    OrderSizeOutOfRange = 10,
83    /// `user_id` is missing or zero while STP is enabled.
84    MissingUserId = 11,
85    /// An order with the same id is already present in the book.
86    DuplicateOrderId = 12,
87    /// The order could not be filled with the available resting depth
88    /// (IOC / FOK semantics).
89    InsufficientLiquidity = 13,
90    /// Caller-supplied / unmapped code. The library never emits this
91    /// variant; it exists so applications can ferry their own reject
92    /// codes through the same channel without forking the enum.
93    Other(u16),
94}
95
96impl RejectReason {
97    /// Numeric wire code. Stable across `0.7.x`.
98    ///
99    /// For named variants this returns the explicit `#[repr(u16)]`
100    /// discriminant; for [`Self::Other`] this returns the wrapped
101    /// caller-supplied code verbatim.
102    #[inline]
103    #[must_use]
104    pub fn as_u16(self) -> u16 {
105        match self {
106            Self::KillSwitchActive => 1,
107            Self::RiskMaxOpenOrders => 2,
108            Self::RiskMaxNotional => 3,
109            Self::RiskPriceBand => 4,
110            Self::PostOnlyWouldCross => 5,
111            Self::SelfTradePrevention => 6,
112            Self::InvalidPrice => 7,
113            Self::InvalidQuantity => 8,
114            Self::InvalidPriceLevel => 9,
115            Self::OrderSizeOutOfRange => 10,
116            Self::MissingUserId => 11,
117            Self::DuplicateOrderId => 12,
118            Self::InsufficientLiquidity => 13,
119            Self::Other(code) => code,
120        }
121    }
122
123    /// Reconstruct a [`RejectReason`] from its wire code. Known
124    /// discriminants map to their named variant; any other value is
125    /// preserved via [`Self::Other`] so older deserializers can carry
126    /// forward unknown codes minted by newer producers.
127    #[inline]
128    #[must_use]
129    pub fn from_u16(code: u16) -> Self {
130        match code {
131            1 => Self::KillSwitchActive,
132            2 => Self::RiskMaxOpenOrders,
133            3 => Self::RiskMaxNotional,
134            4 => Self::RiskPriceBand,
135            5 => Self::PostOnlyWouldCross,
136            6 => Self::SelfTradePrevention,
137            7 => Self::InvalidPrice,
138            8 => Self::InvalidQuantity,
139            9 => Self::InvalidPriceLevel,
140            10 => Self::OrderSizeOutOfRange,
141            11 => Self::MissingUserId,
142            12 => Self::DuplicateOrderId,
143            13 => Self::InsufficientLiquidity,
144            other => Self::Other(other),
145        }
146    }
147}
148
149/// Serialize as the stable `u16` wire code via [`RejectReason::as_u16`].
150///
151/// JSON / Bincode / any serde format encodes the numeric reject code,
152/// not the variant name or an internal serde index. This is what the
153/// wire-stability rustdoc on the type promises and what consumers can
154/// rely on across `0.7.x` patch upgrades.
155impl Serialize for RejectReason {
156    #[inline]
157    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
158        serializer.serialize_u16(self.as_u16())
159    }
160}
161
162/// Deserialize from the stable `u16` wire code via
163/// [`RejectReason::from_u16`].
164///
165/// Unknown codes map to [`RejectReason::Other`] so an older deserializer
166/// can still parse a payload minted by a newer producer that has
167/// introduced a new reject variant.
168impl<'de> Deserialize<'de> for RejectReason {
169    #[inline]
170    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
171        let code = u16::deserialize(deserializer)?;
172        Ok(Self::from_u16(code))
173    }
174}
175
176impl std::fmt::Display for RejectReason {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        match self {
179            Self::KillSwitchActive => write!(f, "kill switch active"),
180            Self::RiskMaxOpenOrders => write!(f, "risk: max open orders"),
181            Self::RiskMaxNotional => write!(f, "risk: max notional"),
182            Self::RiskPriceBand => write!(f, "risk: price band"),
183            Self::PostOnlyWouldCross => write!(f, "post-only would cross"),
184            Self::SelfTradePrevention => write!(f, "self-trade prevention"),
185            Self::InvalidPrice => write!(f, "invalid price"),
186            Self::InvalidQuantity => write!(f, "invalid quantity"),
187            Self::InvalidPriceLevel => write!(f, "invalid price level"),
188            Self::OrderSizeOutOfRange => write!(f, "order size out of range"),
189            Self::MissingUserId => write!(f, "missing user id"),
190            Self::DuplicateOrderId => write!(f, "duplicate order id"),
191            Self::InsufficientLiquidity => write!(f, "insufficient liquidity"),
192            Self::Other(code) => write!(f, "other({code})"),
193        }
194    }
195}
196
197/// Map a typed [`OrderBookError`] to its wire-side reject code.
198///
199/// Errors that do not represent a public reject (e.g.
200/// `SerializationError`, `ChecksumMismatch`, `NatsPublishError`,
201/// internal-state errors) map to \[`RejectReason::Other(0)`\] — they are
202/// not expected to surface on outbound reject events.
203///
204/// The match below is intentionally exhaustive (no `_ =>` catch-all);
205/// any new variant added to [`OrderBookError`] must extend this mapping
206/// at compile time. This is enforced because the `impl` lives inside
207/// the crate, where exhaustive matches over a `#[non_exhaustive]` enum
208/// are still permitted.
209impl From<&OrderBookError> for RejectReason {
210    #[inline]
211    fn from(err: &OrderBookError) -> Self {
212        match err {
213            OrderBookError::KillSwitchActive => Self::KillSwitchActive,
214            OrderBookError::RiskMaxOpenOrders { .. } => Self::RiskMaxOpenOrders,
215            OrderBookError::RiskMaxNotional { .. } => Self::RiskMaxNotional,
216            OrderBookError::RiskPriceBand { .. } => Self::RiskPriceBand,
217            OrderBookError::SelfTradePrevented { .. } => Self::SelfTradePrevention,
218            OrderBookError::InvalidPriceLevel(_) => Self::InvalidPriceLevel,
219            OrderBookError::PriceCrossing { .. } => Self::PostOnlyWouldCross,
220            OrderBookError::InsufficientLiquidity { .. } => Self::InsufficientLiquidity,
221            OrderBookError::InsufficientLiquidityNotional { .. } => Self::InsufficientLiquidity,
222            OrderBookError::InvalidTickSize { .. } => Self::InvalidPrice,
223            OrderBookError::InvalidLotSize { .. } => Self::InvalidQuantity,
224            OrderBookError::OrderSizeOutOfRange { .. } => Self::OrderSizeOutOfRange,
225            OrderBookError::DuplicateOrderId { .. } => Self::DuplicateOrderId,
226            OrderBookError::MissingUserId { .. } => Self::MissingUserId,
227            OrderBookError::PriceLevelError(_) => Self::Other(0),
228            OrderBookError::OrderNotFound(_) => Self::Other(0),
229            OrderBookError::InvalidOperation { .. } => Self::Other(0),
230            OrderBookError::SerializationError { .. } => Self::Other(0),
231            OrderBookError::DeserializationError { .. } => Self::Other(0),
232            OrderBookError::ChecksumMismatch { .. } => Self::Other(0),
233            #[cfg(feature = "nats")]
234            OrderBookError::NatsPublishError { .. } => Self::Other(0),
235            #[cfg(feature = "nats")]
236            OrderBookError::NatsSerializationError { .. } => Self::Other(0),
237        }
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use pricelevel::{Hash32, Id, PriceLevelError, Side};
245
246    /// Every named variant — used to drive exhaustive table-style tests.
247    /// The `Other` variant is added explicitly where needed.
248    fn named_variants() -> [RejectReason; 13] {
249        [
250            RejectReason::KillSwitchActive,
251            RejectReason::RiskMaxOpenOrders,
252            RejectReason::RiskMaxNotional,
253            RejectReason::RiskPriceBand,
254            RejectReason::PostOnlyWouldCross,
255            RejectReason::SelfTradePrevention,
256            RejectReason::InvalidPrice,
257            RejectReason::InvalidQuantity,
258            RejectReason::InvalidPriceLevel,
259            RejectReason::OrderSizeOutOfRange,
260            RejectReason::MissingUserId,
261            RejectReason::DuplicateOrderId,
262            RejectReason::InsufficientLiquidity,
263        ]
264    }
265
266    #[test]
267    fn test_discriminants_are_stable() {
268        assert_eq!(RejectReason::KillSwitchActive.as_u16(), 1);
269        assert_eq!(RejectReason::RiskMaxOpenOrders.as_u16(), 2);
270        assert_eq!(RejectReason::RiskMaxNotional.as_u16(), 3);
271        assert_eq!(RejectReason::RiskPriceBand.as_u16(), 4);
272        assert_eq!(RejectReason::PostOnlyWouldCross.as_u16(), 5);
273        assert_eq!(RejectReason::SelfTradePrevention.as_u16(), 6);
274        assert_eq!(RejectReason::InvalidPrice.as_u16(), 7);
275        assert_eq!(RejectReason::InvalidQuantity.as_u16(), 8);
276        assert_eq!(RejectReason::InvalidPriceLevel.as_u16(), 9);
277        assert_eq!(RejectReason::OrderSizeOutOfRange.as_u16(), 10);
278        assert_eq!(RejectReason::MissingUserId.as_u16(), 11);
279        assert_eq!(RejectReason::DuplicateOrderId.as_u16(), 12);
280        assert_eq!(RejectReason::InsufficientLiquidity.as_u16(), 13);
281    }
282
283    #[test]
284    fn test_other_passthrough() {
285        assert_eq!(RejectReason::Other(0).as_u16(), 0);
286        assert_eq!(RejectReason::Other(7777).as_u16(), 7777);
287        assert_eq!(RejectReason::Other(u16::MAX).as_u16(), u16::MAX);
288    }
289
290    #[test]
291    fn test_display_reads_human_text() {
292        // Smoke check that every variant produces a non-empty,
293        // human-readable line.
294        for reason in named_variants() {
295            let text = reason.to_string();
296            assert!(!text.is_empty(), "Display for {reason:?} produced empty");
297        }
298        assert_eq!(
299            RejectReason::KillSwitchActive.to_string(),
300            "kill switch active"
301        );
302        assert_eq!(RejectReason::Other(42).to_string(), "other(42)");
303    }
304
305    #[test]
306    fn test_from_order_book_error_kill_switch_maps_to_kill_switch_active() {
307        let err = OrderBookError::KillSwitchActive;
308        assert_eq!(RejectReason::from(&err), RejectReason::KillSwitchActive);
309    }
310
311    #[test]
312    fn test_from_order_book_error_risk_max_open_maps_to_risk_max_open_orders() {
313        let err = OrderBookError::RiskMaxOpenOrders {
314            account: Hash32::from([1u8; 32]),
315            current: 5,
316            limit: 5,
317        };
318        assert_eq!(RejectReason::from(&err), RejectReason::RiskMaxOpenOrders);
319    }
320
321    #[test]
322    fn test_from_order_book_error_risk_max_notional() {
323        let err = OrderBookError::RiskMaxNotional {
324            account: Hash32::from([1u8; 32]),
325            current: 100,
326            attempted: 50,
327            limit: 100,
328        };
329        assert_eq!(RejectReason::from(&err), RejectReason::RiskMaxNotional);
330    }
331
332    #[test]
333    fn test_from_order_book_error_risk_price_band() {
334        let err = OrderBookError::RiskPriceBand {
335            submitted: 1_000_000,
336            reference: 500_000,
337            deviation_bps: 10_000,
338            limit_bps: 100,
339        };
340        assert_eq!(RejectReason::from(&err), RejectReason::RiskPriceBand);
341    }
342
343    #[test]
344    fn test_from_order_book_error_invalid_price_level_maps_to_invalid_price_level() {
345        let err = OrderBookError::InvalidPriceLevel(42);
346        assert_eq!(RejectReason::from(&err), RejectReason::InvalidPriceLevel);
347    }
348
349    #[test]
350    fn test_from_order_book_error_order_size_out_of_range() {
351        let err = OrderBookError::OrderSizeOutOfRange {
352            quantity: 0,
353            min: Some(1),
354            max: Some(100),
355        };
356        assert_eq!(RejectReason::from(&err), RejectReason::OrderSizeOutOfRange);
357    }
358
359    #[test]
360    fn test_from_order_book_error_missing_user_id() {
361        let err = OrderBookError::MissingUserId {
362            order_id: Id::new_uuid(),
363        };
364        assert_eq!(RejectReason::from(&err), RejectReason::MissingUserId);
365    }
366
367    #[test]
368    fn test_from_order_book_error_duplicate_order_id() {
369        let err = OrderBookError::DuplicateOrderId {
370            order_id: Id::new_uuid(),
371        };
372        assert_eq!(RejectReason::from(&err), RejectReason::DuplicateOrderId);
373    }
374
375    #[test]
376    fn test_from_order_book_error_self_trade_prevented_maps_to_self_trade_prevention() {
377        let err = OrderBookError::SelfTradePrevented {
378            mode: crate::orderbook::stp::STPMode::CancelTaker,
379            taker_order_id: Id::new_uuid(),
380            user_id: Hash32::from([1u8; 32]),
381        };
382        assert_eq!(RejectReason::from(&err), RejectReason::SelfTradePrevention);
383    }
384
385    #[test]
386    fn test_from_order_book_error_price_crossing_maps_to_post_only_would_cross() {
387        let err = OrderBookError::PriceCrossing {
388            price: 100,
389            side: Side::Buy,
390            opposite_price: 99,
391        };
392        assert_eq!(RejectReason::from(&err), RejectReason::PostOnlyWouldCross);
393    }
394
395    #[test]
396    fn test_from_order_book_error_invalid_tick_size_maps_to_invalid_price() {
397        let err = OrderBookError::InvalidTickSize {
398            price: 150,
399            tick_size: 100,
400        };
401        assert_eq!(RejectReason::from(&err), RejectReason::InvalidPrice);
402    }
403
404    #[test]
405    fn test_from_order_book_error_invalid_lot_size_maps_to_invalid_quantity() {
406        let err = OrderBookError::InvalidLotSize {
407            quantity: 75,
408            lot_size: 10,
409        };
410        assert_eq!(RejectReason::from(&err), RejectReason::InvalidQuantity);
411    }
412
413    #[test]
414    fn test_from_order_book_error_insufficient_liquidity() {
415        let err = OrderBookError::InsufficientLiquidity {
416            side: Side::Buy,
417            requested: 100,
418            available: 50,
419        };
420        assert_eq!(
421            RejectReason::from(&err),
422            RejectReason::InsufficientLiquidity
423        );
424    }
425
426    #[test]
427    fn test_from_order_book_error_insufficient_liquidity_notional() {
428        let err = OrderBookError::InsufficientLiquidityNotional {
429            side: Side::Buy,
430            requested: 1_000_000,
431            spent: 0,
432        };
433        assert_eq!(
434            RejectReason::from(&err),
435            RejectReason::InsufficientLiquidity
436        );
437    }
438
439    #[test]
440    fn test_from_order_book_error_serialization_error_maps_to_other_zero() {
441        let err = OrderBookError::SerializationError {
442            message: "oops".to_string(),
443        };
444        assert_eq!(RejectReason::from(&err), RejectReason::Other(0));
445    }
446
447    #[test]
448    fn test_from_order_book_error_internal_state_errors_map_to_other_zero() {
449        let cases = [
450            OrderBookError::OrderNotFound("x".to_string()),
451            OrderBookError::InvalidOperation {
452                message: "nope".to_string(),
453            },
454            OrderBookError::DeserializationError {
455                message: "bad".to_string(),
456            },
457            OrderBookError::ChecksumMismatch {
458                expected: "a".to_string(),
459                actual: "b".to_string(),
460            },
461            OrderBookError::PriceLevelError(PriceLevelError::InvalidFormat),
462        ];
463        for err in cases {
464            assert_eq!(
465                RejectReason::from(&err),
466                RejectReason::Other(0),
467                "{err:?} should map to Other(0)"
468            );
469        }
470    }
471
472    #[test]
473    fn test_serde_json_roundtrip_each_variant() {
474        for reason in named_variants() {
475            let json = serde_json::to_string(&reason).expect("serialize named variant");
476            let decoded: RejectReason =
477                serde_json::from_str(&json).expect("deserialize named variant");
478            assert_eq!(decoded, reason);
479        }
480        let other = RejectReason::Other(42);
481        let json = serde_json::to_string(&other).expect("serialize Other(42)");
482        let decoded: RejectReason = serde_json::from_str(&json).expect("deserialize Other(42)");
483        assert_eq!(decoded, other);
484    }
485
486    #[test]
487    fn test_serde_json_emits_stable_u16_wire_code() {
488        // Wire format must be the documented u16 code, not a variant
489        // name or a serde-derived index. This is the contract consumers
490        // rely on across `0.7.x` patch upgrades.
491        for reason in named_variants() {
492            let json = serde_json::to_string(&reason).expect("serialize named variant");
493            assert_eq!(
494                json,
495                reason.as_u16().to_string(),
496                "JSON wire code drift for {reason:?}"
497            );
498        }
499        let other = RejectReason::Other(7777);
500        let json = serde_json::to_string(&other).expect("serialize Other");
501        assert_eq!(json, "7777");
502    }
503
504    #[test]
505    fn test_serde_json_unknown_code_decodes_to_other() {
506        // Forward-compat: an older deserializer reading a payload
507        // minted by a newer producer with a code outside the documented
508        // table preserves it via `RejectReason::Other(code)` instead of
509        // failing to deserialize.
510        let decoded: RejectReason = serde_json::from_str("999").expect("deserialize unknown code");
511        assert_eq!(decoded, RejectReason::Other(999));
512
513        // Reserved-application range round-trips too.
514        let decoded: RejectReason =
515            serde_json::from_str("1234").expect("deserialize reserved-range code");
516        assert_eq!(decoded, RejectReason::Other(1234));
517    }
518
519    #[cfg(feature = "bincode")]
520    #[test]
521    fn test_serde_bincode_roundtrip_each_variant() {
522        let cfg = bincode::config::standard();
523        for reason in named_variants() {
524            let bytes = bincode::serde::encode_to_vec(reason, cfg).expect("encode named variant");
525            let (decoded, n) = bincode::serde::decode_from_slice::<RejectReason, _>(&bytes, cfg)
526                .expect("decode named variant");
527            assert_eq!(decoded, reason);
528            assert_eq!(n, bytes.len(), "bincode should consume entire payload");
529        }
530        let other = RejectReason::Other(42);
531        let bytes = bincode::serde::encode_to_vec(other, cfg).expect("encode Other(42)");
532        let (decoded, n) = bincode::serde::decode_from_slice::<RejectReason, _>(&bytes, cfg)
533            .expect("decode Other(42)");
534        assert_eq!(decoded, other);
535        assert_eq!(n, bytes.len());
536    }
537}