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("UNSUPPORTED_REDUCE_ONLY")]
402 UnsupportedReduceOnly,
403
404 #[error("UNSUPPORTED_TIME_IN_FORCE: {0}")]
406 UnsupportedTimeInForce(TimeInForce),
407
408 #[error("UNSUPPORTED_TP_SL: {detail}")]
410 UnsupportedTpSl {
411 detail: String,
413 },
414
415 #[error("VALIDATION_FAILED: {detail}")]
417 ValidationFailed {
418 detail: String,
420 },
421}
422
423impl OrderDeniedCode {
424 #[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 #[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 let rows: Vec<(String, &'static str)> = OrderDeniedCode::iter()
981 .map(|code| (format!("`{code}`"), code.description()))
982 .collect();
983 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}