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