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 the requested reduce-only instruction.
401    #[error("UNSUPPORTED_REDUCE_ONLY")]
402    UnsupportedReduceOnly,
403
404    /// The order's time in force is not supported.
405    #[error("UNSUPPORTED_TIME_IN_FORCE: {0}")]
406    UnsupportedTimeInForce(TimeInForce),
407
408    /// The venue does not support the requested take-profit/stop-loss parameters.
409    #[error("UNSUPPORTED_TP_SL: {detail}")]
410    UnsupportedTpSl {
411        /// The reason the take-profit/stop-loss parameters are unsupported.
412        detail: String,
413    },
414
415    /// The order failed validation before submission.
416    #[error("VALIDATION_FAILED: {detail}")]
417    ValidationFailed {
418        /// The validation failure detail.
419        detail: String,
420    },
421}
422
423impl OrderDeniedCode {
424    /// Returns a one-line description of this denial code.
425    #[must_use]
426    #[rustfmt::skip]
427    pub fn description(&self) -> &'static str {
428        match self {
429            Self::PricePrecisionExceedsMaximum => "The price precision exceeds the instrument maximum.",
430            Self::PriceNotPositive => "The price is not positive.",
431            Self::QuantityPrecisionExceedsMaximum => "The quantity precision exceeds the instrument maximum.",
432            Self::QuantityConversionFailed => "The order quantity could not be converted for risk checks.",
433            Self::QuantityExceedsMaximum => "The effective order quantity exceeds the instrument maximum.",
434            Self::QuantityBelowMinimum => "The effective order quantity is below the instrument minimum.",
435            Self::InvalidMaxNotionalPerOrder => "The configured maximum notional per order is invalid.",
436            Self::MissingExpireTime => "A GTD order is missing its expire time.",
437            Self::ExpireTimeInPast => "The order's expire time is in the past.",
438            Self::MissingTrailingOffsetType => "The order is missing a required trailing offset type.",
439            Self::UnsupportedTrailingOffsetType => "The order's trailing offset type is not supported.",
440            Self::MissingTriggerType => "The order is missing a required trigger type.",
441            Self::MissingTrailingOffset => "The order is missing a required trailing offset.",
442            Self::InstrumentNotFound => "The instrument was not found in the cache.",
443            Self::PositionNotFound => "The position for a reduce-only order was not found.",
444            Self::MarketPriceUnavailable => "No market price is available for the order risk check.",
445            Self::TrailingStopCalculationFailed => "The trailing stop trigger price could not be calculated.",
446            Self::NotionalCalculationFailed => "The order notional value could not be calculated.",
447            Self::NotionalBelowMinimum => "The order notional is below the instrument minimum.",
448            Self::NotionalExceedsMaximum => "The order notional exceeds the instrument maximum.",
449            Self::NotionalExceedsMaxPerOrder => "The order notional exceeds the configured maximum per order.",
450            Self::NotionalExceedsFreeBalance => "The order notional exceeds the account free balance.",
451            Self::InitialMarginCalculationFailed => "The order initial margin could not be calculated.",
452            Self::InitialMarginExceedsFreeBalance => "The order initial margin exceeds the account free balance.",
453            Self::BettingBalanceLockedCalculationFailed => "The balance to lock for the betting order could not be calculated.",
454            Self::CumulativeNotionalExceedsFreeBalance => "The cumulative order notional exceeds the account free balance.",
455            Self::CumulativeInitialMarginCalculationFailed => "The cumulative initial margin could not be calculated.",
456            Self::CumulativeInitialMarginExceedsFreeBalance => "The cumulative initial margin exceeds the account free balance.",
457            Self::ReduceOnlyWouldIncreasePosition => "A reduce-only order would increase the position.",
458            Self::OrderListIncomplete => "The order list is missing orders in the cache.",
459            Self::OrderListDenied => "The order was denied because its order list failed risk checks.",
460            Self::TradingHalted => "Trading is halted; new submissions and modifications are denied.",
461            Self::TradingStateReducing => "Trading is reducing; only eligible reduce-only submissions are permitted.",
462            Self::RateLimitExceeded => "The order submission rate limit was exceeded.",
463            Self::StreamReconciling => "The execution stream is unavailable or recovering; retry after recovery.",
464            Self::NoExecutionClient => "No execution client was found for the routed command.",
465            Self::ClientVenueMismatch => "The execution client does not handle the order venue.",
466            Self::SubmitFailed => "Submitting the order to the execution client failed.",
467            Self::InvalidClientOrderId => "The client order ID is invalid for the venue.",
468            Self::InvalidPositionId => "The supplied position ID is invalid for the order submission.",
469            Self::UnsupportedOrderList => "The venue does not support the requested order list.",
470            Self::UnsupportedOrderType => "The order type is not supported.",
471            Self::UnsupportedReduceOnly => "The execution client or venue does not support the requested reduce-only instruction.",
472            Self::UnsupportedTimeInForce => "The order's time in force is not supported.",
473            Self::UnsupportedTpSl => "The venue does not support the requested take-profit/stop-loss parameters.",
474            Self::ValidationFailed => "The order failed validation before submission.",
475        }
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use rstest::rstest;
482    use strum::IntoEnumIterator;
483
484    use super::*;
485
486    const DOC_PATH: &str = concat!(
487        env!("CARGO_MANIFEST_DIR"),
488        "/../../docs/concepts/execution/index.md"
489    );
490    const BLOCK_BEGIN: &str = "<!-- BEGIN GENERATED: order-denied-reasons -->";
491    const BLOCK_END: &str = "<!-- END GENERATED: order-denied-reasons -->";
492
493    #[rstest]
494    fn renders_subject_led_messages() {
495        let exceeds = OrderDeniedReason::QuantityExceedsMaximum {
496            effective_quantity: Quantity::from("15"),
497            max_quantity: Quantity::from("10"),
498        };
499        let below = OrderDeniedReason::QuantityBelowMinimum {
500            effective_quantity: Quantity::from("1"),
501            min_quantity: Quantity::from("5"),
502        };
503        let notional = OrderDeniedReason::NotionalBelowMinimum {
504            min_notional: Money::from("1.00 USD"),
505            notional: Money::from("0.90 USD"),
506        };
507
508        assert_eq!(
509            exceeds.to_string(),
510            "QUANTITY_EXCEEDS_MAXIMUM: effective=15, max=10"
511        );
512        assert_eq!(
513            below.to_string(),
514            "QUANTITY_BELOW_MINIMUM: effective=1, min=5"
515        );
516        assert_eq!(
517            notional.to_string(),
518            "NOTIONAL_BELOW_MINIMUM: min=1.00 USD, notional=0.90 USD"
519        );
520    }
521
522    #[rstest]
523    fn renders_standardized_risk_messages() {
524        assert_eq!(
525            OrderDeniedReason::PricePrecisionExceedsMaximum {
526                field: OrderPriceField::Price,
527                price: Price::from("1.234"),
528                price_precision: 3,
529                max_precision: 2,
530            }
531            .to_string(),
532            "PRICE_PRECISION_EXCEEDS_MAXIMUM: field=PRICE, price=1.234, precision=3, max_precision=2"
533        );
534        assert_eq!(
535            OrderDeniedReason::PriceNotPositive {
536                field: OrderPriceField::TriggerPrice,
537                price: Price::from("-0.1"),
538            }
539            .to_string(),
540            "PRICE_NOT_POSITIVE: field=TRIGGER_PRICE, price=-0.1"
541        );
542        assert_eq!(
543            OrderDeniedReason::QuantityConversionFailed {
544                detail: "value exceeds MoneyRaw bounds".to_string(),
545            }
546            .to_string(),
547            "QUANTITY_CONVERSION_FAILED: value exceeds MoneyRaw bounds"
548        );
549        assert_eq!(
550            OrderDeniedReason::UnsupportedTrailingOffsetType {
551                offset_type: TrailingOffsetType::PriceTier,
552            }
553            .to_string(),
554            "UNSUPPORTED_TRAILING_OFFSET_TYPE: PRICE_TIER"
555        );
556        assert_eq!(
557            OrderDeniedReason::NotionalCalculationFailed {
558                detail: "value exceeds Money bounds".to_string(),
559            }
560            .to_string(),
561            "NOTIONAL_CALCULATION_FAILED: value exceeds Money bounds"
562        );
563        assert_eq!(
564            OrderDeniedReason::InitialMarginCalculationFailed {
565                detail: "margin model unavailable".to_string(),
566            }
567            .to_string(),
568            "INITIAL_MARGIN_CALCULATION_FAILED: margin model unavailable"
569        );
570        assert_eq!(
571            OrderDeniedReason::NotionalExceedsMaxPerOrder {
572                max_notional: Money::from("10.00 USD"),
573                notional: Money::from("11.00 USD"),
574            }
575            .to_string(),
576            "NOTIONAL_EXCEEDS_MAX_PER_ORDER: max=10.00 USD, notional=11.00 USD"
577        );
578        assert_eq!(
579            OrderDeniedReason::NotionalExceedsMaximum {
580                max_notional: Money::from("12.00 USD"),
581                notional: Money::from("13.00 USD"),
582            }
583            .to_string(),
584            "NOTIONAL_EXCEEDS_MAXIMUM: max=12.00 USD, notional=13.00 USD"
585        );
586        assert_eq!(
587            OrderDeniedReason::NotionalExceedsFreeBalance {
588                free_balance: Money::from("10.00 USD"),
589                notional: Money::from("11.00 USD"),
590            }
591            .to_string(),
592            "NOTIONAL_EXCEEDS_FREE_BALANCE: free=10.00 USD, notional=11.00 USD"
593        );
594        assert_eq!(
595            OrderDeniedReason::CumulativeNotionalExceedsFreeBalance {
596                free_balance: Money::from("10.00 USD"),
597                cumulative_notional: Money::from("12.00 USD"),
598            }
599            .to_string(),
600            "CUMULATIVE_NOTIONAL_EXCEEDS_FREE_BALANCE: free=10.00 USD, notional=12.00 USD"
601        );
602        assert_eq!(
603            OrderDeniedReason::InitialMarginExceedsFreeBalance {
604                free_balance: Money::from("10.00 USD"),
605                initial_margin: Money::from("13.00 USD"),
606            }
607            .to_string(),
608            "INITIAL_MARGIN_EXCEEDS_FREE_BALANCE: free=10.00 USD, margin=13.00 USD"
609        );
610        assert_eq!(
611            OrderDeniedReason::CumulativeInitialMarginExceedsFreeBalance {
612                free_balance: Money::from("10.00 USD"),
613                cumulative_initial_margin: Money::from("14.00 USD"),
614            }
615            .to_string(),
616            "CUMULATIVE_INITIAL_MARGIN_EXCEEDS_FREE_BALANCE: free=10.00 USD, margin=14.00 USD"
617        );
618        assert_eq!(
619            OrderDeniedReason::CumulativeInitialMarginCalculationFailed {
620                detail: "total exceeds Money bounds".to_string(),
621            }
622            .to_string(),
623            "CUMULATIVE_INITIAL_MARGIN_CALCULATION_FAILED: total exceeds Money bounds"
624        );
625        assert_eq!(
626            OrderDeniedReason::BettingBalanceLockedCalculationFailed {
627                detail: "invalid liability".to_string(),
628            }
629            .to_string(),
630            "BETTING_BALANCE_LOCKED_CALCULATION_FAILED: invalid liability"
631        );
632        assert_eq!(
633            OrderDeniedReason::TrailingStopCalculationFailed {
634                detail: "missing market price".to_string(),
635            }
636            .to_string(),
637            "TRAILING_STOP_CALCULATION_FAILED: missing market price"
638        );
639    }
640
641    #[rstest]
642    fn renders_lifecycle_and_state_messages() {
643        let not_found = OrderDeniedReason::InstrumentNotFound {
644            instrument_id: InstrumentId::from("AUD/USD.SIM"),
645        };
646        let reducing = OrderDeniedReason::TradingStateReducing {
647            order_side: OrderSide::Buy,
648            instrument_id: InstrumentId::from("AUD/USD.SIM"),
649        };
650
651        assert_eq!(not_found.to_string(), "INSTRUMENT_NOT_FOUND: AUD/USD.SIM");
652        assert_eq!(
653            OrderDeniedReason::ExpireTimeInPast {
654                expire_time: "1970-01-01T00:00:00Z".to_string(),
655            }
656            .to_string(),
657            "EXPIRE_TIME_IN_PAST: 1970-01-01T00:00:00Z"
658        );
659        assert_eq!(
660            OrderDeniedReason::PositionNotFound {
661                position_id: PositionId::from("P-1"),
662            }
663            .to_string(),
664            "POSITION_NOT_FOUND: P-1"
665        );
666        assert_eq!(
667            OrderDeniedReason::ReduceOnlyWouldIncreasePosition {
668                position_id: PositionId::from("P-2"),
669            }
670            .to_string(),
671            "REDUCE_ONLY_WOULD_INCREASE_POSITION: P-2"
672        );
673        assert_eq!(
674            OrderDeniedReason::OrderListIncomplete {
675                order_list_id: OrderListId::from("OL-1"),
676            }
677            .to_string(),
678            "ORDER_LIST_INCOMPLETE: OL-1"
679        );
680        assert_eq!(
681            OrderDeniedReason::OrderListDenied {
682                order_list_id: OrderListId::from("OL-2"),
683            }
684            .to_string(),
685            "ORDER_LIST_DENIED: OL-2"
686        );
687        assert_eq!(
688            OrderDeniedReason::TradingHalted.to_string(),
689            "TRADING_HALTED"
690        );
691        assert_eq!(
692            OrderDeniedReason::RateLimitExceeded.to_string(),
693            "RATE_LIMIT_EXCEEDED"
694        );
695        assert_eq!(
696            reducing.to_string(),
697            "TRADING_STATE_REDUCING: side=BUY, instrument_id=AUD/USD.SIM"
698        );
699    }
700
701    #[rstest]
702    fn renders_routing_messages() {
703        let missing_client = OrderDeniedReason::NoExecutionClient {
704            client_id: Some(ClientId::from("SIM")),
705            routing_context: "venue=SIM".to_string(),
706        };
707        let mismatch = OrderDeniedReason::ClientVenueMismatch {
708            client_id: ClientId::from("IB"),
709            order_venue: Venue::from("XCME"),
710            client_venue: Venue::from("IB"),
711        };
712        let submit_failed = OrderDeniedReason::SubmitFailed {
713            detail: "transport closed".to_string(),
714        };
715        let invalid_position_id = OrderDeniedReason::InvalidPositionId {
716            position_id: PositionId::from("P-1"),
717            detail: "not valid for NETTING OMS".to_string(),
718        };
719
720        assert_eq!(
721            missing_client.to_string(),
722            "NO_EXECUTION_CLIENT: client_id=SIM, venue=SIM"
723        );
724        assert_eq!(
725            mismatch.to_string(),
726            "CLIENT_VENUE_MISMATCH: client_id=IB, order_venue=XCME, client_venue=IB"
727        );
728        assert_eq!(submit_failed.to_string(), "SUBMIT_FAILED: transport closed");
729        assert_eq!(
730            invalid_position_id.to_string(),
731            "INVALID_POSITION_ID: P-1; not valid for NETTING OMS"
732        );
733    }
734
735    #[rstest]
736    fn renders_condition_led_message() {
737        let reason = OrderDeniedReason::UnsupportedTimeInForce(TimeInForce::Gtd);
738        assert_eq!(reason.to_string(), "UNSUPPORTED_TIME_IN_FORCE: GTD");
739    }
740
741    #[rstest]
742    fn renders_adapter_messages() {
743        let invalid_client_order_id = OrderDeniedReason::InvalidClientOrderId {
744            detail: "clOrdId must be alphanumeric".to_string(),
745        };
746        let unsupported_order_list = OrderDeniedReason::UnsupportedOrderList {
747            detail: "spread instruments are not supported in order lists".to_string(),
748        };
749        let unsupported_order_type = OrderDeniedReason::UnsupportedOrderType {
750            order_type: OrderType::TrailingStopMarket,
751        };
752        let unsupported_reduce_only = OrderDeniedReason::UnsupportedReduceOnly;
753        let unsupported_tp_sl = OrderDeniedReason::UnsupportedTpSl {
754            detail: "TP/SL trigger prices are not supported in demo mode".to_string(),
755        };
756        let validation_failed = OrderDeniedReason::ValidationFailed {
757            detail: "`bbo_side_type` and `bbo_level` are only supported for linear products"
758                .to_string(),
759        };
760
761        assert_eq!(
762            invalid_client_order_id.to_string(),
763            "INVALID_CLIENT_ORDER_ID: clOrdId must be alphanumeric"
764        );
765        assert_eq!(
766            unsupported_order_list.to_string(),
767            "UNSUPPORTED_ORDER_LIST: spread instruments are not supported in order lists"
768        );
769        assert_eq!(
770            unsupported_order_type.to_string(),
771            "UNSUPPORTED_ORDER_TYPE: TRAILING_STOP_MARKET"
772        );
773        assert_eq!(
774            unsupported_reduce_only.to_string(),
775            "UNSUPPORTED_REDUCE_ONLY"
776        );
777        assert_eq!(
778            unsupported_tp_sl.to_string(),
779            "UNSUPPORTED_TP_SL: TP/SL trigger prices are not supported in demo mode"
780        );
781        assert_eq!(
782            validation_failed.to_string(),
783            "VALIDATION_FAILED: `bbo_side_type` and `bbo_level` are only supported for linear products"
784        );
785        assert_eq!(
786            OrderDeniedReason::StreamReconciling.to_string(),
787            "STREAM_RECONCILING: execution stream unavailable or recovering, retry after recovery"
788        );
789    }
790
791    // Drift pin: each variant's rendered message must start with its discriminant code, keeping
792    // the hand-written `#[error]` prefix in sync with the strum-derived `OrderDeniedCode`.
793    #[rstest]
794    fn message_prefix_matches_code() {
795        let usd = || Money::from("100.00 USD");
796        let samples = [
797            OrderDeniedReason::PricePrecisionExceedsMaximum {
798                field: OrderPriceField::Price,
799                price: Price::from("1.00"),
800                price_precision: 2,
801                max_precision: 1,
802            },
803            OrderDeniedReason::PriceNotPositive {
804                field: OrderPriceField::TriggerPrice,
805                price: Price::from("0.00"),
806            },
807            OrderDeniedReason::QuantityPrecisionExceedsMaximum {
808                quantity: Quantity::from("1.00"),
809                quantity_precision: 2,
810                max_precision: 1,
811            },
812            OrderDeniedReason::QuantityConversionFailed {
813                detail: "boom".to_string(),
814            },
815            OrderDeniedReason::QuantityExceedsMaximum {
816                effective_quantity: Quantity::from("15"),
817                max_quantity: Quantity::from("10"),
818            },
819            OrderDeniedReason::QuantityBelowMinimum {
820                effective_quantity: Quantity::from("1"),
821                min_quantity: Quantity::from("5"),
822            },
823            OrderDeniedReason::InvalidMaxNotionalPerOrder {
824                instrument_id: InstrumentId::from("AUD/USD.SIM"),
825                value: Decimal::ONE,
826            },
827            OrderDeniedReason::MissingExpireTime,
828            OrderDeniedReason::ExpireTimeInPast {
829                expire_time: "1970-01-01T00:00:00Z".to_string(),
830            },
831            OrderDeniedReason::MissingTrailingOffsetType,
832            OrderDeniedReason::UnsupportedTrailingOffsetType {
833                offset_type: TrailingOffsetType::Price,
834            },
835            OrderDeniedReason::MissingTriggerType,
836            OrderDeniedReason::MissingTrailingOffset,
837            OrderDeniedReason::InstrumentNotFound {
838                instrument_id: InstrumentId::from("AUD/USD.SIM"),
839            },
840            OrderDeniedReason::PositionNotFound {
841                position_id: PositionId::from("P-1"),
842            },
843            OrderDeniedReason::MarketPriceUnavailable {
844                order_type: OrderType::Market,
845                instrument_id: InstrumentId::from("AUD/USD.SIM"),
846            },
847            OrderDeniedReason::TrailingStopCalculationFailed {
848                detail: "boom".to_string(),
849            },
850            OrderDeniedReason::NotionalCalculationFailed {
851                detail: "boom".to_string(),
852            },
853            OrderDeniedReason::NotionalBelowMinimum {
854                min_notional: usd(),
855                notional: usd(),
856            },
857            OrderDeniedReason::NotionalExceedsMaximum {
858                max_notional: usd(),
859                notional: usd(),
860            },
861            OrderDeniedReason::NotionalExceedsMaxPerOrder {
862                max_notional: usd(),
863                notional: usd(),
864            },
865            OrderDeniedReason::NotionalExceedsFreeBalance {
866                free_balance: usd(),
867                notional: usd(),
868            },
869            OrderDeniedReason::InitialMarginCalculationFailed {
870                detail: "boom".to_string(),
871            },
872            OrderDeniedReason::InitialMarginExceedsFreeBalance {
873                free_balance: usd(),
874                initial_margin: usd(),
875            },
876            OrderDeniedReason::BettingBalanceLockedCalculationFailed {
877                detail: "boom".to_string(),
878            },
879            OrderDeniedReason::CumulativeNotionalExceedsFreeBalance {
880                free_balance: usd(),
881                cumulative_notional: usd(),
882            },
883            OrderDeniedReason::CumulativeInitialMarginCalculationFailed {
884                detail: "boom".to_string(),
885            },
886            OrderDeniedReason::CumulativeInitialMarginExceedsFreeBalance {
887                free_balance: usd(),
888                cumulative_initial_margin: usd(),
889            },
890            OrderDeniedReason::ReduceOnlyWouldIncreasePosition {
891                position_id: PositionId::from("P-1"),
892            },
893            OrderDeniedReason::OrderListIncomplete {
894                order_list_id: OrderListId::from("OL-1"),
895            },
896            OrderDeniedReason::OrderListDenied {
897                order_list_id: OrderListId::from("OL-1"),
898            },
899            OrderDeniedReason::TradingHalted,
900            OrderDeniedReason::TradingStateReducing {
901                order_side: OrderSide::Buy,
902                instrument_id: InstrumentId::from("AUD/USD.SIM"),
903            },
904            OrderDeniedReason::RateLimitExceeded,
905            OrderDeniedReason::StreamReconciling,
906            OrderDeniedReason::NoExecutionClient {
907                client_id: Some(ClientId::from("SIM")),
908                routing_context: "venue=SIM".to_string(),
909            },
910            OrderDeniedReason::ClientVenueMismatch {
911                client_id: ClientId::from("IB"),
912                order_venue: Venue::from("XCME"),
913                client_venue: Venue::from("IB"),
914            },
915            OrderDeniedReason::SubmitFailed {
916                detail: "boom".to_string(),
917            },
918            OrderDeniedReason::InvalidClientOrderId {
919                detail: "boom".to_string(),
920            },
921            OrderDeniedReason::InvalidPositionId {
922                position_id: PositionId::from("P-1"),
923                detail: "boom".to_string(),
924            },
925            OrderDeniedReason::UnsupportedOrderList {
926                detail: "boom".to_string(),
927            },
928            OrderDeniedReason::UnsupportedOrderType {
929                order_type: OrderType::TrailingStopMarket,
930            },
931            OrderDeniedReason::UnsupportedReduceOnly,
932            OrderDeniedReason::UnsupportedTimeInForce(TimeInForce::Gtd),
933            OrderDeniedReason::UnsupportedTpSl {
934                detail: "boom".to_string(),
935            },
936            OrderDeniedReason::ValidationFailed {
937                detail: "boom".to_string(),
938            },
939        ];
940
941        assert_eq!(samples.len(), OrderDeniedCode::iter().count());
942        for reason in samples {
943            let code = OrderDeniedCode::from(&reason).to_string();
944            assert!(
945                reason.to_string().starts_with(&code),
946                "message `{reason}` must start with code `{code}`"
947            );
948        }
949    }
950
951    #[rstest]
952    fn generated_table_is_in_sync() {
953        let committed = std::fs::read_to_string(DOC_PATH).expect("execution/index.md should exist");
954        assert!(
955            committed.contains(&generated_block()),
956            "the order-denied-reasons table in docs/concepts/execution/index.md is stale; regenerate \
957             with `cargo test -p nautilus-model regenerate_order_denied_reasons_doc -- --ignored`"
958        );
959    }
960
961    #[rstest]
962    #[ignore = "rewrites the generated table in execution/index.md; run after changing OrderDeniedReason variants"]
963    fn regenerate_order_denied_reasons_doc() {
964        let doc = std::fs::read_to_string(DOC_PATH).expect("execution/index.md should exist");
965        let start = doc.find(BLOCK_BEGIN).expect("begin marker present");
966        let end = doc.find(BLOCK_END).expect("end marker present") + BLOCK_END.len();
967        let updated = format!("{}{}{}", &doc[..start], generated_block(), &doc[end..]);
968        std::fs::write(DOC_PATH, updated).expect("should write execution/index.md");
969    }
970
971    fn generated_block() -> String {
972        format!("{BLOCK_BEGIN}\n\n{}\n\n{BLOCK_END}", markdown_table())
973    }
974
975    fn markdown_table() -> String {
976        const CODE_HEADER: &str = "Code";
977        const DESC_HEADER: &str = "Description";
978
979        // Declaration order carries the public table's abstraction hierarchy
980        let rows: Vec<(String, &'static str)> = OrderDeniedCode::iter()
981            .map(|code| (format!("`{code}`"), code.description()))
982            .collect();
983        // Width counts characters, matching the padding applied by `format!` and the
984        // column width the Markdown table hook normalizes to.
985        let code_w = rows
986            .iter()
987            .map(|(code, _)| code.chars().count())
988            .max()
989            .unwrap_or(0)
990            .max(CODE_HEADER.chars().count());
991        let desc_w = rows
992            .iter()
993            .map(|(_, desc)| desc.chars().count())
994            .max()
995            .unwrap_or(0)
996            .max(DESC_HEADER.chars().count());
997
998        let mut lines = vec![
999            format!("| {CODE_HEADER:<code_w$} | {DESC_HEADER:<desc_w$} |"),
1000            format!("| {:-<code_w$} | {:-<desc_w$} |", "", ""),
1001        ];
1002
1003        for (code, desc) in rows {
1004            lines.push(format!("| {code:<code_w$} | {desc:<desc_w$} |"));
1005        }
1006        lines.join("\n")
1007    }
1008}