Skip to main content

nautilus_model/events/order/
denied_reason.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Standardized reasons for local order denial.
17//!
18//! [`OrderDeniedReason`] is the single source of truth for the `CATEGORY_CONDITION` codes
19//! attached to [`OrderDenied`](super::denied::OrderDenied) events. Each variant renders, via
20//! [`std::fmt::Display`], to a message whose leading token is the stable code, followed when
21//! applicable by the diagnostic suffix documented on [`OrderDeniedReason`]. The companion
22//! [`OrderDeniedCode`] enum (generated by `strum`) enumerates the codes without their per-denial
23//! context, so documentation and grouping can iterate the closed set.
24
25use rust_decimal::Decimal;
26use strum::{AsRefStr, Display, EnumDiscriminants, EnumIter, EnumString};
27use thiserror::Error;
28
29use crate::{
30    enums::{OrderSide, OrderType, TimeInForce, TrailingOffsetType},
31    identifiers::{ClientId, InstrumentId, OrderListId, PositionId, Venue},
32    types::{Money, Price, Quantity},
33};
34
35/// The order price field being validated.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Display)]
37#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
38pub enum OrderPriceField {
39    /// The order's `price` field.
40    Price,
41    /// The order's `trigger_price` field.
42    TriggerPrice,
43}
44
45/// A standardized reason an order was denied locally by the Nautilus system.
46///
47/// A denial is a local rejection: the order never reached a venue. Each variant carries the
48/// context needed to render its message and maps to a stable [`OrderDeniedCode`].
49///
50/// Rendered messages use these forms:
51///
52/// - `CODE` when the denial needs no diagnostic suffix.
53/// - `CODE: value` for one typed value or an opaque diagnostic detail.
54/// - `CODE: key=value, key=value` when multiple typed values need disambiguation.
55/// - `CODE: value; free text` when one typed value precedes an opaque detail.
56///
57/// Only the leading code is canonical. Consumers must not recover classification or control flow
58/// from the diagnostic suffix.
59///
60/// Variants progress from direct field checks and required context through single-order,
61/// cumulative, policy, routing, and downstream validation failures. Declaration order does not
62/// define validation precedence.
63#[derive(Debug, Clone, PartialEq, Eq, Error, EnumDiscriminants)]
64#[strum_discriminants(
65    name(OrderDeniedCode),
66    derive(Display, AsRefStr, EnumIter, EnumString),
67    strum(serialize_all = "SCREAMING_SNAKE_CASE")
68)]
69pub enum OrderDeniedReason {
70    /// The price precision exceeds the instrument maximum.
71    #[error(
72        "PRICE_PRECISION_EXCEEDS_MAXIMUM: field={field}, price={price}, precision={price_precision}, max_precision={max_precision}"
73    )]
74    PricePrecisionExceedsMaximum {
75        /// The price field being validated.
76        field: OrderPriceField,
77        /// The submitted price.
78        price: Price,
79        /// The submitted price precision.
80        price_precision: u8,
81        /// The instrument's maximum price precision.
82        max_precision: u8,
83    },
84
85    /// The price is not positive for an instrument that disallows negative prices.
86    #[error("PRICE_NOT_POSITIVE: field={field}, price={price}")]
87    PriceNotPositive {
88        /// The price field being validated.
89        field: OrderPriceField,
90        /// The submitted price.
91        price: Price,
92    },
93
94    /// The quantity precision exceeds the instrument maximum.
95    #[error(
96        "QUANTITY_PRECISION_EXCEEDS_MAXIMUM: quantity={quantity}, precision={quantity_precision}, max_precision={max_precision}"
97    )]
98    QuantityPrecisionExceedsMaximum {
99        /// The submitted quantity.
100        quantity: Quantity,
101        /// The submitted quantity precision.
102        quantity_precision: u8,
103        /// The instrument's maximum quantity precision.
104        max_precision: u8,
105    },
106
107    /// The order quantity could not be converted for risk checks.
108    #[error("QUANTITY_CONVERSION_FAILED: {detail}")]
109    QuantityConversionFailed {
110        /// The underlying conversion error.
111        detail: String,
112    },
113
114    /// The effective order quantity exceeds the instrument maximum.
115    #[error("QUANTITY_EXCEEDS_MAXIMUM: effective={effective_quantity}, max={max_quantity}")]
116    QuantityExceedsMaximum {
117        /// The order quantity after any quote-to-base conversion.
118        effective_quantity: Quantity,
119        /// The instrument's maximum tradable quantity.
120        max_quantity: Quantity,
121    },
122
123    /// The effective order quantity is below the instrument minimum.
124    #[error("QUANTITY_BELOW_MINIMUM: effective={effective_quantity}, min={min_quantity}")]
125    QuantityBelowMinimum {
126        /// The order quantity after any quote-to-base conversion.
127        effective_quantity: Quantity,
128        /// The instrument's minimum tradable quantity.
129        min_quantity: Quantity,
130    },
131
132    /// The configured maximum notional per order is invalid.
133    #[error("INVALID_MAX_NOTIONAL_PER_ORDER: instrument_id={instrument_id}, value={value}")]
134    InvalidMaxNotionalPerOrder {
135        /// The instrument the setting applies to.
136        instrument_id: InstrumentId,
137        /// The invalid configured value.
138        value: Decimal,
139    },
140
141    /// A GTD order is missing its expire time.
142    #[error("MISSING_EXPIRE_TIME")]
143    MissingExpireTime,
144
145    /// The order's expire time is in the past.
146    #[error("EXPIRE_TIME_IN_PAST: {expire_time}")]
147    ExpireTimeInPast {
148        /// The expire time that has already elapsed.
149        expire_time: String,
150    },
151
152    /// The order is missing a required trailing offset type.
153    #[error("MISSING_TRAILING_OFFSET_TYPE")]
154    MissingTrailingOffsetType,
155
156    /// The order's trailing offset type is not supported.
157    #[error("UNSUPPORTED_TRAILING_OFFSET_TYPE: {offset_type}")]
158    UnsupportedTrailingOffsetType {
159        /// The unsupported trailing offset type.
160        offset_type: TrailingOffsetType,
161    },
162
163    /// The order is missing a required trigger type.
164    #[error("MISSING_TRIGGER_TYPE")]
165    MissingTriggerType,
166
167    /// The order is missing a required trailing offset.
168    #[error("MISSING_TRAILING_OFFSET")]
169    MissingTrailingOffset,
170
171    /// The instrument was not found in the cache.
172    #[error("INSTRUMENT_NOT_FOUND: {instrument_id}")]
173    InstrumentNotFound {
174        /// The instrument that was not found.
175        instrument_id: InstrumentId,
176    },
177
178    /// The position for a reduce-only order was not found.
179    #[error("POSITION_NOT_FOUND: {position_id}")]
180    PositionNotFound {
181        /// The position that was not found.
182        position_id: PositionId,
183    },
184
185    /// No market price is available for the order risk check.
186    #[error("MARKET_PRICE_UNAVAILABLE: order_type={order_type}, instrument_id={instrument_id}")]
187    MarketPriceUnavailable {
188        /// The order type requiring a market price.
189        order_type: OrderType,
190        /// The instrument with no available market price.
191        instrument_id: InstrumentId,
192    },
193
194    /// The trailing stop trigger price could not be calculated.
195    #[error("TRAILING_STOP_CALCULATION_FAILED: {detail}")]
196    TrailingStopCalculationFailed {
197        /// The underlying calculation error.
198        detail: String,
199    },
200
201    /// The order notional value could not be calculated.
202    #[error("NOTIONAL_CALCULATION_FAILED: {detail}")]
203    NotionalCalculationFailed {
204        /// The underlying calculation error.
205        detail: String,
206    },
207
208    /// The order notional is below the instrument minimum.
209    #[error("NOTIONAL_BELOW_MINIMUM: min={min_notional}, notional={notional}")]
210    NotionalBelowMinimum {
211        /// The instrument's minimum notional.
212        min_notional: Money,
213        /// The order's notional value.
214        notional: Money,
215    },
216
217    /// The order notional exceeds the instrument maximum.
218    #[error("NOTIONAL_EXCEEDS_MAXIMUM: max={max_notional}, notional={notional}")]
219    NotionalExceedsMaximum {
220        /// The instrument's maximum notional.
221        max_notional: Money,
222        /// The order's notional value.
223        notional: Money,
224    },
225
226    /// The order notional exceeds the configured maximum per order.
227    #[error("NOTIONAL_EXCEEDS_MAX_PER_ORDER: max={max_notional}, notional={notional}")]
228    NotionalExceedsMaxPerOrder {
229        /// The configured maximum notional per order.
230        max_notional: Money,
231        /// The order's notional value.
232        notional: Money,
233    },
234
235    /// The order notional exceeds the account free balance.
236    #[error("NOTIONAL_EXCEEDS_FREE_BALANCE: free={free_balance}, notional={notional}")]
237    NotionalExceedsFreeBalance {
238        /// The account's free balance.
239        free_balance: Money,
240        /// The order's notional value.
241        notional: Money,
242    },
243
244    /// The order initial margin could not be calculated.
245    #[error("INITIAL_MARGIN_CALCULATION_FAILED: {detail}")]
246    InitialMarginCalculationFailed {
247        /// The underlying calculation error.
248        detail: String,
249    },
250
251    /// The order initial margin exceeds the account free balance.
252    #[error("INITIAL_MARGIN_EXCEEDS_FREE_BALANCE: free={free_balance}, margin={initial_margin}")]
253    InitialMarginExceedsFreeBalance {
254        /// The account's free balance.
255        free_balance: Money,
256        /// The initial margin required for the order.
257        initial_margin: Money,
258    },
259
260    /// The balance to lock for the betting order could not be calculated.
261    #[error("BETTING_BALANCE_LOCKED_CALCULATION_FAILED: {detail}")]
262    BettingBalanceLockedCalculationFailed {
263        /// The underlying calculation error.
264        detail: String,
265    },
266
267    /// The cumulative order notional exceeds the account free balance.
268    #[error(
269        "CUMULATIVE_NOTIONAL_EXCEEDS_FREE_BALANCE: free={free_balance}, notional={cumulative_notional}"
270    )]
271    CumulativeNotionalExceedsFreeBalance {
272        /// The account's free balance.
273        free_balance: Money,
274        /// The cumulative notional across the checked orders.
275        cumulative_notional: Money,
276    },
277
278    /// The cumulative initial margin could not be calculated.
279    #[error("CUMULATIVE_INITIAL_MARGIN_CALCULATION_FAILED: {detail}")]
280    CumulativeInitialMarginCalculationFailed {
281        /// The underlying calculation error.
282        detail: String,
283    },
284
285    /// The cumulative initial margin exceeds the account free balance.
286    #[error(
287        "CUMULATIVE_INITIAL_MARGIN_EXCEEDS_FREE_BALANCE: free={free_balance}, margin={cumulative_initial_margin}"
288    )]
289    CumulativeInitialMarginExceedsFreeBalance {
290        /// The account's free balance.
291        free_balance: Money,
292        /// The cumulative initial margin across the checked orders.
293        cumulative_initial_margin: Money,
294    },
295
296    /// A reduce-only order would increase the position.
297    #[error("REDUCE_ONLY_WOULD_INCREASE_POSITION: {position_id}")]
298    ReduceOnlyWouldIncreasePosition {
299        /// The position the order would increase.
300        position_id: PositionId,
301    },
302
303    /// The order list is missing orders in the cache.
304    #[error("ORDER_LIST_INCOMPLETE: {order_list_id}")]
305    OrderListIncomplete {
306        /// The order list with missing orders.
307        order_list_id: OrderListId,
308    },
309
310    /// The order was denied because its order list failed risk checks.
311    #[error("ORDER_LIST_DENIED: {order_list_id}")]
312    OrderListDenied {
313        /// The order list that failed risk checks.
314        order_list_id: OrderListId,
315    },
316
317    /// Trading is halted; new submissions and modifications are denied.
318    #[error("TRADING_HALTED")]
319    TradingHalted,
320
321    /// Trading is reducing; only eligible reduce-only submissions are permitted.
322    #[error("TRADING_STATE_REDUCING: side={order_side}, instrument_id={instrument_id}")]
323    TradingStateReducing {
324        /// The side of the denied order.
325        order_side: OrderSide,
326        /// The instrument the order applies to.
327        instrument_id: InstrumentId,
328    },
329
330    /// The order submission rate limit was exceeded.
331    #[error("RATE_LIMIT_EXCEEDED")]
332    RateLimitExceeded,
333
334    /// The execution stream is unavailable or recovering; retry after recovery.
335    #[error("STREAM_RECONCILING: execution stream unavailable or recovering, retry after recovery")]
336    StreamReconciling,
337
338    /// No execution client was found for the routed command.
339    #[error(
340        "NO_EXECUTION_CLIENT: client_id={client}, {routing_context}",
341        client = .client_id.as_ref().map_or("NONE", ClientId::as_str),
342    )]
343    NoExecutionClient {
344        /// The explicitly requested client, if one was supplied.
345        client_id: Option<ClientId>,
346        /// The routing context used to look up an execution client.
347        routing_context: String,
348    },
349
350    /// The execution client does not handle the order venue.
351    #[error(
352        "CLIENT_VENUE_MISMATCH: client_id={client_id}, order_venue={order_venue}, client_venue={client_venue}"
353    )]
354    ClientVenueMismatch {
355        /// The routed execution client.
356        client_id: ClientId,
357        /// The order venue.
358        order_venue: Venue,
359        /// The execution client's venue.
360        client_venue: Venue,
361    },
362
363    /// Submitting the order to the execution client failed.
364    #[error("SUBMIT_FAILED: {detail}")]
365    SubmitFailed {
366        /// The underlying submission error.
367        detail: String,
368    },
369
370    /// The client order ID is invalid for the venue.
371    #[error("INVALID_CLIENT_ORDER_ID: {detail}")]
372    InvalidClientOrderId {
373        /// The validation failure detail.
374        detail: String,
375    },
376
377    /// The supplied position ID is invalid for the order submission.
378    #[error("INVALID_POSITION_ID: {position_id}; {detail}")]
379    InvalidPositionId {
380        /// The invalid position ID.
381        position_id: PositionId,
382        /// The validation failure detail.
383        detail: String,
384    },
385
386    /// The venue does not support the requested order list.
387    #[error("UNSUPPORTED_ORDER_LIST: {detail}")]
388    UnsupportedOrderList {
389        /// The reason the order list is unsupported.
390        detail: String,
391    },
392
393    /// The order type is not supported.
394    #[error("UNSUPPORTED_ORDER_TYPE: {order_type}")]
395    UnsupportedOrderType {
396        /// The unsupported order type.
397        order_type: OrderType,
398    },
399
400    /// The execution client or venue does not support quote quantity for the order.
401    #[error(
402        "UNSUPPORTED_QUOTE_QUANTITY{}{}",
403        if .detail.as_deref().unwrap_or_default().is_empty() { "" } else { ": " },
404        .detail.as_deref().unwrap_or_default()
405    )]
406    UnsupportedQuoteQuantity {
407        /// Optional venue-specific diagnostic detail.
408        detail: Option<String>,
409    },
410
411    /// The execution client or venue does not support the requested post-only instruction.
412    #[error(
413        "UNSUPPORTED_POST_ONLY{}{}",
414        if .detail.as_deref().unwrap_or_default().is_empty() { "" } else { ": " },
415        .detail.as_deref().unwrap_or_default()
416    )]
417    UnsupportedPostOnly {
418        /// Optional venue-specific diagnostic detail.
419        detail: Option<String>,
420    },
421
422    /// The execution client or venue does not support the requested reduce-only instruction.
423    #[error("UNSUPPORTED_REDUCE_ONLY")]
424    UnsupportedReduceOnly,
425
426    /// The order's time in force is not supported.
427    #[error("UNSUPPORTED_TIME_IN_FORCE: {0}")]
428    UnsupportedTimeInForce(TimeInForce),
429
430    /// The venue does not support the requested take-profit/stop-loss parameters.
431    #[error("UNSUPPORTED_TP_SL: {detail}")]
432    UnsupportedTpSl {
433        /// The reason the take-profit/stop-loss parameters are unsupported.
434        detail: String,
435    },
436
437    /// The order failed validation before submission.
438    #[error("VALIDATION_FAILED: {detail}")]
439    ValidationFailed {
440        /// The validation failure detail.
441        detail: String,
442    },
443}
444
445impl OrderDeniedReason {
446    /// Creates an unsupported quote-quantity denial without diagnostic detail.
447    #[must_use]
448    pub const fn unsupported_quote_quantity() -> Self {
449        Self::UnsupportedQuoteQuantity { detail: None }
450    }
451
452    /// Creates an unsupported quote-quantity denial with diagnostic detail.
453    #[must_use]
454    pub fn unsupported_quote_quantity_with_detail(detail: impl Into<String>) -> Self {
455        Self::UnsupportedQuoteQuantity {
456            detail: Some(detail.into()),
457        }
458    }
459
460    /// Creates an unsupported post-only denial without diagnostic detail.
461    #[must_use]
462    pub const fn unsupported_post_only() -> Self {
463        Self::UnsupportedPostOnly { detail: None }
464    }
465
466    /// Creates an unsupported post-only denial with diagnostic detail.
467    #[must_use]
468    pub fn unsupported_post_only_with_detail(detail: impl Into<String>) -> Self {
469        Self::UnsupportedPostOnly {
470            detail: Some(detail.into()),
471        }
472    }
473}
474
475impl OrderDeniedCode {
476    /// Returns a one-line description of this denial code.
477    #[must_use]
478    #[rustfmt::skip]
479    pub fn description(&self) -> &'static str {
480        match self {
481            Self::PricePrecisionExceedsMaximum => "The price precision exceeds the instrument maximum.",
482            Self::PriceNotPositive => "The price is not positive.",
483            Self::QuantityPrecisionExceedsMaximum => "The quantity precision exceeds the instrument maximum.",
484            Self::QuantityConversionFailed => "The order quantity could not be converted for risk checks.",
485            Self::QuantityExceedsMaximum => "The effective order quantity exceeds the instrument maximum.",
486            Self::QuantityBelowMinimum => "The effective order quantity is below the instrument minimum.",
487            Self::InvalidMaxNotionalPerOrder => "The configured maximum notional per order is invalid.",
488            Self::MissingExpireTime => "A GTD order is missing its expire time.",
489            Self::ExpireTimeInPast => "The order's expire time is in the past.",
490            Self::MissingTrailingOffsetType => "The order is missing a required trailing offset type.",
491            Self::UnsupportedTrailingOffsetType => "The order's trailing offset type is not supported.",
492            Self::MissingTriggerType => "The order is missing a required trigger type.",
493            Self::MissingTrailingOffset => "The order is missing a required trailing offset.",
494            Self::InstrumentNotFound => "The instrument was not found in the cache.",
495            Self::PositionNotFound => "The position for a reduce-only order was not found.",
496            Self::MarketPriceUnavailable => "No market price is available for the order risk check.",
497            Self::TrailingStopCalculationFailed => "The trailing stop trigger price could not be calculated.",
498            Self::NotionalCalculationFailed => "The order notional value could not be calculated.",
499            Self::NotionalBelowMinimum => "The order notional is below the instrument minimum.",
500            Self::NotionalExceedsMaximum => "The order notional exceeds the instrument maximum.",
501            Self::NotionalExceedsMaxPerOrder => "The order notional exceeds the configured maximum per order.",
502            Self::NotionalExceedsFreeBalance => "The order notional exceeds the account free balance.",
503            Self::InitialMarginCalculationFailed => "The order initial margin could not be calculated.",
504            Self::InitialMarginExceedsFreeBalance => "The order initial margin exceeds the account free balance.",
505            Self::BettingBalanceLockedCalculationFailed => "The balance to lock for the betting order could not be calculated.",
506            Self::CumulativeNotionalExceedsFreeBalance => "The cumulative order notional exceeds the account free balance.",
507            Self::CumulativeInitialMarginCalculationFailed => "The cumulative initial margin could not be calculated.",
508            Self::CumulativeInitialMarginExceedsFreeBalance => "The cumulative initial margin exceeds the account free balance.",
509            Self::ReduceOnlyWouldIncreasePosition => "A reduce-only order would increase the position.",
510            Self::OrderListIncomplete => "The order list is missing orders in the cache.",
511            Self::OrderListDenied => "The order was denied because its order list failed risk checks.",
512            Self::TradingHalted => "Trading is halted; new submissions and modifications are denied.",
513            Self::TradingStateReducing => "Trading is reducing; only eligible reduce-only submissions are permitted.",
514            Self::RateLimitExceeded => "The order submission rate limit was exceeded.",
515            Self::StreamReconciling => "The execution stream is unavailable or recovering; retry after recovery.",
516            Self::NoExecutionClient => "No execution client was found for the routed command.",
517            Self::ClientVenueMismatch => "The execution client does not handle the order venue.",
518            Self::SubmitFailed => "Submitting the order to the execution client failed.",
519            Self::InvalidClientOrderId => "The client order ID is invalid for the venue.",
520            Self::InvalidPositionId => "The supplied position ID is invalid for the order submission.",
521            Self::UnsupportedOrderList => "The venue does not support the requested order list.",
522            Self::UnsupportedOrderType => "The order type is not supported.",
523            Self::UnsupportedQuoteQuantity => "The execution client or venue does not support quote quantity for the order.",
524            Self::UnsupportedPostOnly => "The execution client or venue does not support the requested post-only instruction.",
525            Self::UnsupportedReduceOnly => "The execution client or venue does not support the requested reduce-only instruction.",
526            Self::UnsupportedTimeInForce => "The order's time in force is not supported.",
527            Self::UnsupportedTpSl => "The venue does not support the requested take-profit/stop-loss parameters.",
528            Self::ValidationFailed => "The order failed validation before submission.",
529        }
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use rstest::rstest;
536    use strum::IntoEnumIterator;
537
538    use super::*;
539
540    const DOC_PATH: &str = concat!(
541        env!("CARGO_MANIFEST_DIR"),
542        "/../../docs/concepts/execution/index.md"
543    );
544    const BLOCK_BEGIN: &str = "<!-- BEGIN GENERATED: order-denied-reasons -->";
545    const BLOCK_END: &str = "<!-- END GENERATED: order-denied-reasons -->";
546
547    #[rstest]
548    fn renders_subject_led_messages() {
549        let exceeds = OrderDeniedReason::QuantityExceedsMaximum {
550            effective_quantity: Quantity::from("15"),
551            max_quantity: Quantity::from("10"),
552        };
553        let below = OrderDeniedReason::QuantityBelowMinimum {
554            effective_quantity: Quantity::from("1"),
555            min_quantity: Quantity::from("5"),
556        };
557        let notional = OrderDeniedReason::NotionalBelowMinimum {
558            min_notional: Money::from("1.00 USD"),
559            notional: Money::from("0.90 USD"),
560        };
561
562        assert_eq!(
563            exceeds.to_string(),
564            "QUANTITY_EXCEEDS_MAXIMUM: effective=15, max=10"
565        );
566        assert_eq!(
567            below.to_string(),
568            "QUANTITY_BELOW_MINIMUM: effective=1, min=5"
569        );
570        assert_eq!(
571            notional.to_string(),
572            "NOTIONAL_BELOW_MINIMUM: min=1.00 USD, notional=0.90 USD"
573        );
574    }
575
576    #[rstest]
577    fn renders_standardized_risk_messages() {
578        assert_eq!(
579            OrderDeniedReason::PricePrecisionExceedsMaximum {
580                field: OrderPriceField::Price,
581                price: Price::from("1.234"),
582                price_precision: 3,
583                max_precision: 2,
584            }
585            .to_string(),
586            "PRICE_PRECISION_EXCEEDS_MAXIMUM: field=PRICE, price=1.234, precision=3, max_precision=2"
587        );
588        assert_eq!(
589            OrderDeniedReason::PriceNotPositive {
590                field: OrderPriceField::TriggerPrice,
591                price: Price::from("-0.1"),
592            }
593            .to_string(),
594            "PRICE_NOT_POSITIVE: field=TRIGGER_PRICE, price=-0.1"
595        );
596        assert_eq!(
597            OrderDeniedReason::QuantityConversionFailed {
598                detail: "value exceeds MoneyRaw bounds".to_string(),
599            }
600            .to_string(),
601            "QUANTITY_CONVERSION_FAILED: value exceeds MoneyRaw bounds"
602        );
603        assert_eq!(
604            OrderDeniedReason::UnsupportedTrailingOffsetType {
605                offset_type: TrailingOffsetType::PriceTier,
606            }
607            .to_string(),
608            "UNSUPPORTED_TRAILING_OFFSET_TYPE: PRICE_TIER"
609        );
610        assert_eq!(
611            OrderDeniedReason::NotionalCalculationFailed {
612                detail: "value exceeds Money bounds".to_string(),
613            }
614            .to_string(),
615            "NOTIONAL_CALCULATION_FAILED: value exceeds Money bounds"
616        );
617        assert_eq!(
618            OrderDeniedReason::InitialMarginCalculationFailed {
619                detail: "margin model unavailable".to_string(),
620            }
621            .to_string(),
622            "INITIAL_MARGIN_CALCULATION_FAILED: margin model unavailable"
623        );
624        assert_eq!(
625            OrderDeniedReason::NotionalExceedsMaxPerOrder {
626                max_notional: Money::from("10.00 USD"),
627                notional: Money::from("11.00 USD"),
628            }
629            .to_string(),
630            "NOTIONAL_EXCEEDS_MAX_PER_ORDER: max=10.00 USD, notional=11.00 USD"
631        );
632        assert_eq!(
633            OrderDeniedReason::NotionalExceedsMaximum {
634                max_notional: Money::from("12.00 USD"),
635                notional: Money::from("13.00 USD"),
636            }
637            .to_string(),
638            "NOTIONAL_EXCEEDS_MAXIMUM: max=12.00 USD, notional=13.00 USD"
639        );
640        assert_eq!(
641            OrderDeniedReason::NotionalExceedsFreeBalance {
642                free_balance: Money::from("10.00 USD"),
643                notional: Money::from("11.00 USD"),
644            }
645            .to_string(),
646            "NOTIONAL_EXCEEDS_FREE_BALANCE: free=10.00 USD, notional=11.00 USD"
647        );
648        assert_eq!(
649            OrderDeniedReason::CumulativeNotionalExceedsFreeBalance {
650                free_balance: Money::from("10.00 USD"),
651                cumulative_notional: Money::from("12.00 USD"),
652            }
653            .to_string(),
654            "CUMULATIVE_NOTIONAL_EXCEEDS_FREE_BALANCE: free=10.00 USD, notional=12.00 USD"
655        );
656        assert_eq!(
657            OrderDeniedReason::InitialMarginExceedsFreeBalance {
658                free_balance: Money::from("10.00 USD"),
659                initial_margin: Money::from("13.00 USD"),
660            }
661            .to_string(),
662            "INITIAL_MARGIN_EXCEEDS_FREE_BALANCE: free=10.00 USD, margin=13.00 USD"
663        );
664        assert_eq!(
665            OrderDeniedReason::CumulativeInitialMarginExceedsFreeBalance {
666                free_balance: Money::from("10.00 USD"),
667                cumulative_initial_margin: Money::from("14.00 USD"),
668            }
669            .to_string(),
670            "CUMULATIVE_INITIAL_MARGIN_EXCEEDS_FREE_BALANCE: free=10.00 USD, margin=14.00 USD"
671        );
672        assert_eq!(
673            OrderDeniedReason::CumulativeInitialMarginCalculationFailed {
674                detail: "total exceeds Money bounds".to_string(),
675            }
676            .to_string(),
677            "CUMULATIVE_INITIAL_MARGIN_CALCULATION_FAILED: total exceeds Money bounds"
678        );
679        assert_eq!(
680            OrderDeniedReason::BettingBalanceLockedCalculationFailed {
681                detail: "invalid liability".to_string(),
682            }
683            .to_string(),
684            "BETTING_BALANCE_LOCKED_CALCULATION_FAILED: invalid liability"
685        );
686        assert_eq!(
687            OrderDeniedReason::TrailingStopCalculationFailed {
688                detail: "missing market price".to_string(),
689            }
690            .to_string(),
691            "TRAILING_STOP_CALCULATION_FAILED: missing market price"
692        );
693    }
694
695    #[rstest]
696    fn renders_lifecycle_and_state_messages() {
697        let not_found = OrderDeniedReason::InstrumentNotFound {
698            instrument_id: InstrumentId::from("AUD/USD.SIM"),
699        };
700        let reducing = OrderDeniedReason::TradingStateReducing {
701            order_side: OrderSide::Buy,
702            instrument_id: InstrumentId::from("AUD/USD.SIM"),
703        };
704
705        assert_eq!(not_found.to_string(), "INSTRUMENT_NOT_FOUND: AUD/USD.SIM");
706        assert_eq!(
707            OrderDeniedReason::ExpireTimeInPast {
708                expire_time: "1970-01-01T00:00:00Z".to_string(),
709            }
710            .to_string(),
711            "EXPIRE_TIME_IN_PAST: 1970-01-01T00:00:00Z"
712        );
713        assert_eq!(
714            OrderDeniedReason::PositionNotFound {
715                position_id: PositionId::from("P-1"),
716            }
717            .to_string(),
718            "POSITION_NOT_FOUND: P-1"
719        );
720        assert_eq!(
721            OrderDeniedReason::ReduceOnlyWouldIncreasePosition {
722                position_id: PositionId::from("P-2"),
723            }
724            .to_string(),
725            "REDUCE_ONLY_WOULD_INCREASE_POSITION: P-2"
726        );
727        assert_eq!(
728            OrderDeniedReason::OrderListIncomplete {
729                order_list_id: OrderListId::from("OL-1"),
730            }
731            .to_string(),
732            "ORDER_LIST_INCOMPLETE: OL-1"
733        );
734        assert_eq!(
735            OrderDeniedReason::OrderListDenied {
736                order_list_id: OrderListId::from("OL-2"),
737            }
738            .to_string(),
739            "ORDER_LIST_DENIED: OL-2"
740        );
741        assert_eq!(
742            OrderDeniedReason::TradingHalted.to_string(),
743            "TRADING_HALTED"
744        );
745        assert_eq!(
746            OrderDeniedReason::RateLimitExceeded.to_string(),
747            "RATE_LIMIT_EXCEEDED"
748        );
749        assert_eq!(
750            reducing.to_string(),
751            "TRADING_STATE_REDUCING: side=BUY, instrument_id=AUD/USD.SIM"
752        );
753    }
754
755    #[rstest]
756    fn renders_routing_messages() {
757        let missing_client = OrderDeniedReason::NoExecutionClient {
758            client_id: Some(ClientId::from("SIM")),
759            routing_context: "venue=SIM".to_string(),
760        };
761        let mismatch = OrderDeniedReason::ClientVenueMismatch {
762            client_id: ClientId::from("IB"),
763            order_venue: Venue::from("XCME"),
764            client_venue: Venue::from("IB"),
765        };
766        let submit_failed = OrderDeniedReason::SubmitFailed {
767            detail: "transport closed".to_string(),
768        };
769        let invalid_position_id = OrderDeniedReason::InvalidPositionId {
770            position_id: PositionId::from("P-1"),
771            detail: "not valid for NETTING OMS".to_string(),
772        };
773
774        assert_eq!(
775            missing_client.to_string(),
776            "NO_EXECUTION_CLIENT: client_id=SIM, venue=SIM"
777        );
778        assert_eq!(
779            mismatch.to_string(),
780            "CLIENT_VENUE_MISMATCH: client_id=IB, order_venue=XCME, client_venue=IB"
781        );
782        assert_eq!(submit_failed.to_string(), "SUBMIT_FAILED: transport closed");
783        assert_eq!(
784            invalid_position_id.to_string(),
785            "INVALID_POSITION_ID: P-1; not valid for NETTING OMS"
786        );
787    }
788
789    #[rstest]
790    fn renders_condition_led_message() {
791        let reason = OrderDeniedReason::UnsupportedTimeInForce(TimeInForce::Gtd);
792        assert_eq!(reason.to_string(), "UNSUPPORTED_TIME_IN_FORCE: GTD");
793    }
794
795    #[rstest]
796    #[case(
797        OrderDeniedReason::unsupported_quote_quantity(),
798        "UNSUPPORTED_QUOTE_QUANTITY",
799        "UNSUPPORTED_QUOTE_QUANTITY"
800    )]
801    #[case(
802        OrderDeniedReason::unsupported_quote_quantity_with_detail(String::new()),
803        "UNSUPPORTED_QUOTE_QUANTITY",
804        "UNSUPPORTED_QUOTE_QUANTITY"
805    )]
806    #[case(
807        OrderDeniedReason::unsupported_quote_quantity_with_detail(
808            "IB error 10244; use base quantity"
809        ),
810        "UNSUPPORTED_QUOTE_QUANTITY",
811        "UNSUPPORTED_QUOTE_QUANTITY: IB error 10244; use base quantity"
812    )]
813    #[case(
814        OrderDeniedReason::unsupported_post_only(),
815        "UNSUPPORTED_POST_ONLY",
816        "UNSUPPORTED_POST_ONLY"
817    )]
818    #[case(
819        OrderDeniedReason::unsupported_post_only_with_detail(String::new()),
820        "UNSUPPORTED_POST_ONLY",
821        "UNSUPPORTED_POST_ONLY"
822    )]
823    #[case(
824        OrderDeniedReason::unsupported_post_only_with_detail("IOC orders cannot be post-only"),
825        "UNSUPPORTED_POST_ONLY",
826        "UNSUPPORTED_POST_ONLY: IOC orders cannot be post-only"
827    )]
828    fn renders_unsupported_instruction(
829        #[case] reason: OrderDeniedReason,
830        #[case] expected_code: &str,
831        #[case] expected_message: &str,
832    ) {
833        let code = OrderDeniedCode::from(&reason);
834
835        assert_eq!(reason.to_string(), expected_message);
836        assert_eq!(code.to_string(), expected_code);
837        assert_eq!(expected_code.parse::<OrderDeniedCode>(), Ok(code));
838    }
839
840    #[rstest]
841    fn renders_adapter_messages() {
842        let invalid_client_order_id = OrderDeniedReason::InvalidClientOrderId {
843            detail: "clOrdId must be alphanumeric".to_string(),
844        };
845        let unsupported_order_list = OrderDeniedReason::UnsupportedOrderList {
846            detail: "spread instruments are not supported in order lists".to_string(),
847        };
848        let unsupported_order_type = OrderDeniedReason::UnsupportedOrderType {
849            order_type: OrderType::TrailingStopMarket,
850        };
851        let unsupported_reduce_only = OrderDeniedReason::UnsupportedReduceOnly;
852        let unsupported_tp_sl = OrderDeniedReason::UnsupportedTpSl {
853            detail: "TP/SL trigger prices are not supported in demo mode".to_string(),
854        };
855        let validation_failed = OrderDeniedReason::ValidationFailed {
856            detail: "`bbo_side_type` and `bbo_level` are only supported for linear products"
857                .to_string(),
858        };
859
860        assert_eq!(
861            invalid_client_order_id.to_string(),
862            "INVALID_CLIENT_ORDER_ID: clOrdId must be alphanumeric"
863        );
864        assert_eq!(
865            unsupported_order_list.to_string(),
866            "UNSUPPORTED_ORDER_LIST: spread instruments are not supported in order lists"
867        );
868        assert_eq!(
869            unsupported_order_type.to_string(),
870            "UNSUPPORTED_ORDER_TYPE: TRAILING_STOP_MARKET"
871        );
872        assert_eq!(
873            unsupported_reduce_only.to_string(),
874            "UNSUPPORTED_REDUCE_ONLY"
875        );
876        assert_eq!(
877            unsupported_tp_sl.to_string(),
878            "UNSUPPORTED_TP_SL: TP/SL trigger prices are not supported in demo mode"
879        );
880        assert_eq!(
881            validation_failed.to_string(),
882            "VALIDATION_FAILED: `bbo_side_type` and `bbo_level` are only supported for linear products"
883        );
884        assert_eq!(
885            OrderDeniedReason::StreamReconciling.to_string(),
886            "STREAM_RECONCILING: execution stream unavailable or recovering, retry after recovery"
887        );
888    }
889
890    // Drift pin: each variant's rendered message must start with its discriminant code, keeping
891    // the hand-written `#[error]` prefix in sync with the strum-derived `OrderDeniedCode`.
892    #[rstest]
893    fn message_prefix_matches_code() {
894        let usd = || Money::from("100.00 USD");
895        let samples = [
896            OrderDeniedReason::PricePrecisionExceedsMaximum {
897                field: OrderPriceField::Price,
898                price: Price::from("1.00"),
899                price_precision: 2,
900                max_precision: 1,
901            },
902            OrderDeniedReason::PriceNotPositive {
903                field: OrderPriceField::TriggerPrice,
904                price: Price::from("0.00"),
905            },
906            OrderDeniedReason::QuantityPrecisionExceedsMaximum {
907                quantity: Quantity::from("1.00"),
908                quantity_precision: 2,
909                max_precision: 1,
910            },
911            OrderDeniedReason::QuantityConversionFailed {
912                detail: "boom".to_string(),
913            },
914            OrderDeniedReason::QuantityExceedsMaximum {
915                effective_quantity: Quantity::from("15"),
916                max_quantity: Quantity::from("10"),
917            },
918            OrderDeniedReason::QuantityBelowMinimum {
919                effective_quantity: Quantity::from("1"),
920                min_quantity: Quantity::from("5"),
921            },
922            OrderDeniedReason::InvalidMaxNotionalPerOrder {
923                instrument_id: InstrumentId::from("AUD/USD.SIM"),
924                value: Decimal::ONE,
925            },
926            OrderDeniedReason::MissingExpireTime,
927            OrderDeniedReason::ExpireTimeInPast {
928                expire_time: "1970-01-01T00:00:00Z".to_string(),
929            },
930            OrderDeniedReason::MissingTrailingOffsetType,
931            OrderDeniedReason::UnsupportedTrailingOffsetType {
932                offset_type: TrailingOffsetType::Price,
933            },
934            OrderDeniedReason::MissingTriggerType,
935            OrderDeniedReason::MissingTrailingOffset,
936            OrderDeniedReason::InstrumentNotFound {
937                instrument_id: InstrumentId::from("AUD/USD.SIM"),
938            },
939            OrderDeniedReason::PositionNotFound {
940                position_id: PositionId::from("P-1"),
941            },
942            OrderDeniedReason::MarketPriceUnavailable {
943                order_type: OrderType::Market,
944                instrument_id: InstrumentId::from("AUD/USD.SIM"),
945            },
946            OrderDeniedReason::TrailingStopCalculationFailed {
947                detail: "boom".to_string(),
948            },
949            OrderDeniedReason::NotionalCalculationFailed {
950                detail: "boom".to_string(),
951            },
952            OrderDeniedReason::NotionalBelowMinimum {
953                min_notional: usd(),
954                notional: usd(),
955            },
956            OrderDeniedReason::NotionalExceedsMaximum {
957                max_notional: usd(),
958                notional: usd(),
959            },
960            OrderDeniedReason::NotionalExceedsMaxPerOrder {
961                max_notional: usd(),
962                notional: usd(),
963            },
964            OrderDeniedReason::NotionalExceedsFreeBalance {
965                free_balance: usd(),
966                notional: usd(),
967            },
968            OrderDeniedReason::InitialMarginCalculationFailed {
969                detail: "boom".to_string(),
970            },
971            OrderDeniedReason::InitialMarginExceedsFreeBalance {
972                free_balance: usd(),
973                initial_margin: usd(),
974            },
975            OrderDeniedReason::BettingBalanceLockedCalculationFailed {
976                detail: "boom".to_string(),
977            },
978            OrderDeniedReason::CumulativeNotionalExceedsFreeBalance {
979                free_balance: usd(),
980                cumulative_notional: usd(),
981            },
982            OrderDeniedReason::CumulativeInitialMarginCalculationFailed {
983                detail: "boom".to_string(),
984            },
985            OrderDeniedReason::CumulativeInitialMarginExceedsFreeBalance {
986                free_balance: usd(),
987                cumulative_initial_margin: usd(),
988            },
989            OrderDeniedReason::ReduceOnlyWouldIncreasePosition {
990                position_id: PositionId::from("P-1"),
991            },
992            OrderDeniedReason::OrderListIncomplete {
993                order_list_id: OrderListId::from("OL-1"),
994            },
995            OrderDeniedReason::OrderListDenied {
996                order_list_id: OrderListId::from("OL-1"),
997            },
998            OrderDeniedReason::TradingHalted,
999            OrderDeniedReason::TradingStateReducing {
1000                order_side: OrderSide::Buy,
1001                instrument_id: InstrumentId::from("AUD/USD.SIM"),
1002            },
1003            OrderDeniedReason::RateLimitExceeded,
1004            OrderDeniedReason::StreamReconciling,
1005            OrderDeniedReason::NoExecutionClient {
1006                client_id: Some(ClientId::from("SIM")),
1007                routing_context: "venue=SIM".to_string(),
1008            },
1009            OrderDeniedReason::ClientVenueMismatch {
1010                client_id: ClientId::from("IB"),
1011                order_venue: Venue::from("XCME"),
1012                client_venue: Venue::from("IB"),
1013            },
1014            OrderDeniedReason::SubmitFailed {
1015                detail: "boom".to_string(),
1016            },
1017            OrderDeniedReason::InvalidClientOrderId {
1018                detail: "boom".to_string(),
1019            },
1020            OrderDeniedReason::InvalidPositionId {
1021                position_id: PositionId::from("P-1"),
1022                detail: "boom".to_string(),
1023            },
1024            OrderDeniedReason::UnsupportedOrderList {
1025                detail: "boom".to_string(),
1026            },
1027            OrderDeniedReason::UnsupportedOrderType {
1028                order_type: OrderType::TrailingStopMarket,
1029            },
1030            OrderDeniedReason::unsupported_quote_quantity(),
1031            OrderDeniedReason::unsupported_post_only(),
1032            OrderDeniedReason::UnsupportedReduceOnly,
1033            OrderDeniedReason::UnsupportedTimeInForce(TimeInForce::Gtd),
1034            OrderDeniedReason::UnsupportedTpSl {
1035                detail: "boom".to_string(),
1036            },
1037            OrderDeniedReason::ValidationFailed {
1038                detail: "boom".to_string(),
1039            },
1040        ];
1041
1042        assert_eq!(samples.len(), OrderDeniedCode::iter().count());
1043        for reason in samples {
1044            let code = OrderDeniedCode::from(&reason).to_string();
1045            assert!(
1046                reason.to_string().starts_with(&code),
1047                "message `{reason}` must start with code `{code}`"
1048            );
1049        }
1050    }
1051
1052    #[rstest]
1053    fn generated_table_is_in_sync() {
1054        let committed = std::fs::read_to_string(DOC_PATH).expect("execution/index.md should exist");
1055        assert!(
1056            committed.contains(&generated_block()),
1057            "the order-denied-reasons table in docs/concepts/execution/index.md is stale; regenerate \
1058             with `cargo test -p nautilus-model regenerate_order_denied_reasons_doc -- --ignored`"
1059        );
1060    }
1061
1062    #[rstest]
1063    #[ignore = "rewrites the generated table in execution/index.md; run after changing OrderDeniedReason variants"]
1064    fn regenerate_order_denied_reasons_doc() {
1065        let doc = std::fs::read_to_string(DOC_PATH).expect("execution/index.md should exist");
1066        let start = doc.find(BLOCK_BEGIN).expect("begin marker present");
1067        let end = doc.find(BLOCK_END).expect("end marker present") + BLOCK_END.len();
1068        let updated = format!("{}{}{}", &doc[..start], generated_block(), &doc[end..]);
1069        std::fs::write(DOC_PATH, updated).expect("should write execution/index.md");
1070    }
1071
1072    fn generated_block() -> String {
1073        format!("{BLOCK_BEGIN}\n\n{}\n\n{BLOCK_END}", markdown_table())
1074    }
1075
1076    fn markdown_table() -> String {
1077        const CODE_HEADER: &str = "Code";
1078        const DESC_HEADER: &str = "Description";
1079
1080        // Declaration order carries the public table's abstraction hierarchy
1081        let rows: Vec<(String, &'static str)> = OrderDeniedCode::iter()
1082            .map(|code| (format!("`{code}`"), code.description()))
1083            .collect();
1084        // Width counts characters, matching the padding applied by `format!` and the
1085        // column width the Markdown table hook normalizes to.
1086        let code_w = rows
1087            .iter()
1088            .map(|(code, _)| code.chars().count())
1089            .max()
1090            .unwrap_or(0)
1091            .max(CODE_HEADER.chars().count());
1092        let desc_w = rows
1093            .iter()
1094            .map(|(_, desc)| desc.chars().count())
1095            .max()
1096            .unwrap_or(0)
1097            .max(DESC_HEADER.chars().count());
1098
1099        let mut lines = vec![
1100            format!("| {CODE_HEADER:<code_w$} | {DESC_HEADER:<desc_w$} |"),
1101            format!("| {:-<code_w$} | {:-<desc_w$} |", "", ""),
1102        ];
1103
1104        for (code, desc) in rows {
1105            lines.push(format!("| {code:<code_w$} | {desc:<desc_w$} |"));
1106        }
1107        lines.join("\n")
1108    }
1109}