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 by
21//! `key=value` context. The companion [`OrderDeniedCode`] enum (generated by `strum`) enumerates
22//! the codes without their per-denial context, so documentation and grouping can iterate the
23//! 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, Quantity},
33};
34
35/// A standardized reason an order was denied locally by the Nautilus system.
36///
37/// A denial is a local rejection: the order never reached a venue. Each variant carries the
38/// context needed to render its message and maps to a stable [`OrderDeniedCode`].
39#[derive(Debug, Clone, PartialEq, Eq, Error, EnumDiscriminants)]
40#[strum_discriminants(
41    name(OrderDeniedCode),
42    derive(Display, AsRefStr, EnumIter, EnumString),
43    strum(serialize_all = "SCREAMING_SNAKE_CASE")
44)]
45pub enum OrderDeniedReason {
46    /// The effective order quantity exceeds the instrument maximum.
47    #[error(
48        "QUANTITY_EXCEEDS_MAXIMUM: effective_quantity={effective_quantity}, max_quantity={max_quantity}"
49    )]
50    QuantityExceedsMaximum {
51        /// The order quantity after any quote-to-base conversion.
52        effective_quantity: Quantity,
53        /// The instrument's maximum tradable quantity.
54        max_quantity: Quantity,
55    },
56    /// The effective order quantity is below the instrument minimum.
57    #[error(
58        "QUANTITY_BELOW_MINIMUM: effective_quantity={effective_quantity}, min_quantity={min_quantity}"
59    )]
60    QuantityBelowMinimum {
61        /// The order quantity after any quote-to-base conversion.
62        effective_quantity: Quantity,
63        /// The instrument's minimum tradable quantity.
64        min_quantity: Quantity,
65    },
66    /// The order notional exceeds the configured maximum per order.
67    #[error("NOTIONAL_EXCEEDS_MAX_PER_ORDER: max_notional={max_notional:?}, notional={notional:?}")]
68    NotionalExceedsMaxPerOrder {
69        /// The configured maximum notional per order.
70        max_notional: Money,
71        /// The order's notional value.
72        notional: Money,
73    },
74    /// The order notional exceeds the instrument maximum.
75    #[error("NOTIONAL_EXCEEDS_MAXIMUM: max_notional={max_notional:?}, notional={notional:?}")]
76    NotionalExceedsMaximum {
77        /// The instrument's maximum notional.
78        max_notional: Money,
79        /// The order's notional value.
80        notional: Money,
81    },
82    /// The order notional is below the instrument minimum.
83    #[error("NOTIONAL_BELOW_MINIMUM: min_notional={min_notional:?}, notional={notional:?}")]
84    NotionalBelowMinimum {
85        /// The instrument's minimum notional.
86        min_notional: Money,
87        /// The order's notional value.
88        notional: Money,
89    },
90    /// The order notional exceeds the account free balance.
91    #[error("NOTIONAL_EXCEEDS_FREE_BALANCE: free={free:?}, notional={notional:?}")]
92    NotionalExceedsFreeBalance {
93        /// The account's free balance.
94        free: Money,
95        /// The order's notional value.
96        notional: Money,
97    },
98    /// The cumulative order notional exceeds the account free balance.
99    #[error("CUM_NOTIONAL_EXCEEDS_FREE_BALANCE: free={free}, cum_notional={cum_notional}")]
100    CumNotionalExceedsFreeBalance {
101        /// The account's free balance.
102        free: Money,
103        /// The cumulative notional across the checked orders.
104        cum_notional: Money,
105    },
106    /// The order initial margin exceeds the account free balance.
107    #[error("MARGIN_EXCEEDS_FREE_BALANCE: free={free}, margin_required={margin_required}")]
108    MarginExceedsFreeBalance {
109        /// The account's free balance.
110        free: Money,
111        /// The initial margin required for the order.
112        margin_required: Money,
113    },
114    /// The cumulative initial margin exceeds the account free balance.
115    #[error("CUM_MARGIN_EXCEEDS_FREE_BALANCE: free={free}, cum_margin={cum_margin}")]
116    CumMarginExceedsFreeBalance {
117        /// The account's free balance.
118        free: Money,
119        /// The cumulative initial margin across the checked orders.
120        cum_margin: Money,
121    },
122    /// The configured maximum notional per order is invalid.
123    #[error("INVALID_MAX_NOTIONAL_PER_ORDER: instrument_id={instrument_id}, value={value}")]
124    InvalidMaxNotionalPerOrder {
125        /// The instrument the setting applies to.
126        instrument_id: InstrumentId,
127        /// The invalid configured value.
128        value: Decimal,
129    },
130    /// The order side is invalid for this operation.
131    #[error("INVALID_ORDER_SIDE: {order_side}")]
132    InvalidOrderSide {
133        /// The offending order side.
134        order_side: OrderSide,
135    },
136    /// A GTD order is missing its expire time.
137    #[error("MISSING_EXPIRE_TIME")]
138    MissingExpireTime,
139    /// The order's expire time is in the past.
140    #[error("EXPIRE_TIME_IN_PAST: expire_time={expire_time}")]
141    ExpireTimeInPast {
142        /// The expire time that has already elapsed.
143        expire_time: String,
144    },
145    /// The order is missing a required trigger type.
146    #[error("MISSING_TRIGGER_TYPE")]
147    MissingTriggerType,
148    /// The order is missing a required trailing offset.
149    #[error("MISSING_TRAILING_OFFSET")]
150    MissingTrailingOffset,
151    /// The order is missing a required trailing offset type.
152    #[error("MISSING_TRAILING_OFFSET_TYPE")]
153    MissingTrailingOffsetType,
154    /// The order's trailing offset type is not supported.
155    #[error("UNSUPPORTED_TRAILING_OFFSET_TYPE: {offset_type:?}")]
156    UnsupportedTrailingOffsetType {
157        /// The unsupported trailing offset type.
158        offset_type: TrailingOffsetType,
159    },
160    /// The trailing stop trigger price could not be calculated.
161    #[error("TRAILING_STOP_CALC_FAILED: {detail}")]
162    TrailingStopCalcFailed {
163        /// The underlying calculation error.
164        detail: String,
165    },
166    /// The order quantity could not be converted for risk checks.
167    #[error("QUANTITY_CONVERSION_FAILED: {detail}")]
168    QuantityConversionFailed {
169        /// The underlying conversion error.
170        detail: String,
171    },
172    /// The instrument was not found in the cache.
173    #[error("INSTRUMENT_NOT_FOUND: instrument_id={instrument_id}")]
174    InstrumentNotFound {
175        /// The instrument that was not found.
176        instrument_id: InstrumentId,
177    },
178    /// The position for a reduce-only order was not found.
179    #[error("POSITION_NOT_FOUND: position_id={position_id}")]
180    PositionNotFound {
181        /// The position that was not found.
182        position_id: PositionId,
183    },
184    /// A reduce-only order would increase the position.
185    #[error("REDUCE_ONLY_WOULD_INCREASE_POSITION: position_id={position_id}")]
186    ReduceOnlyWouldIncreasePosition {
187        /// The position the order would increase.
188        position_id: PositionId,
189    },
190    /// The order list is missing orders in the cache.
191    #[error("ORDER_LIST_INCOMPLETE: order_list_id={order_list_id}")]
192    OrderListIncomplete {
193        /// The order list with missing orders.
194        order_list_id: OrderListId,
195    },
196    /// The order was denied because its order list failed risk checks.
197    #[error("ORDER_LIST_DENIED: order_list_id={order_list_id}")]
198    OrderListDenied {
199        /// The order list that failed risk checks.
200        order_list_id: OrderListId,
201    },
202    /// Trading is halted; new orders are denied.
203    #[error("TRADING_HALTED")]
204    TradingHalted,
205    /// Trading is reducing; the order would increase exposure.
206    #[error("TRADING_STATE_REDUCING: order_side={order_side}, instrument_id={instrument_id}")]
207    TradingStateReducing {
208        /// The side of the order that would increase exposure.
209        order_side: OrderSide,
210        /// The instrument the order applies to.
211        instrument_id: InstrumentId,
212    },
213    /// The order submission rate limit was exceeded.
214    #[error("RATE_LIMIT_EXCEEDED")]
215    RateLimitExceeded,
216    /// No execution client was found for the routed command.
217    #[error("NO_EXECUTION_CLIENT: client_id={client_id:?}, routing_context={routing_context}")]
218    NoExecutionClient {
219        /// The explicitly requested client, if one was supplied.
220        client_id: Option<ClientId>,
221        /// The routing context used to look up an execution client.
222        routing_context: String,
223    },
224    /// The execution client does not handle the order venue.
225    #[error(
226        "CLIENT_VENUE_MISMATCH: client_id={client_id}, order_venue={order_venue}, client_venue={client_venue}"
227    )]
228    ClientVenueMismatch {
229        /// The routed execution client.
230        client_id: ClientId,
231        /// The order venue.
232        order_venue: Venue,
233        /// The execution client's venue.
234        client_venue: Venue,
235    },
236    /// Submitting the order to the execution client failed.
237    #[error("SUBMIT_FAILED: {detail}")]
238    SubmitFailed {
239        /// The underlying submission error.
240        detail: String,
241    },
242    /// The supplied position ID is invalid for the order submission.
243    #[error("INVALID_POSITION_ID: position_id={position_id}, detail={detail}")]
244    InvalidPositionId {
245        /// The invalid position ID.
246        position_id: PositionId,
247        /// The validation failure detail.
248        detail: String,
249    },
250    /// The order's time in force is not supported.
251    #[error("UNSUPPORTED_TIME_IN_FORCE: {0}")]
252    UnsupportedTimeInForce(TimeInForce),
253    /// The client order ID is invalid for the venue.
254    #[error("INVALID_CLIENT_ORDER_ID: {detail}")]
255    InvalidClientOrderId {
256        /// The validation failure detail.
257        detail: String,
258    },
259    /// The venue does not support the requested order list.
260    #[error("UNSUPPORTED_ORDER_LIST: {detail}")]
261    UnsupportedOrderList {
262        /// The reason the order list is unsupported.
263        detail: String,
264    },
265    /// The order type is not supported by the venue.
266    #[error("UNSUPPORTED_ORDER_TYPE: {order_type}")]
267    UnsupportedOrderType {
268        /// The unsupported order type.
269        order_type: OrderType,
270    },
271    /// The venue does not support the requested take-profit/stop-loss parameters.
272    #[error("UNSUPPORTED_TP_SL: {detail}")]
273    UnsupportedTpSl {
274        /// The reason the take-profit/stop-loss parameters are unsupported.
275        detail: String,
276    },
277    /// The order failed adapter validation before submission.
278    #[error("VALIDATION_FAILED: {detail}")]
279    ValidationFailed {
280        /// The validation failure detail.
281        detail: String,
282    },
283    /// A post-reconnect stream reconciliation is in progress; retry once it completes.
284    #[error(
285        "STREAM_RECONCILING: post-reconnect reconciliation in progress, retry once it completes"
286    )]
287    StreamReconciling,
288}
289
290impl OrderDeniedCode {
291    /// Returns a one-line description of this denial code.
292    #[must_use]
293    pub fn description(&self) -> &'static str {
294        match self {
295            Self::QuantityExceedsMaximum => {
296                "The effective order quantity exceeds the instrument maximum."
297            }
298            Self::QuantityBelowMinimum => {
299                "The effective order quantity is below the instrument minimum."
300            }
301            Self::NotionalExceedsMaxPerOrder => {
302                "The order notional exceeds the configured maximum per order."
303            }
304            Self::NotionalExceedsMaximum => "The order notional exceeds the instrument maximum.",
305            Self::NotionalBelowMinimum => "The order notional is below the instrument minimum.",
306            Self::NotionalExceedsFreeBalance => {
307                "The order notional exceeds the account free balance."
308            }
309            Self::CumNotionalExceedsFreeBalance => {
310                "The cumulative order notional exceeds the account free balance."
311            }
312            Self::MarginExceedsFreeBalance => {
313                "The order initial margin exceeds the account free balance."
314            }
315            Self::CumMarginExceedsFreeBalance => {
316                "The cumulative initial margin exceeds the account free balance."
317            }
318            Self::InvalidMaxNotionalPerOrder => {
319                "The configured maximum notional per order is invalid."
320            }
321            Self::InvalidOrderSide => "The order side is invalid for this operation.",
322            Self::MissingExpireTime => "A GTD order is missing its expire time.",
323            Self::ExpireTimeInPast => "The order's expire time is in the past.",
324            Self::MissingTriggerType => "The order is missing a required trigger type.",
325            Self::MissingTrailingOffset => "The order is missing a required trailing offset.",
326            Self::MissingTrailingOffsetType => {
327                "The order is missing a required trailing offset type."
328            }
329            Self::UnsupportedTrailingOffsetType => {
330                "The order's trailing offset type is not supported."
331            }
332            Self::TrailingStopCalcFailed => {
333                "The trailing stop trigger price could not be calculated."
334            }
335            Self::QuantityConversionFailed => {
336                "The order quantity could not be converted for risk checks."
337            }
338            Self::InstrumentNotFound => "The instrument was not found in the cache.",
339            Self::PositionNotFound => "The position for a reduce‑only order was not found.",
340            Self::ReduceOnlyWouldIncreasePosition => {
341                "A reduce‑only order would increase the position."
342            }
343            Self::OrderListIncomplete => "The order list is missing orders in the cache.",
344            Self::OrderListDenied => {
345                "The order was denied because its order list failed risk checks."
346            }
347            Self::TradingHalted => "Trading is halted; new orders are denied.",
348            Self::TradingStateReducing => "Trading is reducing; the order would increase exposure.",
349            Self::RateLimitExceeded => "The order submission rate limit was exceeded.",
350            Self::NoExecutionClient => "No execution client was found for the routed command.",
351            Self::ClientVenueMismatch => "The execution client does not handle the order venue.",
352            Self::SubmitFailed => "Submitting the order to the execution client failed.",
353            Self::InvalidPositionId => {
354                "The supplied position ID is invalid for the order submission."
355            }
356            Self::UnsupportedTimeInForce => "The order's time in force is not supported.",
357            Self::InvalidClientOrderId => "The client order ID is invalid for the venue.",
358            Self::UnsupportedOrderList => "The venue does not support the requested order list.",
359            Self::UnsupportedOrderType => "The order type is not supported by the venue.",
360            Self::UnsupportedTpSl => {
361                "The venue does not support the requested take‑profit/stop‑loss parameters."
362            }
363            Self::ValidationFailed => "The order failed adapter validation before submission.",
364            Self::StreamReconciling => {
365                "A post‑reconnect stream reconciliation is in progress; retry once it completes."
366            }
367        }
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use rstest::rstest;
374    use strum::IntoEnumIterator;
375
376    use super::*;
377
378    const DOC_PATH: &str = concat!(
379        env!("CARGO_MANIFEST_DIR"),
380        "/../../docs/concepts/execution.md"
381    );
382    const BLOCK_BEGIN: &str = "<!-- BEGIN GENERATED: order-denied-reasons -->";
383    const BLOCK_END: &str = "<!-- END GENERATED: order-denied-reasons -->";
384
385    #[rstest]
386    fn renders_subject_led_messages() {
387        let exceeds = OrderDeniedReason::QuantityExceedsMaximum {
388            effective_quantity: Quantity::from("15"),
389            max_quantity: Quantity::from("10"),
390        };
391        let below = OrderDeniedReason::QuantityBelowMinimum {
392            effective_quantity: Quantity::from("1"),
393            min_quantity: Quantity::from("5"),
394        };
395        let notional = OrderDeniedReason::NotionalBelowMinimum {
396            min_notional: Money::from("1.00 USD"),
397            notional: Money::from("0.90 USD"),
398        };
399
400        assert_eq!(
401            exceeds.to_string(),
402            "QUANTITY_EXCEEDS_MAXIMUM: effective_quantity=15, max_quantity=10"
403        );
404        assert_eq!(
405            below.to_string(),
406            "QUANTITY_BELOW_MINIMUM: effective_quantity=1, min_quantity=5"
407        );
408        assert_eq!(
409            notional.to_string(),
410            "NOTIONAL_BELOW_MINIMUM: min_notional=Money(1.00, USD), notional=Money(0.90, USD)"
411        );
412    }
413
414    #[rstest]
415    fn renders_lifecycle_and_state_messages() {
416        let not_found = OrderDeniedReason::InstrumentNotFound {
417            instrument_id: InstrumentId::from("AUD/USD.SIM"),
418        };
419        let bad_side = OrderDeniedReason::InvalidOrderSide {
420            order_side: OrderSide::NoOrderSide,
421        };
422        let reducing = OrderDeniedReason::TradingStateReducing {
423            order_side: OrderSide::Buy,
424            instrument_id: InstrumentId::from("AUD/USD.SIM"),
425        };
426
427        assert_eq!(
428            not_found.to_string(),
429            "INSTRUMENT_NOT_FOUND: instrument_id=AUD/USD.SIM"
430        );
431        assert_eq!(bad_side.to_string(), "INVALID_ORDER_SIDE: NO_ORDER_SIDE");
432        assert_eq!(
433            OrderDeniedReason::TradingHalted.to_string(),
434            "TRADING_HALTED"
435        );
436        assert_eq!(
437            OrderDeniedReason::RateLimitExceeded.to_string(),
438            "RATE_LIMIT_EXCEEDED"
439        );
440        assert_eq!(
441            reducing.to_string(),
442            "TRADING_STATE_REDUCING: order_side=BUY, instrument_id=AUD/USD.SIM"
443        );
444    }
445
446    #[rstest]
447    fn renders_routing_messages() {
448        let missing_client = OrderDeniedReason::NoExecutionClient {
449            client_id: Some(ClientId::from("SIM")),
450            routing_context: "venue=SIM".to_string(),
451        };
452        let mismatch = OrderDeniedReason::ClientVenueMismatch {
453            client_id: ClientId::from("IB"),
454            order_venue: Venue::from("XCME"),
455            client_venue: Venue::from("IB"),
456        };
457        let submit_failed = OrderDeniedReason::SubmitFailed {
458            detail: "transport closed".to_string(),
459        };
460        let invalid_position_id = OrderDeniedReason::InvalidPositionId {
461            position_id: PositionId::from("P-1"),
462            detail: "not valid for NETTING OMS".to_string(),
463        };
464
465        assert_eq!(
466            missing_client.to_string(),
467            "NO_EXECUTION_CLIENT: client_id=Some(\"SIM\"), routing_context=venue=SIM"
468        );
469        assert_eq!(
470            mismatch.to_string(),
471            "CLIENT_VENUE_MISMATCH: client_id=IB, order_venue=XCME, client_venue=IB"
472        );
473        assert_eq!(submit_failed.to_string(), "SUBMIT_FAILED: transport closed");
474        assert_eq!(
475            invalid_position_id.to_string(),
476            "INVALID_POSITION_ID: position_id=P-1, detail=not valid for NETTING OMS"
477        );
478    }
479
480    #[rstest]
481    fn renders_condition_led_message() {
482        let reason = OrderDeniedReason::UnsupportedTimeInForce(TimeInForce::Gtd);
483        assert_eq!(reason.to_string(), "UNSUPPORTED_TIME_IN_FORCE: GTD");
484    }
485
486    #[rstest]
487    fn renders_adapter_messages() {
488        let invalid_client_order_id = OrderDeniedReason::InvalidClientOrderId {
489            detail: "clOrdId must be alphanumeric".to_string(),
490        };
491        let unsupported_order_list = OrderDeniedReason::UnsupportedOrderList {
492            detail: "spread instruments are not supported in order lists".to_string(),
493        };
494        let unsupported_order_type = OrderDeniedReason::UnsupportedOrderType {
495            order_type: OrderType::TrailingStopMarket,
496        };
497        let unsupported_tp_sl = OrderDeniedReason::UnsupportedTpSl {
498            detail: "TP/SL trigger prices are not supported in demo mode".to_string(),
499        };
500        let validation_failed = OrderDeniedReason::ValidationFailed {
501            detail: "`bbo_side_type` and `bbo_level` are only supported for linear products"
502                .to_string(),
503        };
504
505        assert_eq!(
506            invalid_client_order_id.to_string(),
507            "INVALID_CLIENT_ORDER_ID: clOrdId must be alphanumeric"
508        );
509        assert_eq!(
510            unsupported_order_list.to_string(),
511            "UNSUPPORTED_ORDER_LIST: spread instruments are not supported in order lists"
512        );
513        assert_eq!(
514            unsupported_order_type.to_string(),
515            "UNSUPPORTED_ORDER_TYPE: TRAILING_STOP_MARKET"
516        );
517        assert_eq!(
518            unsupported_tp_sl.to_string(),
519            "UNSUPPORTED_TP_SL: TP/SL trigger prices are not supported in demo mode"
520        );
521        assert_eq!(
522            validation_failed.to_string(),
523            "VALIDATION_FAILED: `bbo_side_type` and `bbo_level` are only supported for linear products"
524        );
525        assert_eq!(
526            OrderDeniedReason::StreamReconciling.to_string(),
527            "STREAM_RECONCILING: post-reconnect reconciliation in progress, retry once it completes"
528        );
529    }
530
531    // Drift pin: each variant's rendered message must start with its discriminant code, keeping
532    // the hand-written `#[error]` prefix in sync with the strum-derived `OrderDeniedCode`.
533    #[rstest]
534    fn message_prefix_matches_code() {
535        let usd = || Money::from("100.00 USD");
536        let samples = [
537            OrderDeniedReason::QuantityExceedsMaximum {
538                effective_quantity: Quantity::from("15"),
539                max_quantity: Quantity::from("10"),
540            },
541            OrderDeniedReason::QuantityBelowMinimum {
542                effective_quantity: Quantity::from("1"),
543                min_quantity: Quantity::from("5"),
544            },
545            OrderDeniedReason::NotionalExceedsMaxPerOrder {
546                max_notional: usd(),
547                notional: usd(),
548            },
549            OrderDeniedReason::NotionalExceedsMaximum {
550                max_notional: usd(),
551                notional: usd(),
552            },
553            OrderDeniedReason::NotionalBelowMinimum {
554                min_notional: usd(),
555                notional: usd(),
556            },
557            OrderDeniedReason::NotionalExceedsFreeBalance {
558                free: usd(),
559                notional: usd(),
560            },
561            OrderDeniedReason::CumNotionalExceedsFreeBalance {
562                free: usd(),
563                cum_notional: usd(),
564            },
565            OrderDeniedReason::MarginExceedsFreeBalance {
566                free: usd(),
567                margin_required: usd(),
568            },
569            OrderDeniedReason::CumMarginExceedsFreeBalance {
570                free: usd(),
571                cum_margin: usd(),
572            },
573            OrderDeniedReason::InvalidMaxNotionalPerOrder {
574                instrument_id: InstrumentId::from("AUD/USD.SIM"),
575                value: Decimal::ONE,
576            },
577            OrderDeniedReason::InvalidOrderSide {
578                order_side: OrderSide::NoOrderSide,
579            },
580            OrderDeniedReason::MissingExpireTime,
581            OrderDeniedReason::ExpireTimeInPast {
582                expire_time: "1970-01-01T00:00:00Z".to_string(),
583            },
584            OrderDeniedReason::MissingTriggerType,
585            OrderDeniedReason::MissingTrailingOffset,
586            OrderDeniedReason::MissingTrailingOffsetType,
587            OrderDeniedReason::UnsupportedTrailingOffsetType {
588                offset_type: TrailingOffsetType::Price,
589            },
590            OrderDeniedReason::TrailingStopCalcFailed {
591                detail: "boom".to_string(),
592            },
593            OrderDeniedReason::QuantityConversionFailed {
594                detail: "boom".to_string(),
595            },
596            OrderDeniedReason::InstrumentNotFound {
597                instrument_id: InstrumentId::from("AUD/USD.SIM"),
598            },
599            OrderDeniedReason::PositionNotFound {
600                position_id: PositionId::from("P-1"),
601            },
602            OrderDeniedReason::ReduceOnlyWouldIncreasePosition {
603                position_id: PositionId::from("P-1"),
604            },
605            OrderDeniedReason::OrderListIncomplete {
606                order_list_id: OrderListId::from("OL-1"),
607            },
608            OrderDeniedReason::OrderListDenied {
609                order_list_id: OrderListId::from("OL-1"),
610            },
611            OrderDeniedReason::TradingHalted,
612            OrderDeniedReason::TradingStateReducing {
613                order_side: OrderSide::Buy,
614                instrument_id: InstrumentId::from("AUD/USD.SIM"),
615            },
616            OrderDeniedReason::RateLimitExceeded,
617            OrderDeniedReason::NoExecutionClient {
618                client_id: Some(ClientId::from("SIM")),
619                routing_context: "venue=SIM".to_string(),
620            },
621            OrderDeniedReason::ClientVenueMismatch {
622                client_id: ClientId::from("IB"),
623                order_venue: Venue::from("XCME"),
624                client_venue: Venue::from("IB"),
625            },
626            OrderDeniedReason::SubmitFailed {
627                detail: "boom".to_string(),
628            },
629            OrderDeniedReason::InvalidPositionId {
630                position_id: PositionId::from("P-1"),
631                detail: "boom".to_string(),
632            },
633            OrderDeniedReason::UnsupportedTimeInForce(TimeInForce::Gtd),
634            OrderDeniedReason::InvalidClientOrderId {
635                detail: "boom".to_string(),
636            },
637            OrderDeniedReason::UnsupportedOrderList {
638                detail: "boom".to_string(),
639            },
640            OrderDeniedReason::UnsupportedOrderType {
641                order_type: OrderType::TrailingStopMarket,
642            },
643            OrderDeniedReason::UnsupportedTpSl {
644                detail: "boom".to_string(),
645            },
646            OrderDeniedReason::ValidationFailed {
647                detail: "boom".to_string(),
648            },
649            OrderDeniedReason::StreamReconciling,
650        ];
651
652        for reason in samples {
653            let code = OrderDeniedCode::from(&reason).to_string();
654            assert!(
655                reason.to_string().starts_with(&code),
656                "message `{reason}` must start with code `{code}`"
657            );
658        }
659    }
660
661    #[rstest]
662    fn generated_table_is_in_sync() {
663        let committed = std::fs::read_to_string(DOC_PATH).expect("execution.md should exist");
664        assert!(
665            committed.contains(&generated_block()),
666            "the order-denied-reasons table in docs/concepts/execution.md is stale; regenerate \
667             with `cargo test -p nautilus-model regenerate_order_denied_reasons_doc -- --ignored`"
668        );
669    }
670
671    #[rstest]
672    #[ignore = "rewrites the generated table in execution.md; run after changing OrderDeniedReason variants"]
673    fn regenerate_order_denied_reasons_doc() {
674        let doc = std::fs::read_to_string(DOC_PATH).expect("execution.md should exist");
675        let start = doc.find(BLOCK_BEGIN).expect("begin marker present");
676        let end = doc.find(BLOCK_END).expect("end marker present") + BLOCK_END.len();
677        let updated = format!("{}{}{}", &doc[..start], generated_block(), &doc[end..]);
678        std::fs::write(DOC_PATH, updated).expect("should write execution.md");
679    }
680
681    fn generated_block() -> String {
682        format!("{BLOCK_BEGIN}\n\n{}\n\n{BLOCK_END}", markdown_table())
683    }
684
685    fn markdown_table() -> String {
686        const CODE_HEADER: &str = "Code";
687        const DESC_HEADER: &str = "Description";
688
689        // Sort codes alphabetically so families (UNSUPPORTED_, MISSING_, ...) cluster in the
690        // table; the enum itself stays in declaration order to track the rollout phases.
691        let mut codes: Vec<OrderDeniedCode> = OrderDeniedCode::iter().collect();
692        codes.sort_by_key(ToString::to_string);
693        let rows: Vec<(String, &'static str)> = codes
694            .iter()
695            .map(|code| (format!("`{code}`"), code.description()))
696            .collect();
697        let code_w = rows
698            .iter()
699            .map(|(code, _)| code.len())
700            .max()
701            .unwrap_or(0)
702            .max(CODE_HEADER.len());
703        let desc_w = rows
704            .iter()
705            .map(|(_, desc)| desc.len())
706            .max()
707            .unwrap_or(0)
708            .max(DESC_HEADER.len());
709
710        let mut lines = vec![
711            format!("| {CODE_HEADER:<code_w$} | {DESC_HEADER:<desc_w$} |"),
712            format!("| {:-<code_w$} | {:-<desc_w$} |", "", ""),
713        ];
714
715        for (code, desc) in rows {
716            lines.push(format!("| {code:<code_w$} | {desc:<desc_w$} |"));
717        }
718        lines.join("\n")
719    }
720}