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    /// The price is not positive for an instrument that disallows negative prices.
85    #[error("PRICE_NOT_POSITIVE: field={field}, price={price}")]
86    PriceNotPositive {
87        /// The price field being validated.
88        field: OrderPriceField,
89        /// The submitted price.
90        price: Price,
91    },
92    /// The quantity precision exceeds the instrument maximum.
93    #[error(
94        "QUANTITY_PRECISION_EXCEEDS_MAXIMUM: quantity={quantity}, precision={quantity_precision}, max_precision={max_precision}"
95    )]
96    QuantityPrecisionExceedsMaximum {
97        /// The submitted quantity.
98        quantity: Quantity,
99        /// The submitted quantity precision.
100        quantity_precision: u8,
101        /// The instrument's maximum quantity precision.
102        max_precision: u8,
103    },
104    /// The order quantity could not be converted for risk checks.
105    #[error("QUANTITY_CONVERSION_FAILED: {detail}")]
106    QuantityConversionFailed {
107        /// The underlying conversion error.
108        detail: String,
109    },
110    /// The effective order quantity exceeds the instrument maximum.
111    #[error("QUANTITY_EXCEEDS_MAXIMUM: effective={effective_quantity}, max={max_quantity}")]
112    QuantityExceedsMaximum {
113        /// The order quantity after any quote-to-base conversion.
114        effective_quantity: Quantity,
115        /// The instrument's maximum tradable quantity.
116        max_quantity: Quantity,
117    },
118    /// The effective order quantity is below the instrument minimum.
119    #[error("QUANTITY_BELOW_MINIMUM: effective={effective_quantity}, min={min_quantity}")]
120    QuantityBelowMinimum {
121        /// The order quantity after any quote-to-base conversion.
122        effective_quantity: Quantity,
123        /// The instrument's minimum tradable quantity.
124        min_quantity: Quantity,
125    },
126
127    /// The configured maximum notional per order is invalid.
128    #[error("INVALID_MAX_NOTIONAL_PER_ORDER: instrument_id={instrument_id}, value={value}")]
129    InvalidMaxNotionalPerOrder {
130        /// The instrument the setting applies to.
131        instrument_id: InstrumentId,
132        /// The invalid configured value.
133        value: Decimal,
134    },
135    /// The order side is invalid for this operation.
136    #[error(
137        "INVALID_ORDER_SIDE: {}",
138        order_side.as_ref().map_or("NO_ORDER_SIDE", AsRef::as_ref)
139    )]
140    InvalidOrderSide {
141        /// The offending order side.
142        order_side: Option<OrderSide>,
143    },
144    /// A GTD order is missing its expire time.
145    #[error("MISSING_EXPIRE_TIME")]
146    MissingExpireTime,
147    /// The order's expire time is in the past.
148    #[error("EXPIRE_TIME_IN_PAST: {expire_time}")]
149    ExpireTimeInPast {
150        /// The expire time that has already elapsed.
151        expire_time: String,
152    },
153    /// The order is missing a required trailing offset type.
154    #[error("MISSING_TRAILING_OFFSET_TYPE")]
155    MissingTrailingOffsetType,
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    /// The order is missing a required trigger type.
163    #[error("MISSING_TRIGGER_TYPE")]
164    MissingTriggerType,
165    /// The order is missing a required trailing offset.
166    #[error("MISSING_TRAILING_OFFSET")]
167    MissingTrailingOffset,
168
169    /// The instrument was not found in the cache.
170    #[error("INSTRUMENT_NOT_FOUND: {instrument_id}")]
171    InstrumentNotFound {
172        /// The instrument that was not found.
173        instrument_id: InstrumentId,
174    },
175    /// The position for a reduce-only order was not found.
176    #[error("POSITION_NOT_FOUND: {position_id}")]
177    PositionNotFound {
178        /// The position that was not found.
179        position_id: PositionId,
180    },
181    /// No market price is available for the order risk check.
182    #[error("MARKET_PRICE_UNAVAILABLE: order_type={order_type}, instrument_id={instrument_id}")]
183    MarketPriceUnavailable {
184        /// The order type requiring a market price.
185        order_type: OrderType,
186        /// The instrument with no available market price.
187        instrument_id: InstrumentId,
188    },
189
190    /// The trailing stop trigger price could not be calculated.
191    #[error("TRAILING_STOP_CALCULATION_FAILED: {detail}")]
192    TrailingStopCalculationFailed {
193        /// The underlying calculation error.
194        detail: String,
195    },
196    /// The order notional value could not be calculated.
197    #[error("NOTIONAL_CALCULATION_FAILED: {detail}")]
198    NotionalCalculationFailed {
199        /// The underlying calculation error.
200        detail: String,
201    },
202    /// The order notional is below the instrument minimum.
203    #[error("NOTIONAL_BELOW_MINIMUM: min={min_notional}, notional={notional}")]
204    NotionalBelowMinimum {
205        /// The instrument's minimum notional.
206        min_notional: Money,
207        /// The order's notional value.
208        notional: Money,
209    },
210    /// The order notional exceeds the instrument maximum.
211    #[error("NOTIONAL_EXCEEDS_MAXIMUM: max={max_notional}, notional={notional}")]
212    NotionalExceedsMaximum {
213        /// The instrument's maximum notional.
214        max_notional: Money,
215        /// The order's notional value.
216        notional: Money,
217    },
218    /// The order notional exceeds the configured maximum per order.
219    #[error("NOTIONAL_EXCEEDS_MAX_PER_ORDER: max={max_notional}, notional={notional}")]
220    NotionalExceedsMaxPerOrder {
221        /// The configured maximum notional per order.
222        max_notional: Money,
223        /// The order's notional value.
224        notional: Money,
225    },
226    /// The order notional exceeds the account free balance.
227    #[error("NOTIONAL_EXCEEDS_FREE_BALANCE: free={free_balance}, notional={notional}")]
228    NotionalExceedsFreeBalance {
229        /// The account's free balance.
230        free_balance: Money,
231        /// The order's notional value.
232        notional: Money,
233    },
234    /// The order initial margin could not be calculated.
235    #[error("INITIAL_MARGIN_CALCULATION_FAILED: {detail}")]
236    InitialMarginCalculationFailed {
237        /// The underlying calculation error.
238        detail: String,
239    },
240    /// The order initial margin exceeds the account free balance.
241    #[error("INITIAL_MARGIN_EXCEEDS_FREE_BALANCE: free={free_balance}, margin={initial_margin}")]
242    InitialMarginExceedsFreeBalance {
243        /// The account's free balance.
244        free_balance: Money,
245        /// The initial margin required for the order.
246        initial_margin: Money,
247    },
248    /// The balance to lock for the betting order could not be calculated.
249    #[error("BETTING_BALANCE_LOCKED_CALCULATION_FAILED: {detail}")]
250    BettingBalanceLockedCalculationFailed {
251        /// The underlying calculation error.
252        detail: String,
253    },
254
255    /// The cumulative order notional exceeds the account free balance.
256    #[error(
257        "CUMULATIVE_NOTIONAL_EXCEEDS_FREE_BALANCE: free={free_balance}, notional={cumulative_notional}"
258    )]
259    CumulativeNotionalExceedsFreeBalance {
260        /// The account's free balance.
261        free_balance: Money,
262        /// The cumulative notional across the checked orders.
263        cumulative_notional: Money,
264    },
265    /// The cumulative initial margin could not be calculated.
266    #[error("CUMULATIVE_INITIAL_MARGIN_CALCULATION_FAILED: {detail}")]
267    CumulativeInitialMarginCalculationFailed {
268        /// The underlying calculation error.
269        detail: String,
270    },
271    /// The cumulative initial margin exceeds the account free balance.
272    #[error(
273        "CUMULATIVE_INITIAL_MARGIN_EXCEEDS_FREE_BALANCE: free={free_balance}, margin={cumulative_initial_margin}"
274    )]
275    CumulativeInitialMarginExceedsFreeBalance {
276        /// The account's free balance.
277        free_balance: Money,
278        /// The cumulative initial margin across the checked orders.
279        cumulative_initial_margin: Money,
280    },
281
282    /// A reduce-only order would increase the position.
283    #[error("REDUCE_ONLY_WOULD_INCREASE_POSITION: {position_id}")]
284    ReduceOnlyWouldIncreasePosition {
285        /// The position the order would increase.
286        position_id: PositionId,
287    },
288    /// The order list is missing orders in the cache.
289    #[error("ORDER_LIST_INCOMPLETE: {order_list_id}")]
290    OrderListIncomplete {
291        /// The order list with missing orders.
292        order_list_id: OrderListId,
293    },
294    /// The order was denied because its order list failed risk checks.
295    #[error("ORDER_LIST_DENIED: {order_list_id}")]
296    OrderListDenied {
297        /// The order list that failed risk checks.
298        order_list_id: OrderListId,
299    },
300    /// Trading is halted; new orders are denied.
301    #[error("TRADING_HALTED")]
302    TradingHalted,
303    /// Trading is reducing; the order would increase exposure.
304    #[error("TRADING_STATE_REDUCING: side={order_side}, instrument_id={instrument_id}")]
305    TradingStateReducing {
306        /// The side of the order that would increase exposure.
307        order_side: OrderSide,
308        /// The instrument the order applies to.
309        instrument_id: InstrumentId,
310    },
311    /// The order submission rate limit was exceeded.
312    #[error("RATE_LIMIT_EXCEEDED")]
313    RateLimitExceeded,
314    /// The execution stream is unavailable or recovering; retry after recovery.
315    #[error("STREAM_RECONCILING: execution stream unavailable or recovering, retry after recovery")]
316    StreamReconciling,
317
318    /// No execution client was found for the routed command.
319    #[error(
320        "NO_EXECUTION_CLIENT: client_id={client}, {routing_context}",
321        client = .client_id.as_ref().map_or("NONE", ClientId::as_str),
322    )]
323    NoExecutionClient {
324        /// The explicitly requested client, if one was supplied.
325        client_id: Option<ClientId>,
326        /// The routing context used to look up an execution client.
327        routing_context: String,
328    },
329    /// The execution client does not handle the order venue.
330    #[error(
331        "CLIENT_VENUE_MISMATCH: client_id={client_id}, order_venue={order_venue}, client_venue={client_venue}"
332    )]
333    ClientVenueMismatch {
334        /// The routed execution client.
335        client_id: ClientId,
336        /// The order venue.
337        order_venue: Venue,
338        /// The execution client's venue.
339        client_venue: Venue,
340    },
341    /// Submitting the order to the execution client failed.
342    #[error("SUBMIT_FAILED: {detail}")]
343    SubmitFailed {
344        /// The underlying submission error.
345        detail: String,
346    },
347
348    /// The client order ID is invalid for the venue.
349    #[error("INVALID_CLIENT_ORDER_ID: {detail}")]
350    InvalidClientOrderId {
351        /// The validation failure detail.
352        detail: String,
353    },
354    /// The supplied position ID is invalid for the order submission.
355    #[error("INVALID_POSITION_ID: {position_id}; {detail}")]
356    InvalidPositionId {
357        /// The invalid position ID.
358        position_id: PositionId,
359        /// The validation failure detail.
360        detail: String,
361    },
362    /// The venue does not support the requested order list.
363    #[error("UNSUPPORTED_ORDER_LIST: {detail}")]
364    UnsupportedOrderList {
365        /// The reason the order list is unsupported.
366        detail: String,
367    },
368    /// The order type is not supported.
369    #[error("UNSUPPORTED_ORDER_TYPE: {order_type}")]
370    UnsupportedOrderType {
371        /// The unsupported order type.
372        order_type: OrderType,
373    },
374    /// The order's time in force is not supported.
375    #[error("UNSUPPORTED_TIME_IN_FORCE: {0}")]
376    UnsupportedTimeInForce(TimeInForce),
377    /// The venue does not support the requested take-profit/stop-loss parameters.
378    #[error("UNSUPPORTED_TP_SL: {detail}")]
379    UnsupportedTpSl {
380        /// The reason the take-profit/stop-loss parameters are unsupported.
381        detail: String,
382    },
383    /// The order failed validation before submission.
384    #[error("VALIDATION_FAILED: {detail}")]
385    ValidationFailed {
386        /// The validation failure detail.
387        detail: String,
388    },
389}
390
391impl OrderDeniedCode {
392    /// Returns a one-line description of this denial code.
393    #[must_use]
394    pub fn description(&self) -> &'static str {
395        match self {
396            Self::PricePrecisionExceedsMaximum => {
397                "The price precision exceeds the instrument maximum."
398            }
399            Self::PriceNotPositive => "The price is not positive.",
400            Self::QuantityPrecisionExceedsMaximum => {
401                "The quantity precision exceeds the instrument maximum."
402            }
403            Self::QuantityConversionFailed => {
404                "The order quantity could not be converted for risk checks."
405            }
406            Self::QuantityExceedsMaximum => {
407                "The effective order quantity exceeds the instrument maximum."
408            }
409            Self::QuantityBelowMinimum => {
410                "The effective order quantity is below the instrument minimum."
411            }
412            Self::InvalidMaxNotionalPerOrder => {
413                "The configured maximum notional per order is invalid."
414            }
415            Self::InvalidOrderSide => "The order side is invalid for this operation.",
416            Self::MissingExpireTime => "A GTD order is missing its expire time.",
417            Self::ExpireTimeInPast => "The order's expire time is in the past.",
418            Self::MissingTrailingOffsetType => {
419                "The order is missing a required trailing offset type."
420            }
421            Self::UnsupportedTrailingOffsetType => {
422                "The order's trailing offset type is not supported."
423            }
424            Self::MissingTriggerType => "The order is missing a required trigger type.",
425            Self::MissingTrailingOffset => "The order is missing a required trailing offset.",
426            Self::InstrumentNotFound => "The instrument was not found in the cache.",
427            Self::PositionNotFound => "The position for a reduce-only order was not found.",
428            Self::MarketPriceUnavailable => {
429                "No market price is available for the order risk check."
430            }
431            Self::TrailingStopCalculationFailed => {
432                "The trailing stop trigger price could not be calculated."
433            }
434            Self::NotionalCalculationFailed => "The order notional value could not be calculated.",
435            Self::NotionalBelowMinimum => "The order notional is below the instrument minimum.",
436            Self::NotionalExceedsMaximum => "The order notional exceeds the instrument maximum.",
437            Self::NotionalExceedsMaxPerOrder => {
438                "The order notional exceeds the configured maximum per order."
439            }
440            Self::NotionalExceedsFreeBalance => {
441                "The order notional exceeds the account free balance."
442            }
443            Self::InitialMarginCalculationFailed => {
444                "The order initial margin could not be calculated."
445            }
446            Self::InitialMarginExceedsFreeBalance => {
447                "The order initial margin exceeds the account free balance."
448            }
449            Self::BettingBalanceLockedCalculationFailed => {
450                "The balance to lock for the betting order could not be calculated."
451            }
452            Self::CumulativeNotionalExceedsFreeBalance => {
453                "The cumulative order notional exceeds the account free balance."
454            }
455            Self::CumulativeInitialMarginCalculationFailed => {
456                "The cumulative initial margin could not be calculated."
457            }
458            Self::CumulativeInitialMarginExceedsFreeBalance => {
459                "The cumulative initial margin exceeds the account free balance."
460            }
461            Self::ReduceOnlyWouldIncreasePosition => {
462                "A reduce-only order would increase the position."
463            }
464            Self::OrderListIncomplete => "The order list is missing orders in the cache.",
465            Self::OrderListDenied => {
466                "The order was denied because its order list failed risk checks."
467            }
468            Self::TradingHalted => "Trading is halted; new orders are denied.",
469            Self::TradingStateReducing => "Trading is reducing; the order would increase exposure.",
470            Self::RateLimitExceeded => "The order submission rate limit was exceeded.",
471            Self::StreamReconciling => {
472                "The execution stream is unavailable or recovering; retry after recovery."
473            }
474            Self::NoExecutionClient => "No execution client was found for the routed command.",
475            Self::ClientVenueMismatch => "The execution client does not handle the order venue.",
476            Self::SubmitFailed => "Submitting the order to the execution client failed.",
477            Self::InvalidClientOrderId => "The client order ID is invalid for the venue.",
478            Self::InvalidPositionId => {
479                "The supplied position ID is invalid for the order submission."
480            }
481            Self::UnsupportedOrderList => "The venue does not support the requested order list.",
482            Self::UnsupportedOrderType => "The order type is not supported.",
483            Self::UnsupportedTimeInForce => "The order's time in force is not supported.",
484            Self::UnsupportedTpSl => {
485                "The venue does not support the requested take-profit/stop-loss parameters."
486            }
487            Self::ValidationFailed => "The order failed validation before submission.",
488        }
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use rstest::rstest;
495    use strum::IntoEnumIterator;
496
497    use super::*;
498
499    const DOC_PATH: &str = concat!(
500        env!("CARGO_MANIFEST_DIR"),
501        "/../../docs/concepts/execution.md"
502    );
503    const BLOCK_BEGIN: &str = "<!-- BEGIN GENERATED: order-denied-reasons -->";
504    const BLOCK_END: &str = "<!-- END GENERATED: order-denied-reasons -->";
505
506    #[rstest]
507    fn renders_subject_led_messages() {
508        let exceeds = OrderDeniedReason::QuantityExceedsMaximum {
509            effective_quantity: Quantity::from("15"),
510            max_quantity: Quantity::from("10"),
511        };
512        let below = OrderDeniedReason::QuantityBelowMinimum {
513            effective_quantity: Quantity::from("1"),
514            min_quantity: Quantity::from("5"),
515        };
516        let notional = OrderDeniedReason::NotionalBelowMinimum {
517            min_notional: Money::from("1.00 USD"),
518            notional: Money::from("0.90 USD"),
519        };
520
521        assert_eq!(
522            exceeds.to_string(),
523            "QUANTITY_EXCEEDS_MAXIMUM: effective=15, max=10"
524        );
525        assert_eq!(
526            below.to_string(),
527            "QUANTITY_BELOW_MINIMUM: effective=1, min=5"
528        );
529        assert_eq!(
530            notional.to_string(),
531            "NOTIONAL_BELOW_MINIMUM: min=1.00 USD, notional=0.90 USD"
532        );
533    }
534
535    #[rstest]
536    fn renders_standardized_risk_messages() {
537        assert_eq!(
538            OrderDeniedReason::PricePrecisionExceedsMaximum {
539                field: OrderPriceField::Price,
540                price: Price::from("1.234"),
541                price_precision: 3,
542                max_precision: 2,
543            }
544            .to_string(),
545            "PRICE_PRECISION_EXCEEDS_MAXIMUM: field=PRICE, price=1.234, precision=3, max_precision=2"
546        );
547        assert_eq!(
548            OrderDeniedReason::PriceNotPositive {
549                field: OrderPriceField::TriggerPrice,
550                price: Price::from("-0.1"),
551            }
552            .to_string(),
553            "PRICE_NOT_POSITIVE: field=TRIGGER_PRICE, price=-0.1"
554        );
555        assert_eq!(
556            OrderDeniedReason::QuantityConversionFailed {
557                detail: "value exceeds MoneyRaw bounds".to_string(),
558            }
559            .to_string(),
560            "QUANTITY_CONVERSION_FAILED: value exceeds MoneyRaw bounds"
561        );
562        assert_eq!(
563            OrderDeniedReason::UnsupportedTrailingOffsetType {
564                offset_type: TrailingOffsetType::PriceTier,
565            }
566            .to_string(),
567            "UNSUPPORTED_TRAILING_OFFSET_TYPE: PRICE_TIER"
568        );
569        assert_eq!(
570            OrderDeniedReason::NotionalCalculationFailed {
571                detail: "value exceeds Money bounds".to_string(),
572            }
573            .to_string(),
574            "NOTIONAL_CALCULATION_FAILED: value exceeds Money bounds"
575        );
576        assert_eq!(
577            OrderDeniedReason::InitialMarginCalculationFailed {
578                detail: "margin model unavailable".to_string(),
579            }
580            .to_string(),
581            "INITIAL_MARGIN_CALCULATION_FAILED: margin model unavailable"
582        );
583        assert_eq!(
584            OrderDeniedReason::NotionalExceedsMaxPerOrder {
585                max_notional: Money::from("10.00 USD"),
586                notional: Money::from("11.00 USD"),
587            }
588            .to_string(),
589            "NOTIONAL_EXCEEDS_MAX_PER_ORDER: max=10.00 USD, notional=11.00 USD"
590        );
591        assert_eq!(
592            OrderDeniedReason::NotionalExceedsMaximum {
593                max_notional: Money::from("12.00 USD"),
594                notional: Money::from("13.00 USD"),
595            }
596            .to_string(),
597            "NOTIONAL_EXCEEDS_MAXIMUM: max=12.00 USD, notional=13.00 USD"
598        );
599        assert_eq!(
600            OrderDeniedReason::NotionalExceedsFreeBalance {
601                free_balance: Money::from("10.00 USD"),
602                notional: Money::from("11.00 USD"),
603            }
604            .to_string(),
605            "NOTIONAL_EXCEEDS_FREE_BALANCE: free=10.00 USD, notional=11.00 USD"
606        );
607        assert_eq!(
608            OrderDeniedReason::CumulativeNotionalExceedsFreeBalance {
609                free_balance: Money::from("10.00 USD"),
610                cumulative_notional: Money::from("12.00 USD"),
611            }
612            .to_string(),
613            "CUMULATIVE_NOTIONAL_EXCEEDS_FREE_BALANCE: free=10.00 USD, notional=12.00 USD"
614        );
615        assert_eq!(
616            OrderDeniedReason::InitialMarginExceedsFreeBalance {
617                free_balance: Money::from("10.00 USD"),
618                initial_margin: Money::from("13.00 USD"),
619            }
620            .to_string(),
621            "INITIAL_MARGIN_EXCEEDS_FREE_BALANCE: free=10.00 USD, margin=13.00 USD"
622        );
623        assert_eq!(
624            OrderDeniedReason::CumulativeInitialMarginExceedsFreeBalance {
625                free_balance: Money::from("10.00 USD"),
626                cumulative_initial_margin: Money::from("14.00 USD"),
627            }
628            .to_string(),
629            "CUMULATIVE_INITIAL_MARGIN_EXCEEDS_FREE_BALANCE: free=10.00 USD, margin=14.00 USD"
630        );
631        assert_eq!(
632            OrderDeniedReason::CumulativeInitialMarginCalculationFailed {
633                detail: "total exceeds Money bounds".to_string(),
634            }
635            .to_string(),
636            "CUMULATIVE_INITIAL_MARGIN_CALCULATION_FAILED: total exceeds Money bounds"
637        );
638        assert_eq!(
639            OrderDeniedReason::BettingBalanceLockedCalculationFailed {
640                detail: "invalid liability".to_string(),
641            }
642            .to_string(),
643            "BETTING_BALANCE_LOCKED_CALCULATION_FAILED: invalid liability"
644        );
645        assert_eq!(
646            OrderDeniedReason::TrailingStopCalculationFailed {
647                detail: "missing market price".to_string(),
648            }
649            .to_string(),
650            "TRAILING_STOP_CALCULATION_FAILED: missing market price"
651        );
652    }
653
654    #[rstest]
655    fn renders_lifecycle_and_state_messages() {
656        let not_found = OrderDeniedReason::InstrumentNotFound {
657            instrument_id: InstrumentId::from("AUD/USD.SIM"),
658        };
659        let bad_side = OrderDeniedReason::InvalidOrderSide { order_side: None };
660        let reducing = OrderDeniedReason::TradingStateReducing {
661            order_side: OrderSide::Buy,
662            instrument_id: InstrumentId::from("AUD/USD.SIM"),
663        };
664
665        assert_eq!(not_found.to_string(), "INSTRUMENT_NOT_FOUND: AUD/USD.SIM");
666        assert_eq!(
667            OrderDeniedReason::ExpireTimeInPast {
668                expire_time: "1970-01-01T00:00:00Z".to_string(),
669            }
670            .to_string(),
671            "EXPIRE_TIME_IN_PAST: 1970-01-01T00:00:00Z"
672        );
673        assert_eq!(
674            OrderDeniedReason::PositionNotFound {
675                position_id: PositionId::from("P-1"),
676            }
677            .to_string(),
678            "POSITION_NOT_FOUND: P-1"
679        );
680        assert_eq!(
681            OrderDeniedReason::ReduceOnlyWouldIncreasePosition {
682                position_id: PositionId::from("P-2"),
683            }
684            .to_string(),
685            "REDUCE_ONLY_WOULD_INCREASE_POSITION: P-2"
686        );
687        assert_eq!(
688            OrderDeniedReason::OrderListIncomplete {
689                order_list_id: OrderListId::from("OL-1"),
690            }
691            .to_string(),
692            "ORDER_LIST_INCOMPLETE: OL-1"
693        );
694        assert_eq!(
695            OrderDeniedReason::OrderListDenied {
696                order_list_id: OrderListId::from("OL-2"),
697            }
698            .to_string(),
699            "ORDER_LIST_DENIED: OL-2"
700        );
701        assert_eq!(bad_side.to_string(), "INVALID_ORDER_SIDE: NO_ORDER_SIDE");
702        assert_eq!(
703            OrderDeniedReason::TradingHalted.to_string(),
704            "TRADING_HALTED"
705        );
706        assert_eq!(
707            OrderDeniedReason::RateLimitExceeded.to_string(),
708            "RATE_LIMIT_EXCEEDED"
709        );
710        assert_eq!(
711            reducing.to_string(),
712            "TRADING_STATE_REDUCING: side=BUY, instrument_id=AUD/USD.SIM"
713        );
714    }
715
716    #[rstest]
717    fn renders_routing_messages() {
718        let missing_client = OrderDeniedReason::NoExecutionClient {
719            client_id: Some(ClientId::from("SIM")),
720            routing_context: "venue=SIM".to_string(),
721        };
722        let mismatch = OrderDeniedReason::ClientVenueMismatch {
723            client_id: ClientId::from("IB"),
724            order_venue: Venue::from("XCME"),
725            client_venue: Venue::from("IB"),
726        };
727        let submit_failed = OrderDeniedReason::SubmitFailed {
728            detail: "transport closed".to_string(),
729        };
730        let invalid_position_id = OrderDeniedReason::InvalidPositionId {
731            position_id: PositionId::from("P-1"),
732            detail: "not valid for NETTING OMS".to_string(),
733        };
734
735        assert_eq!(
736            missing_client.to_string(),
737            "NO_EXECUTION_CLIENT: client_id=SIM, venue=SIM"
738        );
739        assert_eq!(
740            mismatch.to_string(),
741            "CLIENT_VENUE_MISMATCH: client_id=IB, order_venue=XCME, client_venue=IB"
742        );
743        assert_eq!(submit_failed.to_string(), "SUBMIT_FAILED: transport closed");
744        assert_eq!(
745            invalid_position_id.to_string(),
746            "INVALID_POSITION_ID: P-1; not valid for NETTING OMS"
747        );
748    }
749
750    #[rstest]
751    fn renders_condition_led_message() {
752        let reason = OrderDeniedReason::UnsupportedTimeInForce(TimeInForce::Gtd);
753        assert_eq!(reason.to_string(), "UNSUPPORTED_TIME_IN_FORCE: GTD");
754    }
755
756    #[rstest]
757    fn renders_adapter_messages() {
758        let invalid_client_order_id = OrderDeniedReason::InvalidClientOrderId {
759            detail: "clOrdId must be alphanumeric".to_string(),
760        };
761        let unsupported_order_list = OrderDeniedReason::UnsupportedOrderList {
762            detail: "spread instruments are not supported in order lists".to_string(),
763        };
764        let unsupported_order_type = OrderDeniedReason::UnsupportedOrderType {
765            order_type: OrderType::TrailingStopMarket,
766        };
767        let unsupported_tp_sl = OrderDeniedReason::UnsupportedTpSl {
768            detail: "TP/SL trigger prices are not supported in demo mode".to_string(),
769        };
770        let validation_failed = OrderDeniedReason::ValidationFailed {
771            detail: "`bbo_side_type` and `bbo_level` are only supported for linear products"
772                .to_string(),
773        };
774
775        assert_eq!(
776            invalid_client_order_id.to_string(),
777            "INVALID_CLIENT_ORDER_ID: clOrdId must be alphanumeric"
778        );
779        assert_eq!(
780            unsupported_order_list.to_string(),
781            "UNSUPPORTED_ORDER_LIST: spread instruments are not supported in order lists"
782        );
783        assert_eq!(
784            unsupported_order_type.to_string(),
785            "UNSUPPORTED_ORDER_TYPE: TRAILING_STOP_MARKET"
786        );
787        assert_eq!(
788            unsupported_tp_sl.to_string(),
789            "UNSUPPORTED_TP_SL: TP/SL trigger prices are not supported in demo mode"
790        );
791        assert_eq!(
792            validation_failed.to_string(),
793            "VALIDATION_FAILED: `bbo_side_type` and `bbo_level` are only supported for linear products"
794        );
795        assert_eq!(
796            OrderDeniedReason::StreamReconciling.to_string(),
797            "STREAM_RECONCILING: execution stream unavailable or recovering, retry after recovery"
798        );
799    }
800
801    // Drift pin: each variant's rendered message must start with its discriminant code, keeping
802    // the hand-written `#[error]` prefix in sync with the strum-derived `OrderDeniedCode`.
803    #[rstest]
804    fn message_prefix_matches_code() {
805        let usd = || Money::from("100.00 USD");
806        let samples = [
807            OrderDeniedReason::PricePrecisionExceedsMaximum {
808                field: OrderPriceField::Price,
809                price: Price::from("1.00"),
810                price_precision: 2,
811                max_precision: 1,
812            },
813            OrderDeniedReason::PriceNotPositive {
814                field: OrderPriceField::TriggerPrice,
815                price: Price::from("0.00"),
816            },
817            OrderDeniedReason::QuantityPrecisionExceedsMaximum {
818                quantity: Quantity::from("1.00"),
819                quantity_precision: 2,
820                max_precision: 1,
821            },
822            OrderDeniedReason::QuantityConversionFailed {
823                detail: "boom".to_string(),
824            },
825            OrderDeniedReason::QuantityExceedsMaximum {
826                effective_quantity: Quantity::from("15"),
827                max_quantity: Quantity::from("10"),
828            },
829            OrderDeniedReason::QuantityBelowMinimum {
830                effective_quantity: Quantity::from("1"),
831                min_quantity: Quantity::from("5"),
832            },
833            OrderDeniedReason::InvalidMaxNotionalPerOrder {
834                instrument_id: InstrumentId::from("AUD/USD.SIM"),
835                value: Decimal::ONE,
836            },
837            OrderDeniedReason::InvalidOrderSide { order_side: None },
838            OrderDeniedReason::MissingExpireTime,
839            OrderDeniedReason::ExpireTimeInPast {
840                expire_time: "1970-01-01T00:00:00Z".to_string(),
841            },
842            OrderDeniedReason::MissingTrailingOffsetType,
843            OrderDeniedReason::UnsupportedTrailingOffsetType {
844                offset_type: TrailingOffsetType::Price,
845            },
846            OrderDeniedReason::MissingTriggerType,
847            OrderDeniedReason::MissingTrailingOffset,
848            OrderDeniedReason::InstrumentNotFound {
849                instrument_id: InstrumentId::from("AUD/USD.SIM"),
850            },
851            OrderDeniedReason::PositionNotFound {
852                position_id: PositionId::from("P-1"),
853            },
854            OrderDeniedReason::MarketPriceUnavailable {
855                order_type: OrderType::Market,
856                instrument_id: InstrumentId::from("AUD/USD.SIM"),
857            },
858            OrderDeniedReason::TrailingStopCalculationFailed {
859                detail: "boom".to_string(),
860            },
861            OrderDeniedReason::NotionalCalculationFailed {
862                detail: "boom".to_string(),
863            },
864            OrderDeniedReason::NotionalBelowMinimum {
865                min_notional: usd(),
866                notional: usd(),
867            },
868            OrderDeniedReason::NotionalExceedsMaximum {
869                max_notional: usd(),
870                notional: usd(),
871            },
872            OrderDeniedReason::NotionalExceedsMaxPerOrder {
873                max_notional: usd(),
874                notional: usd(),
875            },
876            OrderDeniedReason::NotionalExceedsFreeBalance {
877                free_balance: usd(),
878                notional: usd(),
879            },
880            OrderDeniedReason::InitialMarginCalculationFailed {
881                detail: "boom".to_string(),
882            },
883            OrderDeniedReason::InitialMarginExceedsFreeBalance {
884                free_balance: usd(),
885                initial_margin: usd(),
886            },
887            OrderDeniedReason::BettingBalanceLockedCalculationFailed {
888                detail: "boom".to_string(),
889            },
890            OrderDeniedReason::CumulativeNotionalExceedsFreeBalance {
891                free_balance: usd(),
892                cumulative_notional: usd(),
893            },
894            OrderDeniedReason::CumulativeInitialMarginCalculationFailed {
895                detail: "boom".to_string(),
896            },
897            OrderDeniedReason::CumulativeInitialMarginExceedsFreeBalance {
898                free_balance: usd(),
899                cumulative_initial_margin: usd(),
900            },
901            OrderDeniedReason::ReduceOnlyWouldIncreasePosition {
902                position_id: PositionId::from("P-1"),
903            },
904            OrderDeniedReason::OrderListIncomplete {
905                order_list_id: OrderListId::from("OL-1"),
906            },
907            OrderDeniedReason::OrderListDenied {
908                order_list_id: OrderListId::from("OL-1"),
909            },
910            OrderDeniedReason::TradingHalted,
911            OrderDeniedReason::TradingStateReducing {
912                order_side: OrderSide::Buy,
913                instrument_id: InstrumentId::from("AUD/USD.SIM"),
914            },
915            OrderDeniedReason::RateLimitExceeded,
916            OrderDeniedReason::StreamReconciling,
917            OrderDeniedReason::NoExecutionClient {
918                client_id: Some(ClientId::from("SIM")),
919                routing_context: "venue=SIM".to_string(),
920            },
921            OrderDeniedReason::ClientVenueMismatch {
922                client_id: ClientId::from("IB"),
923                order_venue: Venue::from("XCME"),
924                client_venue: Venue::from("IB"),
925            },
926            OrderDeniedReason::SubmitFailed {
927                detail: "boom".to_string(),
928            },
929            OrderDeniedReason::InvalidClientOrderId {
930                detail: "boom".to_string(),
931            },
932            OrderDeniedReason::InvalidPositionId {
933                position_id: PositionId::from("P-1"),
934                detail: "boom".to_string(),
935            },
936            OrderDeniedReason::UnsupportedOrderList {
937                detail: "boom".to_string(),
938            },
939            OrderDeniedReason::UnsupportedOrderType {
940                order_type: OrderType::TrailingStopMarket,
941            },
942            OrderDeniedReason::UnsupportedTimeInForce(TimeInForce::Gtd),
943            OrderDeniedReason::UnsupportedTpSl {
944                detail: "boom".to_string(),
945            },
946            OrderDeniedReason::ValidationFailed {
947                detail: "boom".to_string(),
948            },
949        ];
950
951        assert_eq!(samples.len(), OrderDeniedCode::iter().count());
952        for reason in samples {
953            let code = OrderDeniedCode::from(&reason).to_string();
954            assert!(
955                reason.to_string().starts_with(&code),
956                "message `{reason}` must start with code `{code}`"
957            );
958        }
959    }
960
961    #[rstest]
962    fn generated_table_is_in_sync() {
963        let committed = std::fs::read_to_string(DOC_PATH).expect("execution.md should exist");
964        assert!(
965            committed.contains(&generated_block()),
966            "the order-denied-reasons table in docs/concepts/execution.md is stale; regenerate \
967             with `cargo test -p nautilus-model regenerate_order_denied_reasons_doc -- --ignored`"
968        );
969    }
970
971    #[rstest]
972    #[ignore = "rewrites the generated table in execution.md; run after changing OrderDeniedReason variants"]
973    fn regenerate_order_denied_reasons_doc() {
974        let doc = std::fs::read_to_string(DOC_PATH).expect("execution.md should exist");
975        let start = doc.find(BLOCK_BEGIN).expect("begin marker present");
976        let end = doc.find(BLOCK_END).expect("end marker present") + BLOCK_END.len();
977        let updated = format!("{}{}{}", &doc[..start], generated_block(), &doc[end..]);
978        std::fs::write(DOC_PATH, updated).expect("should write execution.md");
979    }
980
981    fn generated_block() -> String {
982        format!("{BLOCK_BEGIN}\n\n{}\n\n{BLOCK_END}", markdown_table())
983    }
984
985    fn markdown_table() -> String {
986        const CODE_HEADER: &str = "Code";
987        const DESC_HEADER: &str = "Description";
988
989        // Declaration order carries the public table's abstraction hierarchy
990        let rows: Vec<(String, &'static str)> = OrderDeniedCode::iter()
991            .map(|code| (format!("`{code}`"), code.description()))
992            .collect();
993        // Width counts characters, matching the padding applied by `format!` and the
994        // column width the Markdown table hook normalizes to.
995        let code_w = rows
996            .iter()
997            .map(|(code, _)| code.chars().count())
998            .max()
999            .unwrap_or(0)
1000            .max(CODE_HEADER.chars().count());
1001        let desc_w = rows
1002            .iter()
1003            .map(|(_, desc)| desc.chars().count())
1004            .max()
1005            .unwrap_or(0)
1006            .max(DESC_HEADER.chars().count());
1007
1008        let mut lines = vec![
1009            format!("| {CODE_HEADER:<code_w$} | {DESC_HEADER:<desc_w$} |"),
1010            format!("| {:-<code_w$} | {:-<desc_w$} |", "", ""),
1011        ];
1012
1013        for (code, desc) in rows {
1014            lines.push(format!("| {code:<code_w$} | {desc:<desc_w$} |"));
1015        }
1016        lines.join("\n")
1017    }
1018}