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::QuantityOverflow { .. } => Self::InvalidQuantity,
225            OrderBookError::OrderSizeOutOfRange { .. } => Self::OrderSizeOutOfRange,
226            OrderBookError::DuplicateOrderId { .. } => Self::DuplicateOrderId,
227            OrderBookError::MissingUserId { .. } => Self::MissingUserId,
228            OrderBookError::PriceLevelError(_) => Self::Other(0),
229            OrderBookError::OrderNotFound(_) => Self::Other(0),
230            OrderBookError::InvalidOperation { .. } => Self::Other(0),
231            OrderBookError::SerializationError { .. } => Self::Other(0),
232            OrderBookError::DeserializationError { .. } => Self::Other(0),
233            OrderBookError::ChecksumMismatch { .. } => Self::Other(0),
234            #[cfg(feature = "nats")]
235            OrderBookError::NatsPublishError { .. } => Self::Other(0),
236            #[cfg(feature = "nats")]
237            OrderBookError::NatsSerializationError { .. } => Self::Other(0),
238        }
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use pricelevel::{Hash32, Id, PriceLevelError, Side};
246
247    /// Every named variant — used to drive exhaustive table-style tests.
248    /// The `Other` variant is added explicitly where needed.
249    fn named_variants() -> [RejectReason; 13] {
250        [
251            RejectReason::KillSwitchActive,
252            RejectReason::RiskMaxOpenOrders,
253            RejectReason::RiskMaxNotional,
254            RejectReason::RiskPriceBand,
255            RejectReason::PostOnlyWouldCross,
256            RejectReason::SelfTradePrevention,
257            RejectReason::InvalidPrice,
258            RejectReason::InvalidQuantity,
259            RejectReason::InvalidPriceLevel,
260            RejectReason::OrderSizeOutOfRange,
261            RejectReason::MissingUserId,
262            RejectReason::DuplicateOrderId,
263            RejectReason::InsufficientLiquidity,
264        ]
265    }
266
267    #[test]
268    fn test_discriminants_are_stable() {
269        assert_eq!(RejectReason::KillSwitchActive.as_u16(), 1);
270        assert_eq!(RejectReason::RiskMaxOpenOrders.as_u16(), 2);
271        assert_eq!(RejectReason::RiskMaxNotional.as_u16(), 3);
272        assert_eq!(RejectReason::RiskPriceBand.as_u16(), 4);
273        assert_eq!(RejectReason::PostOnlyWouldCross.as_u16(), 5);
274        assert_eq!(RejectReason::SelfTradePrevention.as_u16(), 6);
275        assert_eq!(RejectReason::InvalidPrice.as_u16(), 7);
276        assert_eq!(RejectReason::InvalidQuantity.as_u16(), 8);
277        assert_eq!(RejectReason::InvalidPriceLevel.as_u16(), 9);
278        assert_eq!(RejectReason::OrderSizeOutOfRange.as_u16(), 10);
279        assert_eq!(RejectReason::MissingUserId.as_u16(), 11);
280        assert_eq!(RejectReason::DuplicateOrderId.as_u16(), 12);
281        assert_eq!(RejectReason::InsufficientLiquidity.as_u16(), 13);
282    }
283
284    #[test]
285    fn test_other_passthrough() {
286        assert_eq!(RejectReason::Other(0).as_u16(), 0);
287        assert_eq!(RejectReason::Other(7777).as_u16(), 7777);
288        assert_eq!(RejectReason::Other(u16::MAX).as_u16(), u16::MAX);
289    }
290
291    #[test]
292    fn test_display_reads_human_text() {
293        // Smoke check that every variant produces a non-empty,
294        // human-readable line.
295        for reason in named_variants() {
296            let text = reason.to_string();
297            assert!(!text.is_empty(), "Display for {reason:?} produced empty");
298        }
299        assert_eq!(
300            RejectReason::KillSwitchActive.to_string(),
301            "kill switch active"
302        );
303        assert_eq!(RejectReason::Other(42).to_string(), "other(42)");
304    }
305
306    #[test]
307    fn test_from_order_book_error_kill_switch_maps_to_kill_switch_active() {
308        let err = OrderBookError::KillSwitchActive;
309        assert_eq!(RejectReason::from(&err), RejectReason::KillSwitchActive);
310    }
311
312    #[test]
313    fn test_from_order_book_error_risk_max_open_maps_to_risk_max_open_orders() {
314        let err = OrderBookError::RiskMaxOpenOrders {
315            account: Hash32::from([1u8; 32]),
316            current: 5,
317            limit: 5,
318        };
319        assert_eq!(RejectReason::from(&err), RejectReason::RiskMaxOpenOrders);
320    }
321
322    #[test]
323    fn test_from_order_book_error_risk_max_notional() {
324        let err = OrderBookError::RiskMaxNotional {
325            account: Hash32::from([1u8; 32]),
326            current: 100,
327            attempted: 50,
328            limit: 100,
329        };
330        assert_eq!(RejectReason::from(&err), RejectReason::RiskMaxNotional);
331    }
332
333    #[test]
334    fn test_from_order_book_error_risk_price_band() {
335        let err = OrderBookError::RiskPriceBand {
336            submitted: 1_000_000,
337            reference: 500_000,
338            deviation_bps: 10_000,
339            limit_bps: 100,
340        };
341        assert_eq!(RejectReason::from(&err), RejectReason::RiskPriceBand);
342    }
343
344    #[test]
345    fn test_from_order_book_error_invalid_price_level_maps_to_invalid_price_level() {
346        let err = OrderBookError::InvalidPriceLevel(42);
347        assert_eq!(RejectReason::from(&err), RejectReason::InvalidPriceLevel);
348    }
349
350    #[test]
351    fn test_from_order_book_error_order_size_out_of_range() {
352        let err = OrderBookError::OrderSizeOutOfRange {
353            quantity: 0,
354            min: Some(1),
355            max: Some(100),
356        };
357        assert_eq!(RejectReason::from(&err), RejectReason::OrderSizeOutOfRange);
358    }
359
360    #[test]
361    fn test_from_order_book_error_missing_user_id() {
362        let err = OrderBookError::MissingUserId {
363            order_id: Id::new_uuid(),
364        };
365        assert_eq!(RejectReason::from(&err), RejectReason::MissingUserId);
366    }
367
368    #[test]
369    fn test_from_order_book_error_duplicate_order_id() {
370        let err = OrderBookError::DuplicateOrderId {
371            order_id: Id::new_uuid(),
372        };
373        assert_eq!(RejectReason::from(&err), RejectReason::DuplicateOrderId);
374    }
375
376    #[test]
377    fn test_from_order_book_error_self_trade_prevented_maps_to_self_trade_prevention() {
378        let err = OrderBookError::SelfTradePrevented {
379            mode: crate::orderbook::stp::STPMode::CancelTaker,
380            taker_order_id: Id::new_uuid(),
381            user_id: Hash32::from([1u8; 32]),
382        };
383        assert_eq!(RejectReason::from(&err), RejectReason::SelfTradePrevention);
384    }
385
386    #[test]
387    fn test_from_order_book_error_price_crossing_maps_to_post_only_would_cross() {
388        let err = OrderBookError::PriceCrossing {
389            price: 100,
390            side: Side::Buy,
391            opposite_price: 99,
392        };
393        assert_eq!(RejectReason::from(&err), RejectReason::PostOnlyWouldCross);
394    }
395
396    #[test]
397    fn test_from_order_book_error_invalid_tick_size_maps_to_invalid_price() {
398        let err = OrderBookError::InvalidTickSize {
399            price: 150,
400            tick_size: 100,
401        };
402        assert_eq!(RejectReason::from(&err), RejectReason::InvalidPrice);
403    }
404
405    #[test]
406    fn test_from_order_book_error_invalid_lot_size_maps_to_invalid_quantity() {
407        let err = OrderBookError::InvalidLotSize {
408            quantity: 75,
409            lot_size: 10,
410        };
411        assert_eq!(RejectReason::from(&err), RejectReason::InvalidQuantity);
412    }
413
414    #[test]
415    fn test_from_order_book_error_insufficient_liquidity() {
416        let err = OrderBookError::InsufficientLiquidity {
417            side: Side::Buy,
418            requested: 100,
419            available: 50,
420        };
421        assert_eq!(
422            RejectReason::from(&err),
423            RejectReason::InsufficientLiquidity
424        );
425    }
426
427    #[test]
428    fn test_from_order_book_error_insufficient_liquidity_notional() {
429        let err = OrderBookError::InsufficientLiquidityNotional {
430            side: Side::Buy,
431            requested: 1_000_000,
432            spent: 0,
433        };
434        assert_eq!(
435            RejectReason::from(&err),
436            RejectReason::InsufficientLiquidity
437        );
438    }
439
440    #[test]
441    fn test_from_order_book_error_serialization_error_maps_to_other_zero() {
442        let err = OrderBookError::SerializationError {
443            message: "oops".to_string(),
444        };
445        assert_eq!(RejectReason::from(&err), RejectReason::Other(0));
446    }
447
448    #[test]
449    fn test_from_order_book_error_internal_state_errors_map_to_other_zero() {
450        let cases = [
451            OrderBookError::OrderNotFound("x".to_string()),
452            OrderBookError::InvalidOperation {
453                message: "nope".to_string(),
454            },
455            OrderBookError::DeserializationError {
456                message: "bad".to_string(),
457            },
458            OrderBookError::ChecksumMismatch {
459                expected: "a".to_string(),
460                actual: "b".to_string(),
461            },
462            OrderBookError::PriceLevelError(PriceLevelError::InvalidFormat),
463        ];
464        for err in cases {
465            assert_eq!(
466                RejectReason::from(&err),
467                RejectReason::Other(0),
468                "{err:?} should map to Other(0)"
469            );
470        }
471    }
472
473    #[test]
474    fn test_serde_json_roundtrip_each_variant() {
475        for reason in named_variants() {
476            let json = serde_json::to_string(&reason).expect("serialize named variant");
477            let decoded: RejectReason =
478                serde_json::from_str(&json).expect("deserialize named variant");
479            assert_eq!(decoded, reason);
480        }
481        let other = RejectReason::Other(42);
482        let json = serde_json::to_string(&other).expect("serialize Other(42)");
483        let decoded: RejectReason = serde_json::from_str(&json).expect("deserialize Other(42)");
484        assert_eq!(decoded, other);
485    }
486
487    #[test]
488    fn test_serde_json_emits_stable_u16_wire_code() {
489        // Wire format must be the documented u16 code, not a variant
490        // name or a serde-derived index. This is the contract consumers
491        // rely on across `0.7.x` patch upgrades.
492        for reason in named_variants() {
493            let json = serde_json::to_string(&reason).expect("serialize named variant");
494            assert_eq!(
495                json,
496                reason.as_u16().to_string(),
497                "JSON wire code drift for {reason:?}"
498            );
499        }
500        let other = RejectReason::Other(7777);
501        let json = serde_json::to_string(&other).expect("serialize Other");
502        assert_eq!(json, "7777");
503    }
504
505    #[test]
506    fn test_serde_json_unknown_code_decodes_to_other() {
507        // Forward-compat: an older deserializer reading a payload
508        // minted by a newer producer with a code outside the documented
509        // table preserves it via `RejectReason::Other(code)` instead of
510        // failing to deserialize.
511        let decoded: RejectReason = serde_json::from_str("999").expect("deserialize unknown code");
512        assert_eq!(decoded, RejectReason::Other(999));
513
514        // Reserved-application range round-trips too.
515        let decoded: RejectReason =
516            serde_json::from_str("1234").expect("deserialize reserved-range code");
517        assert_eq!(decoded, RejectReason::Other(1234));
518    }
519
520    #[cfg(feature = "bincode")]
521    #[test]
522    fn test_serde_bincode_roundtrip_each_variant() {
523        let cfg = bincode::config::standard();
524        for reason in named_variants() {
525            let bytes = bincode::serde::encode_to_vec(reason, cfg).expect("encode named variant");
526            let (decoded, n) = bincode::serde::decode_from_slice::<RejectReason, _>(&bytes, cfg)
527                .expect("decode named variant");
528            assert_eq!(decoded, reason);
529            assert_eq!(n, bytes.len(), "bincode should consume entire payload");
530        }
531        let other = RejectReason::Other(42);
532        let bytes = bincode::serde::encode_to_vec(other, cfg).expect("encode Other(42)");
533        let (decoded, n) = bincode::serde::decode_from_slice::<RejectReason, _>(&bytes, cfg)
534            .expect("decode Other(42)");
535        assert_eq!(decoded, other);
536        assert_eq!(n, bytes.len());
537    }
538}