1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Display)]
37#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
38pub enum OrderPriceField {
39 Price,
41 TriggerPrice,
43}
44
45#[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 #[error(
72 "PRICE_PRECISION_EXCEEDS_MAXIMUM: field={field}, price={price}, precision={price_precision}, max_precision={max_precision}"
73 )]
74 PricePrecisionExceedsMaximum {
75 field: OrderPriceField,
77 price: Price,
79 price_precision: u8,
81 max_precision: u8,
83 },
84
85 #[error("PRICE_NOT_POSITIVE: field={field}, price={price}")]
87 PriceNotPositive {
88 field: OrderPriceField,
90 price: Price,
92 },
93
94 #[error(
96 "QUANTITY_PRECISION_EXCEEDS_MAXIMUM: quantity={quantity}, precision={quantity_precision}, max_precision={max_precision}"
97 )]
98 QuantityPrecisionExceedsMaximum {
99 quantity: Quantity,
101 quantity_precision: u8,
103 max_precision: u8,
105 },
106
107 #[error("QUANTITY_CONVERSION_FAILED: {detail}")]
109 QuantityConversionFailed {
110 detail: String,
112 },
113
114 #[error("QUANTITY_EXCEEDS_MAXIMUM: effective={effective_quantity}, max={max_quantity}")]
116 QuantityExceedsMaximum {
117 effective_quantity: Quantity,
119 max_quantity: Quantity,
121 },
122
123 #[error("QUANTITY_BELOW_MINIMUM: effective={effective_quantity}, min={min_quantity}")]
125 QuantityBelowMinimum {
126 effective_quantity: Quantity,
128 min_quantity: Quantity,
130 },
131
132 #[error("INVALID_MAX_NOTIONAL_PER_ORDER: instrument_id={instrument_id}, value={value}")]
134 InvalidMaxNotionalPerOrder {
135 instrument_id: InstrumentId,
137 value: Decimal,
139 },
140
141 #[error("MISSING_EXPIRE_TIME")]
143 MissingExpireTime,
144
145 #[error("EXPIRE_TIME_IN_PAST: {expire_time}")]
147 ExpireTimeInPast {
148 expire_time: String,
150 },
151
152 #[error("MISSING_TRAILING_OFFSET_TYPE")]
154 MissingTrailingOffsetType,
155
156 #[error("UNSUPPORTED_TRAILING_OFFSET_TYPE: {offset_type}")]
158 UnsupportedTrailingOffsetType {
159 offset_type: TrailingOffsetType,
161 },
162
163 #[error("MISSING_TRIGGER_TYPE")]
165 MissingTriggerType,
166
167 #[error("MISSING_TRAILING_OFFSET")]
169 MissingTrailingOffset,
170
171 #[error("INSTRUMENT_NOT_FOUND: {instrument_id}")]
173 InstrumentNotFound {
174 instrument_id: InstrumentId,
176 },
177
178 #[error("POSITION_NOT_FOUND: {position_id}")]
180 PositionNotFound {
181 position_id: PositionId,
183 },
184
185 #[error("MARKET_PRICE_UNAVAILABLE: order_type={order_type}, instrument_id={instrument_id}")]
187 MarketPriceUnavailable {
188 order_type: OrderType,
190 instrument_id: InstrumentId,
192 },
193
194 #[error("TRAILING_STOP_CALCULATION_FAILED: {detail}")]
196 TrailingStopCalculationFailed {
197 detail: String,
199 },
200
201 #[error("NOTIONAL_CALCULATION_FAILED: {detail}")]
203 NotionalCalculationFailed {
204 detail: String,
206 },
207
208 #[error("NOTIONAL_BELOW_MINIMUM: min={min_notional}, notional={notional}")]
210 NotionalBelowMinimum {
211 min_notional: Money,
213 notional: Money,
215 },
216
217 #[error("NOTIONAL_EXCEEDS_MAXIMUM: max={max_notional}, notional={notional}")]
219 NotionalExceedsMaximum {
220 max_notional: Money,
222 notional: Money,
224 },
225
226 #[error("NOTIONAL_EXCEEDS_MAX_PER_ORDER: max={max_notional}, notional={notional}")]
228 NotionalExceedsMaxPerOrder {
229 max_notional: Money,
231 notional: Money,
233 },
234
235 #[error("NOTIONAL_EXCEEDS_FREE_BALANCE: free={free_balance}, notional={notional}")]
237 NotionalExceedsFreeBalance {
238 free_balance: Money,
240 notional: Money,
242 },
243
244 #[error("INITIAL_MARGIN_CALCULATION_FAILED: {detail}")]
246 InitialMarginCalculationFailed {
247 detail: String,
249 },
250
251 #[error("INITIAL_MARGIN_EXCEEDS_FREE_BALANCE: free={free_balance}, margin={initial_margin}")]
253 InitialMarginExceedsFreeBalance {
254 free_balance: Money,
256 initial_margin: Money,
258 },
259
260 #[error("BETTING_BALANCE_LOCKED_CALCULATION_FAILED: {detail}")]
262 BettingBalanceLockedCalculationFailed {
263 detail: String,
265 },
266
267 #[error(
269 "CUMULATIVE_NOTIONAL_EXCEEDS_FREE_BALANCE: free={free_balance}, notional={cumulative_notional}"
270 )]
271 CumulativeNotionalExceedsFreeBalance {
272 free_balance: Money,
274 cumulative_notional: Money,
276 },
277
278 #[error("CUMULATIVE_INITIAL_MARGIN_CALCULATION_FAILED: {detail}")]
280 CumulativeInitialMarginCalculationFailed {
281 detail: String,
283 },
284
285 #[error(
287 "CUMULATIVE_INITIAL_MARGIN_EXCEEDS_FREE_BALANCE: free={free_balance}, margin={cumulative_initial_margin}"
288 )]
289 CumulativeInitialMarginExceedsFreeBalance {
290 free_balance: Money,
292 cumulative_initial_margin: Money,
294 },
295
296 #[error("REDUCE_ONLY_WOULD_INCREASE_POSITION: {position_id}")]
298 ReduceOnlyWouldIncreasePosition {
299 position_id: PositionId,
301 },
302
303 #[error("ORDER_LIST_INCOMPLETE: {order_list_id}")]
305 OrderListIncomplete {
306 order_list_id: OrderListId,
308 },
309
310 #[error("ORDER_LIST_DENIED: {order_list_id}")]
312 OrderListDenied {
313 order_list_id: OrderListId,
315 },
316
317 #[error("TRADING_HALTED")]
319 TradingHalted,
320
321 #[error("TRADING_STATE_REDUCING: side={order_side}, instrument_id={instrument_id}")]
323 TradingStateReducing {
324 order_side: OrderSide,
326 instrument_id: InstrumentId,
328 },
329
330 #[error("RATE_LIMIT_EXCEEDED")]
332 RateLimitExceeded,
333
334 #[error("STREAM_RECONCILING: execution stream unavailable or recovering, retry after recovery")]
336 StreamReconciling,
337
338 #[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 client_id: Option<ClientId>,
346 routing_context: String,
348 },
349
350 #[error(
352 "CLIENT_VENUE_MISMATCH: client_id={client_id}, order_venue={order_venue}, client_venue={client_venue}"
353 )]
354 ClientVenueMismatch {
355 client_id: ClientId,
357 order_venue: Venue,
359 client_venue: Venue,
361 },
362
363 #[error("SUBMIT_FAILED: {detail}")]
365 SubmitFailed {
366 detail: String,
368 },
369
370 #[error("INVALID_CLIENT_ORDER_ID: {detail}")]
372 InvalidClientOrderId {
373 detail: String,
375 },
376
377 #[error("INVALID_POSITION_ID: {position_id}; {detail}")]
379 InvalidPositionId {
380 position_id: PositionId,
382 detail: String,
384 },
385
386 #[error("UNSUPPORTED_ORDER_LIST: {detail}")]
388 UnsupportedOrderList {
389 detail: String,
391 },
392
393 #[error("UNSUPPORTED_ORDER_TYPE: {order_type}")]
395 UnsupportedOrderType {
396 order_type: OrderType,
398 },
399
400 #[error(
402 "UNSUPPORTED_QUOTE_QUANTITY{}{}",
403 if .detail.as_deref().unwrap_or_default().is_empty() { "" } else { ": " },
404 .detail.as_deref().unwrap_or_default()
405 )]
406 UnsupportedQuoteQuantity {
407 detail: Option<String>,
409 },
410
411 #[error(
413 "UNSUPPORTED_POST_ONLY{}{}",
414 if .detail.as_deref().unwrap_or_default().is_empty() { "" } else { ": " },
415 .detail.as_deref().unwrap_or_default()
416 )]
417 UnsupportedPostOnly {
418 detail: Option<String>,
420 },
421
422 #[error("UNSUPPORTED_REDUCE_ONLY")]
424 UnsupportedReduceOnly,
425
426 #[error("UNSUPPORTED_TIME_IN_FORCE: {0}")]
428 UnsupportedTimeInForce(TimeInForce),
429
430 #[error("UNSUPPORTED_TP_SL: {detail}")]
432 UnsupportedTpSl {
433 detail: String,
435 },
436
437 #[error("VALIDATION_FAILED: {detail}")]
439 ValidationFailed {
440 detail: String,
442 },
443}
444
445impl OrderDeniedReason {
446 #[must_use]
448 pub const fn unsupported_quote_quantity() -> Self {
449 Self::UnsupportedQuoteQuantity { detail: None }
450 }
451
452 #[must_use]
454 pub fn unsupported_quote_quantity_with_detail(detail: impl Into<String>) -> Self {
455 Self::UnsupportedQuoteQuantity {
456 detail: Some(detail.into()),
457 }
458 }
459
460 #[must_use]
462 pub const fn unsupported_post_only() -> Self {
463 Self::UnsupportedPostOnly { detail: None }
464 }
465
466 #[must_use]
468 pub fn unsupported_post_only_with_detail(detail: impl Into<String>) -> Self {
469 Self::UnsupportedPostOnly {
470 detail: Some(detail.into()),
471 }
472 }
473}
474
475impl OrderDeniedCode {
476 #[must_use]
478 #[rustfmt::skip]
479 pub fn description(&self) -> &'static str {
480 match self {
481 Self::PricePrecisionExceedsMaximum => "The price precision exceeds the instrument maximum.",
482 Self::PriceNotPositive => "The price is not positive.",
483 Self::QuantityPrecisionExceedsMaximum => "The quantity precision exceeds the instrument maximum.",
484 Self::QuantityConversionFailed => "The order quantity could not be converted for risk checks.",
485 Self::QuantityExceedsMaximum => "The effective order quantity exceeds the instrument maximum.",
486 Self::QuantityBelowMinimum => "The effective order quantity is below the instrument minimum.",
487 Self::InvalidMaxNotionalPerOrder => "The configured maximum notional per order is invalid.",
488 Self::MissingExpireTime => "A GTD order is missing its expire time.",
489 Self::ExpireTimeInPast => "The order's expire time is in the past.",
490 Self::MissingTrailingOffsetType => "The order is missing a required trailing offset type.",
491 Self::UnsupportedTrailingOffsetType => "The order's trailing offset type is not supported.",
492 Self::MissingTriggerType => "The order is missing a required trigger type.",
493 Self::MissingTrailingOffset => "The order is missing a required trailing offset.",
494 Self::InstrumentNotFound => "The instrument was not found in the cache.",
495 Self::PositionNotFound => "The position for a reduce-only order was not found.",
496 Self::MarketPriceUnavailable => "No market price is available for the order risk check.",
497 Self::TrailingStopCalculationFailed => "The trailing stop trigger price could not be calculated.",
498 Self::NotionalCalculationFailed => "The order notional value could not be calculated.",
499 Self::NotionalBelowMinimum => "The order notional is below the instrument minimum.",
500 Self::NotionalExceedsMaximum => "The order notional exceeds the instrument maximum.",
501 Self::NotionalExceedsMaxPerOrder => "The order notional exceeds the configured maximum per order.",
502 Self::NotionalExceedsFreeBalance => "The order notional exceeds the account free balance.",
503 Self::InitialMarginCalculationFailed => "The order initial margin could not be calculated.",
504 Self::InitialMarginExceedsFreeBalance => "The order initial margin exceeds the account free balance.",
505 Self::BettingBalanceLockedCalculationFailed => "The balance to lock for the betting order could not be calculated.",
506 Self::CumulativeNotionalExceedsFreeBalance => "The cumulative order notional exceeds the account free balance.",
507 Self::CumulativeInitialMarginCalculationFailed => "The cumulative initial margin could not be calculated.",
508 Self::CumulativeInitialMarginExceedsFreeBalance => "The cumulative initial margin exceeds the account free balance.",
509 Self::ReduceOnlyWouldIncreasePosition => "A reduce-only order would increase the position.",
510 Self::OrderListIncomplete => "The order list is missing orders in the cache.",
511 Self::OrderListDenied => "The order was denied because its order list failed risk checks.",
512 Self::TradingHalted => "Trading is halted; new submissions and modifications are denied.",
513 Self::TradingStateReducing => "Trading is reducing; only eligible reduce-only submissions are permitted.",
514 Self::RateLimitExceeded => "The order submission rate limit was exceeded.",
515 Self::StreamReconciling => "The execution stream is unavailable or recovering; retry after recovery.",
516 Self::NoExecutionClient => "No execution client was found for the routed command.",
517 Self::ClientVenueMismatch => "The execution client does not handle the order venue.",
518 Self::SubmitFailed => "Submitting the order to the execution client failed.",
519 Self::InvalidClientOrderId => "The client order ID is invalid for the venue.",
520 Self::InvalidPositionId => "The supplied position ID is invalid for the order submission.",
521 Self::UnsupportedOrderList => "The venue does not support the requested order list.",
522 Self::UnsupportedOrderType => "The order type is not supported.",
523 Self::UnsupportedQuoteQuantity => "The execution client or venue does not support quote quantity for the order.",
524 Self::UnsupportedPostOnly => "The execution client or venue does not support the requested post-only instruction.",
525 Self::UnsupportedReduceOnly => "The execution client or venue does not support the requested reduce-only instruction.",
526 Self::UnsupportedTimeInForce => "The order's time in force is not supported.",
527 Self::UnsupportedTpSl => "The venue does not support the requested take-profit/stop-loss parameters.",
528 Self::ValidationFailed => "The order failed validation before submission.",
529 }
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use rstest::rstest;
536 use strum::IntoEnumIterator;
537
538 use super::*;
539
540 const DOC_PATH: &str = concat!(
541 env!("CARGO_MANIFEST_DIR"),
542 "/../../docs/concepts/execution/index.md"
543 );
544 const BLOCK_BEGIN: &str = "<!-- BEGIN GENERATED: order-denied-reasons -->";
545 const BLOCK_END: &str = "<!-- END GENERATED: order-denied-reasons -->";
546
547 #[rstest]
548 fn renders_subject_led_messages() {
549 let exceeds = OrderDeniedReason::QuantityExceedsMaximum {
550 effective_quantity: Quantity::from("15"),
551 max_quantity: Quantity::from("10"),
552 };
553 let below = OrderDeniedReason::QuantityBelowMinimum {
554 effective_quantity: Quantity::from("1"),
555 min_quantity: Quantity::from("5"),
556 };
557 let notional = OrderDeniedReason::NotionalBelowMinimum {
558 min_notional: Money::from("1.00 USD"),
559 notional: Money::from("0.90 USD"),
560 };
561
562 assert_eq!(
563 exceeds.to_string(),
564 "QUANTITY_EXCEEDS_MAXIMUM: effective=15, max=10"
565 );
566 assert_eq!(
567 below.to_string(),
568 "QUANTITY_BELOW_MINIMUM: effective=1, min=5"
569 );
570 assert_eq!(
571 notional.to_string(),
572 "NOTIONAL_BELOW_MINIMUM: min=1.00 USD, notional=0.90 USD"
573 );
574 }
575
576 #[rstest]
577 fn renders_standardized_risk_messages() {
578 assert_eq!(
579 OrderDeniedReason::PricePrecisionExceedsMaximum {
580 field: OrderPriceField::Price,
581 price: Price::from("1.234"),
582 price_precision: 3,
583 max_precision: 2,
584 }
585 .to_string(),
586 "PRICE_PRECISION_EXCEEDS_MAXIMUM: field=PRICE, price=1.234, precision=3, max_precision=2"
587 );
588 assert_eq!(
589 OrderDeniedReason::PriceNotPositive {
590 field: OrderPriceField::TriggerPrice,
591 price: Price::from("-0.1"),
592 }
593 .to_string(),
594 "PRICE_NOT_POSITIVE: field=TRIGGER_PRICE, price=-0.1"
595 );
596 assert_eq!(
597 OrderDeniedReason::QuantityConversionFailed {
598 detail: "value exceeds MoneyRaw bounds".to_string(),
599 }
600 .to_string(),
601 "QUANTITY_CONVERSION_FAILED: value exceeds MoneyRaw bounds"
602 );
603 assert_eq!(
604 OrderDeniedReason::UnsupportedTrailingOffsetType {
605 offset_type: TrailingOffsetType::PriceTier,
606 }
607 .to_string(),
608 "UNSUPPORTED_TRAILING_OFFSET_TYPE: PRICE_TIER"
609 );
610 assert_eq!(
611 OrderDeniedReason::NotionalCalculationFailed {
612 detail: "value exceeds Money bounds".to_string(),
613 }
614 .to_string(),
615 "NOTIONAL_CALCULATION_FAILED: value exceeds Money bounds"
616 );
617 assert_eq!(
618 OrderDeniedReason::InitialMarginCalculationFailed {
619 detail: "margin model unavailable".to_string(),
620 }
621 .to_string(),
622 "INITIAL_MARGIN_CALCULATION_FAILED: margin model unavailable"
623 );
624 assert_eq!(
625 OrderDeniedReason::NotionalExceedsMaxPerOrder {
626 max_notional: Money::from("10.00 USD"),
627 notional: Money::from("11.00 USD"),
628 }
629 .to_string(),
630 "NOTIONAL_EXCEEDS_MAX_PER_ORDER: max=10.00 USD, notional=11.00 USD"
631 );
632 assert_eq!(
633 OrderDeniedReason::NotionalExceedsMaximum {
634 max_notional: Money::from("12.00 USD"),
635 notional: Money::from("13.00 USD"),
636 }
637 .to_string(),
638 "NOTIONAL_EXCEEDS_MAXIMUM: max=12.00 USD, notional=13.00 USD"
639 );
640 assert_eq!(
641 OrderDeniedReason::NotionalExceedsFreeBalance {
642 free_balance: Money::from("10.00 USD"),
643 notional: Money::from("11.00 USD"),
644 }
645 .to_string(),
646 "NOTIONAL_EXCEEDS_FREE_BALANCE: free=10.00 USD, notional=11.00 USD"
647 );
648 assert_eq!(
649 OrderDeniedReason::CumulativeNotionalExceedsFreeBalance {
650 free_balance: Money::from("10.00 USD"),
651 cumulative_notional: Money::from("12.00 USD"),
652 }
653 .to_string(),
654 "CUMULATIVE_NOTIONAL_EXCEEDS_FREE_BALANCE: free=10.00 USD, notional=12.00 USD"
655 );
656 assert_eq!(
657 OrderDeniedReason::InitialMarginExceedsFreeBalance {
658 free_balance: Money::from("10.00 USD"),
659 initial_margin: Money::from("13.00 USD"),
660 }
661 .to_string(),
662 "INITIAL_MARGIN_EXCEEDS_FREE_BALANCE: free=10.00 USD, margin=13.00 USD"
663 );
664 assert_eq!(
665 OrderDeniedReason::CumulativeInitialMarginExceedsFreeBalance {
666 free_balance: Money::from("10.00 USD"),
667 cumulative_initial_margin: Money::from("14.00 USD"),
668 }
669 .to_string(),
670 "CUMULATIVE_INITIAL_MARGIN_EXCEEDS_FREE_BALANCE: free=10.00 USD, margin=14.00 USD"
671 );
672 assert_eq!(
673 OrderDeniedReason::CumulativeInitialMarginCalculationFailed {
674 detail: "total exceeds Money bounds".to_string(),
675 }
676 .to_string(),
677 "CUMULATIVE_INITIAL_MARGIN_CALCULATION_FAILED: total exceeds Money bounds"
678 );
679 assert_eq!(
680 OrderDeniedReason::BettingBalanceLockedCalculationFailed {
681 detail: "invalid liability".to_string(),
682 }
683 .to_string(),
684 "BETTING_BALANCE_LOCKED_CALCULATION_FAILED: invalid liability"
685 );
686 assert_eq!(
687 OrderDeniedReason::TrailingStopCalculationFailed {
688 detail: "missing market price".to_string(),
689 }
690 .to_string(),
691 "TRAILING_STOP_CALCULATION_FAILED: missing market price"
692 );
693 }
694
695 #[rstest]
696 fn renders_lifecycle_and_state_messages() {
697 let not_found = OrderDeniedReason::InstrumentNotFound {
698 instrument_id: InstrumentId::from("AUD/USD.SIM"),
699 };
700 let reducing = OrderDeniedReason::TradingStateReducing {
701 order_side: OrderSide::Buy,
702 instrument_id: InstrumentId::from("AUD/USD.SIM"),
703 };
704
705 assert_eq!(not_found.to_string(), "INSTRUMENT_NOT_FOUND: AUD/USD.SIM");
706 assert_eq!(
707 OrderDeniedReason::ExpireTimeInPast {
708 expire_time: "1970-01-01T00:00:00Z".to_string(),
709 }
710 .to_string(),
711 "EXPIRE_TIME_IN_PAST: 1970-01-01T00:00:00Z"
712 );
713 assert_eq!(
714 OrderDeniedReason::PositionNotFound {
715 position_id: PositionId::from("P-1"),
716 }
717 .to_string(),
718 "POSITION_NOT_FOUND: P-1"
719 );
720 assert_eq!(
721 OrderDeniedReason::ReduceOnlyWouldIncreasePosition {
722 position_id: PositionId::from("P-2"),
723 }
724 .to_string(),
725 "REDUCE_ONLY_WOULD_INCREASE_POSITION: P-2"
726 );
727 assert_eq!(
728 OrderDeniedReason::OrderListIncomplete {
729 order_list_id: OrderListId::from("OL-1"),
730 }
731 .to_string(),
732 "ORDER_LIST_INCOMPLETE: OL-1"
733 );
734 assert_eq!(
735 OrderDeniedReason::OrderListDenied {
736 order_list_id: OrderListId::from("OL-2"),
737 }
738 .to_string(),
739 "ORDER_LIST_DENIED: OL-2"
740 );
741 assert_eq!(
742 OrderDeniedReason::TradingHalted.to_string(),
743 "TRADING_HALTED"
744 );
745 assert_eq!(
746 OrderDeniedReason::RateLimitExceeded.to_string(),
747 "RATE_LIMIT_EXCEEDED"
748 );
749 assert_eq!(
750 reducing.to_string(),
751 "TRADING_STATE_REDUCING: side=BUY, instrument_id=AUD/USD.SIM"
752 );
753 }
754
755 #[rstest]
756 fn renders_routing_messages() {
757 let missing_client = OrderDeniedReason::NoExecutionClient {
758 client_id: Some(ClientId::from("SIM")),
759 routing_context: "venue=SIM".to_string(),
760 };
761 let mismatch = OrderDeniedReason::ClientVenueMismatch {
762 client_id: ClientId::from("IB"),
763 order_venue: Venue::from("XCME"),
764 client_venue: Venue::from("IB"),
765 };
766 let submit_failed = OrderDeniedReason::SubmitFailed {
767 detail: "transport closed".to_string(),
768 };
769 let invalid_position_id = OrderDeniedReason::InvalidPositionId {
770 position_id: PositionId::from("P-1"),
771 detail: "not valid for NETTING OMS".to_string(),
772 };
773
774 assert_eq!(
775 missing_client.to_string(),
776 "NO_EXECUTION_CLIENT: client_id=SIM, venue=SIM"
777 );
778 assert_eq!(
779 mismatch.to_string(),
780 "CLIENT_VENUE_MISMATCH: client_id=IB, order_venue=XCME, client_venue=IB"
781 );
782 assert_eq!(submit_failed.to_string(), "SUBMIT_FAILED: transport closed");
783 assert_eq!(
784 invalid_position_id.to_string(),
785 "INVALID_POSITION_ID: P-1; not valid for NETTING OMS"
786 );
787 }
788
789 #[rstest]
790 fn renders_condition_led_message() {
791 let reason = OrderDeniedReason::UnsupportedTimeInForce(TimeInForce::Gtd);
792 assert_eq!(reason.to_string(), "UNSUPPORTED_TIME_IN_FORCE: GTD");
793 }
794
795 #[rstest]
796 #[case(
797 OrderDeniedReason::unsupported_quote_quantity(),
798 "UNSUPPORTED_QUOTE_QUANTITY",
799 "UNSUPPORTED_QUOTE_QUANTITY"
800 )]
801 #[case(
802 OrderDeniedReason::unsupported_quote_quantity_with_detail(String::new()),
803 "UNSUPPORTED_QUOTE_QUANTITY",
804 "UNSUPPORTED_QUOTE_QUANTITY"
805 )]
806 #[case(
807 OrderDeniedReason::unsupported_quote_quantity_with_detail(
808 "IB error 10244; use base quantity"
809 ),
810 "UNSUPPORTED_QUOTE_QUANTITY",
811 "UNSUPPORTED_QUOTE_QUANTITY: IB error 10244; use base quantity"
812 )]
813 #[case(
814 OrderDeniedReason::unsupported_post_only(),
815 "UNSUPPORTED_POST_ONLY",
816 "UNSUPPORTED_POST_ONLY"
817 )]
818 #[case(
819 OrderDeniedReason::unsupported_post_only_with_detail(String::new()),
820 "UNSUPPORTED_POST_ONLY",
821 "UNSUPPORTED_POST_ONLY"
822 )]
823 #[case(
824 OrderDeniedReason::unsupported_post_only_with_detail("IOC orders cannot be post-only"),
825 "UNSUPPORTED_POST_ONLY",
826 "UNSUPPORTED_POST_ONLY: IOC orders cannot be post-only"
827 )]
828 fn renders_unsupported_instruction(
829 #[case] reason: OrderDeniedReason,
830 #[case] expected_code: &str,
831 #[case] expected_message: &str,
832 ) {
833 let code = OrderDeniedCode::from(&reason);
834
835 assert_eq!(reason.to_string(), expected_message);
836 assert_eq!(code.to_string(), expected_code);
837 assert_eq!(expected_code.parse::<OrderDeniedCode>(), Ok(code));
838 }
839
840 #[rstest]
841 fn renders_adapter_messages() {
842 let invalid_client_order_id = OrderDeniedReason::InvalidClientOrderId {
843 detail: "clOrdId must be alphanumeric".to_string(),
844 };
845 let unsupported_order_list = OrderDeniedReason::UnsupportedOrderList {
846 detail: "spread instruments are not supported in order lists".to_string(),
847 };
848 let unsupported_order_type = OrderDeniedReason::UnsupportedOrderType {
849 order_type: OrderType::TrailingStopMarket,
850 };
851 let unsupported_reduce_only = OrderDeniedReason::UnsupportedReduceOnly;
852 let unsupported_tp_sl = OrderDeniedReason::UnsupportedTpSl {
853 detail: "TP/SL trigger prices are not supported in demo mode".to_string(),
854 };
855 let validation_failed = OrderDeniedReason::ValidationFailed {
856 detail: "`bbo_side_type` and `bbo_level` are only supported for linear products"
857 .to_string(),
858 };
859
860 assert_eq!(
861 invalid_client_order_id.to_string(),
862 "INVALID_CLIENT_ORDER_ID: clOrdId must be alphanumeric"
863 );
864 assert_eq!(
865 unsupported_order_list.to_string(),
866 "UNSUPPORTED_ORDER_LIST: spread instruments are not supported in order lists"
867 );
868 assert_eq!(
869 unsupported_order_type.to_string(),
870 "UNSUPPORTED_ORDER_TYPE: TRAILING_STOP_MARKET"
871 );
872 assert_eq!(
873 unsupported_reduce_only.to_string(),
874 "UNSUPPORTED_REDUCE_ONLY"
875 );
876 assert_eq!(
877 unsupported_tp_sl.to_string(),
878 "UNSUPPORTED_TP_SL: TP/SL trigger prices are not supported in demo mode"
879 );
880 assert_eq!(
881 validation_failed.to_string(),
882 "VALIDATION_FAILED: `bbo_side_type` and `bbo_level` are only supported for linear products"
883 );
884 assert_eq!(
885 OrderDeniedReason::StreamReconciling.to_string(),
886 "STREAM_RECONCILING: execution stream unavailable or recovering, retry after recovery"
887 );
888 }
889
890 #[rstest]
893 fn message_prefix_matches_code() {
894 let usd = || Money::from("100.00 USD");
895 let samples = [
896 OrderDeniedReason::PricePrecisionExceedsMaximum {
897 field: OrderPriceField::Price,
898 price: Price::from("1.00"),
899 price_precision: 2,
900 max_precision: 1,
901 },
902 OrderDeniedReason::PriceNotPositive {
903 field: OrderPriceField::TriggerPrice,
904 price: Price::from("0.00"),
905 },
906 OrderDeniedReason::QuantityPrecisionExceedsMaximum {
907 quantity: Quantity::from("1.00"),
908 quantity_precision: 2,
909 max_precision: 1,
910 },
911 OrderDeniedReason::QuantityConversionFailed {
912 detail: "boom".to_string(),
913 },
914 OrderDeniedReason::QuantityExceedsMaximum {
915 effective_quantity: Quantity::from("15"),
916 max_quantity: Quantity::from("10"),
917 },
918 OrderDeniedReason::QuantityBelowMinimum {
919 effective_quantity: Quantity::from("1"),
920 min_quantity: Quantity::from("5"),
921 },
922 OrderDeniedReason::InvalidMaxNotionalPerOrder {
923 instrument_id: InstrumentId::from("AUD/USD.SIM"),
924 value: Decimal::ONE,
925 },
926 OrderDeniedReason::MissingExpireTime,
927 OrderDeniedReason::ExpireTimeInPast {
928 expire_time: "1970-01-01T00:00:00Z".to_string(),
929 },
930 OrderDeniedReason::MissingTrailingOffsetType,
931 OrderDeniedReason::UnsupportedTrailingOffsetType {
932 offset_type: TrailingOffsetType::Price,
933 },
934 OrderDeniedReason::MissingTriggerType,
935 OrderDeniedReason::MissingTrailingOffset,
936 OrderDeniedReason::InstrumentNotFound {
937 instrument_id: InstrumentId::from("AUD/USD.SIM"),
938 },
939 OrderDeniedReason::PositionNotFound {
940 position_id: PositionId::from("P-1"),
941 },
942 OrderDeniedReason::MarketPriceUnavailable {
943 order_type: OrderType::Market,
944 instrument_id: InstrumentId::from("AUD/USD.SIM"),
945 },
946 OrderDeniedReason::TrailingStopCalculationFailed {
947 detail: "boom".to_string(),
948 },
949 OrderDeniedReason::NotionalCalculationFailed {
950 detail: "boom".to_string(),
951 },
952 OrderDeniedReason::NotionalBelowMinimum {
953 min_notional: usd(),
954 notional: usd(),
955 },
956 OrderDeniedReason::NotionalExceedsMaximum {
957 max_notional: usd(),
958 notional: usd(),
959 },
960 OrderDeniedReason::NotionalExceedsMaxPerOrder {
961 max_notional: usd(),
962 notional: usd(),
963 },
964 OrderDeniedReason::NotionalExceedsFreeBalance {
965 free_balance: usd(),
966 notional: usd(),
967 },
968 OrderDeniedReason::InitialMarginCalculationFailed {
969 detail: "boom".to_string(),
970 },
971 OrderDeniedReason::InitialMarginExceedsFreeBalance {
972 free_balance: usd(),
973 initial_margin: usd(),
974 },
975 OrderDeniedReason::BettingBalanceLockedCalculationFailed {
976 detail: "boom".to_string(),
977 },
978 OrderDeniedReason::CumulativeNotionalExceedsFreeBalance {
979 free_balance: usd(),
980 cumulative_notional: usd(),
981 },
982 OrderDeniedReason::CumulativeInitialMarginCalculationFailed {
983 detail: "boom".to_string(),
984 },
985 OrderDeniedReason::CumulativeInitialMarginExceedsFreeBalance {
986 free_balance: usd(),
987 cumulative_initial_margin: usd(),
988 },
989 OrderDeniedReason::ReduceOnlyWouldIncreasePosition {
990 position_id: PositionId::from("P-1"),
991 },
992 OrderDeniedReason::OrderListIncomplete {
993 order_list_id: OrderListId::from("OL-1"),
994 },
995 OrderDeniedReason::OrderListDenied {
996 order_list_id: OrderListId::from("OL-1"),
997 },
998 OrderDeniedReason::TradingHalted,
999 OrderDeniedReason::TradingStateReducing {
1000 order_side: OrderSide::Buy,
1001 instrument_id: InstrumentId::from("AUD/USD.SIM"),
1002 },
1003 OrderDeniedReason::RateLimitExceeded,
1004 OrderDeniedReason::StreamReconciling,
1005 OrderDeniedReason::NoExecutionClient {
1006 client_id: Some(ClientId::from("SIM")),
1007 routing_context: "venue=SIM".to_string(),
1008 },
1009 OrderDeniedReason::ClientVenueMismatch {
1010 client_id: ClientId::from("IB"),
1011 order_venue: Venue::from("XCME"),
1012 client_venue: Venue::from("IB"),
1013 },
1014 OrderDeniedReason::SubmitFailed {
1015 detail: "boom".to_string(),
1016 },
1017 OrderDeniedReason::InvalidClientOrderId {
1018 detail: "boom".to_string(),
1019 },
1020 OrderDeniedReason::InvalidPositionId {
1021 position_id: PositionId::from("P-1"),
1022 detail: "boom".to_string(),
1023 },
1024 OrderDeniedReason::UnsupportedOrderList {
1025 detail: "boom".to_string(),
1026 },
1027 OrderDeniedReason::UnsupportedOrderType {
1028 order_type: OrderType::TrailingStopMarket,
1029 },
1030 OrderDeniedReason::unsupported_quote_quantity(),
1031 OrderDeniedReason::unsupported_post_only(),
1032 OrderDeniedReason::UnsupportedReduceOnly,
1033 OrderDeniedReason::UnsupportedTimeInForce(TimeInForce::Gtd),
1034 OrderDeniedReason::UnsupportedTpSl {
1035 detail: "boom".to_string(),
1036 },
1037 OrderDeniedReason::ValidationFailed {
1038 detail: "boom".to_string(),
1039 },
1040 ];
1041
1042 assert_eq!(samples.len(), OrderDeniedCode::iter().count());
1043 for reason in samples {
1044 let code = OrderDeniedCode::from(&reason).to_string();
1045 assert!(
1046 reason.to_string().starts_with(&code),
1047 "message `{reason}` must start with code `{code}`"
1048 );
1049 }
1050 }
1051
1052 #[rstest]
1053 fn generated_table_is_in_sync() {
1054 let committed = std::fs::read_to_string(DOC_PATH).expect("execution/index.md should exist");
1055 assert!(
1056 committed.contains(&generated_block()),
1057 "the order-denied-reasons table in docs/concepts/execution/index.md is stale; regenerate \
1058 with `cargo test -p nautilus-model regenerate_order_denied_reasons_doc -- --ignored`"
1059 );
1060 }
1061
1062 #[rstest]
1063 #[ignore = "rewrites the generated table in execution/index.md; run after changing OrderDeniedReason variants"]
1064 fn regenerate_order_denied_reasons_doc() {
1065 let doc = std::fs::read_to_string(DOC_PATH).expect("execution/index.md should exist");
1066 let start = doc.find(BLOCK_BEGIN).expect("begin marker present");
1067 let end = doc.find(BLOCK_END).expect("end marker present") + BLOCK_END.len();
1068 let updated = format!("{}{}{}", &doc[..start], generated_block(), &doc[end..]);
1069 std::fs::write(DOC_PATH, updated).expect("should write execution/index.md");
1070 }
1071
1072 fn generated_block() -> String {
1073 format!("{BLOCK_BEGIN}\n\n{}\n\n{BLOCK_END}", markdown_table())
1074 }
1075
1076 fn markdown_table() -> String {
1077 const CODE_HEADER: &str = "Code";
1078 const DESC_HEADER: &str = "Description";
1079
1080 let rows: Vec<(String, &'static str)> = OrderDeniedCode::iter()
1082 .map(|code| (format!("`{code}`"), code.description()))
1083 .collect();
1084 let code_w = rows
1087 .iter()
1088 .map(|(code, _)| code.chars().count())
1089 .max()
1090 .unwrap_or(0)
1091 .max(CODE_HEADER.chars().count());
1092 let desc_w = rows
1093 .iter()
1094 .map(|(_, desc)| desc.chars().count())
1095 .max()
1096 .unwrap_or(0)
1097 .max(DESC_HEADER.chars().count());
1098
1099 let mut lines = vec![
1100 format!("| {CODE_HEADER:<code_w$} | {DESC_HEADER:<desc_w$} |"),
1101 format!("| {:-<code_w$} | {:-<desc_w$} |", "", ""),
1102 ];
1103
1104 for (code, desc) in rows {
1105 lines.push(format!("| {code:<code_w$} | {desc:<desc_w$} |"));
1106 }
1107 lines.join("\n")
1108 }
1109}