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