use rust_decimal::Decimal;
use strum::{AsRefStr, Display, EnumDiscriminants, EnumIter, EnumString};
use thiserror::Error;
use crate::{
enums::{OrderSide, OrderType, TimeInForce, TrailingOffsetType},
identifiers::{ClientId, InstrumentId, OrderListId, PositionId, Venue},
types::{Money, Quantity},
};
#[derive(Debug, Clone, PartialEq, Eq, Error, EnumDiscriminants)]
#[strum_discriminants(
name(OrderDeniedCode),
derive(Display, AsRefStr, EnumIter, EnumString),
strum(serialize_all = "SCREAMING_SNAKE_CASE")
)]
pub enum OrderDeniedReason {
#[error(
"QUANTITY_EXCEEDS_MAXIMUM: effective_quantity={effective_quantity}, max_quantity={max_quantity}"
)]
QuantityExceedsMaximum {
effective_quantity: Quantity,
max_quantity: Quantity,
},
#[error(
"QUANTITY_BELOW_MINIMUM: effective_quantity={effective_quantity}, min_quantity={min_quantity}"
)]
QuantityBelowMinimum {
effective_quantity: Quantity,
min_quantity: Quantity,
},
#[error("NOTIONAL_EXCEEDS_MAX_PER_ORDER: max_notional={max_notional:?}, notional={notional:?}")]
NotionalExceedsMaxPerOrder {
max_notional: Money,
notional: Money,
},
#[error("NOTIONAL_EXCEEDS_MAXIMUM: max_notional={max_notional:?}, notional={notional:?}")]
NotionalExceedsMaximum {
max_notional: Money,
notional: Money,
},
#[error("NOTIONAL_BELOW_MINIMUM: min_notional={min_notional:?}, notional={notional:?}")]
NotionalBelowMinimum {
min_notional: Money,
notional: Money,
},
#[error("NOTIONAL_EXCEEDS_FREE_BALANCE: free={free:?}, notional={notional:?}")]
NotionalExceedsFreeBalance {
free: Money,
notional: Money,
},
#[error("CUM_NOTIONAL_EXCEEDS_FREE_BALANCE: free={free}, cum_notional={cum_notional}")]
CumNotionalExceedsFreeBalance {
free: Money,
cum_notional: Money,
},
#[error("MARGIN_EXCEEDS_FREE_BALANCE: free={free}, margin_required={margin_required}")]
MarginExceedsFreeBalance {
free: Money,
margin_required: Money,
},
#[error("CUM_MARGIN_EXCEEDS_FREE_BALANCE: free={free}, cum_margin={cum_margin}")]
CumMarginExceedsFreeBalance {
free: Money,
cum_margin: Money,
},
#[error("INVALID_MAX_NOTIONAL_PER_ORDER: instrument_id={instrument_id}, value={value}")]
InvalidMaxNotionalPerOrder {
instrument_id: InstrumentId,
value: Decimal,
},
#[error("INVALID_ORDER_SIDE: {order_side}")]
InvalidOrderSide {
order_side: OrderSide,
},
#[error("MISSING_EXPIRE_TIME")]
MissingExpireTime,
#[error("EXPIRE_TIME_IN_PAST: expire_time={expire_time}")]
ExpireTimeInPast {
expire_time: String,
},
#[error("MISSING_TRIGGER_TYPE")]
MissingTriggerType,
#[error("MISSING_TRAILING_OFFSET")]
MissingTrailingOffset,
#[error("MISSING_TRAILING_OFFSET_TYPE")]
MissingTrailingOffsetType,
#[error("UNSUPPORTED_TRAILING_OFFSET_TYPE: {offset_type:?}")]
UnsupportedTrailingOffsetType {
offset_type: TrailingOffsetType,
},
#[error("TRAILING_STOP_CALC_FAILED: {detail}")]
TrailingStopCalcFailed {
detail: String,
},
#[error("QUANTITY_CONVERSION_FAILED: {detail}")]
QuantityConversionFailed {
detail: String,
},
#[error("INSTRUMENT_NOT_FOUND: instrument_id={instrument_id}")]
InstrumentNotFound {
instrument_id: InstrumentId,
},
#[error("POSITION_NOT_FOUND: position_id={position_id}")]
PositionNotFound {
position_id: PositionId,
},
#[error("REDUCE_ONLY_WOULD_INCREASE_POSITION: position_id={position_id}")]
ReduceOnlyWouldIncreasePosition {
position_id: PositionId,
},
#[error("ORDER_LIST_INCOMPLETE: order_list_id={order_list_id}")]
OrderListIncomplete {
order_list_id: OrderListId,
},
#[error("ORDER_LIST_DENIED: order_list_id={order_list_id}")]
OrderListDenied {
order_list_id: OrderListId,
},
#[error("TRADING_HALTED")]
TradingHalted,
#[error("TRADING_STATE_REDUCING: order_side={order_side}, instrument_id={instrument_id}")]
TradingStateReducing {
order_side: OrderSide,
instrument_id: InstrumentId,
},
#[error("RATE_LIMIT_EXCEEDED")]
RateLimitExceeded,
#[error("NO_EXECUTION_CLIENT: client_id={client_id:?}, routing_context={routing_context}")]
NoExecutionClient {
client_id: Option<ClientId>,
routing_context: String,
},
#[error(
"CLIENT_VENUE_MISMATCH: client_id={client_id}, order_venue={order_venue}, client_venue={client_venue}"
)]
ClientVenueMismatch {
client_id: ClientId,
order_venue: Venue,
client_venue: Venue,
},
#[error("SUBMIT_FAILED: {detail}")]
SubmitFailed {
detail: String,
},
#[error("INVALID_POSITION_ID: position_id={position_id}, detail={detail}")]
InvalidPositionId {
position_id: PositionId,
detail: String,
},
#[error("UNSUPPORTED_TIME_IN_FORCE: {0}")]
UnsupportedTimeInForce(TimeInForce),
#[error("INVALID_CLIENT_ORDER_ID: {detail}")]
InvalidClientOrderId {
detail: String,
},
#[error("UNSUPPORTED_ORDER_LIST: {detail}")]
UnsupportedOrderList {
detail: String,
},
#[error("UNSUPPORTED_ORDER_TYPE: {order_type}")]
UnsupportedOrderType {
order_type: OrderType,
},
#[error("UNSUPPORTED_TP_SL: {detail}")]
UnsupportedTpSl {
detail: String,
},
#[error("VALIDATION_FAILED: {detail}")]
ValidationFailed {
detail: String,
},
#[error(
"STREAM_RECONCILING: post-reconnect reconciliation in progress, retry once it completes"
)]
StreamReconciling,
}
impl OrderDeniedCode {
#[must_use]
pub fn description(&self) -> &'static str {
match self {
Self::QuantityExceedsMaximum => {
"The effective order quantity exceeds the instrument maximum."
}
Self::QuantityBelowMinimum => {
"The effective order quantity is below the instrument minimum."
}
Self::NotionalExceedsMaxPerOrder => {
"The order notional exceeds the configured maximum per order."
}
Self::NotionalExceedsMaximum => "The order notional exceeds the instrument maximum.",
Self::NotionalBelowMinimum => "The order notional is below the instrument minimum.",
Self::NotionalExceedsFreeBalance => {
"The order notional exceeds the account free balance."
}
Self::CumNotionalExceedsFreeBalance => {
"The cumulative order notional exceeds the account free balance."
}
Self::MarginExceedsFreeBalance => {
"The order initial margin exceeds the account free balance."
}
Self::CumMarginExceedsFreeBalance => {
"The cumulative initial margin exceeds the account free balance."
}
Self::InvalidMaxNotionalPerOrder => {
"The configured maximum notional per order is invalid."
}
Self::InvalidOrderSide => "The order side is invalid for this operation.",
Self::MissingExpireTime => "A GTD order is missing its expire time.",
Self::ExpireTimeInPast => "The order's expire time is in the past.",
Self::MissingTriggerType => "The order is missing a required trigger type.",
Self::MissingTrailingOffset => "The order is missing a required trailing offset.",
Self::MissingTrailingOffsetType => {
"The order is missing a required trailing offset type."
}
Self::UnsupportedTrailingOffsetType => {
"The order's trailing offset type is not supported."
}
Self::TrailingStopCalcFailed => {
"The trailing stop trigger price could not be calculated."
}
Self::QuantityConversionFailed => {
"The order quantity could not be converted for risk checks."
}
Self::InstrumentNotFound => "The instrument was not found in the cache.",
Self::PositionNotFound => "The position for a reduce‑only order was not found.",
Self::ReduceOnlyWouldIncreasePosition => {
"A reduce‑only order would increase the position."
}
Self::OrderListIncomplete => "The order list is missing orders in the cache.",
Self::OrderListDenied => {
"The order was denied because its order list failed risk checks."
}
Self::TradingHalted => "Trading is halted; new orders are denied.",
Self::TradingStateReducing => "Trading is reducing; the order would increase exposure.",
Self::RateLimitExceeded => "The order submission rate limit was exceeded.",
Self::NoExecutionClient => "No execution client was found for the routed command.",
Self::ClientVenueMismatch => "The execution client does not handle the order venue.",
Self::SubmitFailed => "Submitting the order to the execution client failed.",
Self::InvalidPositionId => {
"The supplied position ID is invalid for the order submission."
}
Self::UnsupportedTimeInForce => "The order's time in force is not supported.",
Self::InvalidClientOrderId => "The client order ID is invalid for the venue.",
Self::UnsupportedOrderList => "The venue does not support the requested order list.",
Self::UnsupportedOrderType => "The order type is not supported by the venue.",
Self::UnsupportedTpSl => {
"The venue does not support the requested take‑profit/stop‑loss parameters."
}
Self::ValidationFailed => "The order failed adapter validation before submission.",
Self::StreamReconciling => {
"A post‑reconnect stream reconciliation is in progress; retry once it completes."
}
}
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use strum::IntoEnumIterator;
use super::*;
const DOC_PATH: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../docs/concepts/execution.md"
);
const BLOCK_BEGIN: &str = "<!-- BEGIN GENERATED: order-denied-reasons -->";
const BLOCK_END: &str = "<!-- END GENERATED: order-denied-reasons -->";
#[rstest]
fn renders_subject_led_messages() {
let exceeds = OrderDeniedReason::QuantityExceedsMaximum {
effective_quantity: Quantity::from("15"),
max_quantity: Quantity::from("10"),
};
let below = OrderDeniedReason::QuantityBelowMinimum {
effective_quantity: Quantity::from("1"),
min_quantity: Quantity::from("5"),
};
let notional = OrderDeniedReason::NotionalBelowMinimum {
min_notional: Money::from("1.00 USD"),
notional: Money::from("0.90 USD"),
};
assert_eq!(
exceeds.to_string(),
"QUANTITY_EXCEEDS_MAXIMUM: effective_quantity=15, max_quantity=10"
);
assert_eq!(
below.to_string(),
"QUANTITY_BELOW_MINIMUM: effective_quantity=1, min_quantity=5"
);
assert_eq!(
notional.to_string(),
"NOTIONAL_BELOW_MINIMUM: min_notional=Money(1.00, USD), notional=Money(0.90, USD)"
);
}
#[rstest]
fn renders_lifecycle_and_state_messages() {
let not_found = OrderDeniedReason::InstrumentNotFound {
instrument_id: InstrumentId::from("AUD/USD.SIM"),
};
let bad_side = OrderDeniedReason::InvalidOrderSide {
order_side: OrderSide::NoOrderSide,
};
let reducing = OrderDeniedReason::TradingStateReducing {
order_side: OrderSide::Buy,
instrument_id: InstrumentId::from("AUD/USD.SIM"),
};
assert_eq!(
not_found.to_string(),
"INSTRUMENT_NOT_FOUND: instrument_id=AUD/USD.SIM"
);
assert_eq!(bad_side.to_string(), "INVALID_ORDER_SIDE: NO_ORDER_SIDE");
assert_eq!(
OrderDeniedReason::TradingHalted.to_string(),
"TRADING_HALTED"
);
assert_eq!(
OrderDeniedReason::RateLimitExceeded.to_string(),
"RATE_LIMIT_EXCEEDED"
);
assert_eq!(
reducing.to_string(),
"TRADING_STATE_REDUCING: order_side=BUY, instrument_id=AUD/USD.SIM"
);
}
#[rstest]
fn renders_routing_messages() {
let missing_client = OrderDeniedReason::NoExecutionClient {
client_id: Some(ClientId::from("SIM")),
routing_context: "venue=SIM".to_string(),
};
let mismatch = OrderDeniedReason::ClientVenueMismatch {
client_id: ClientId::from("IB"),
order_venue: Venue::from("XCME"),
client_venue: Venue::from("IB"),
};
let submit_failed = OrderDeniedReason::SubmitFailed {
detail: "transport closed".to_string(),
};
let invalid_position_id = OrderDeniedReason::InvalidPositionId {
position_id: PositionId::from("P-1"),
detail: "not valid for NETTING OMS".to_string(),
};
assert_eq!(
missing_client.to_string(),
"NO_EXECUTION_CLIENT: client_id=Some(\"SIM\"), routing_context=venue=SIM"
);
assert_eq!(
mismatch.to_string(),
"CLIENT_VENUE_MISMATCH: client_id=IB, order_venue=XCME, client_venue=IB"
);
assert_eq!(submit_failed.to_string(), "SUBMIT_FAILED: transport closed");
assert_eq!(
invalid_position_id.to_string(),
"INVALID_POSITION_ID: position_id=P-1, detail=not valid for NETTING OMS"
);
}
#[rstest]
fn renders_condition_led_message() {
let reason = OrderDeniedReason::UnsupportedTimeInForce(TimeInForce::Gtd);
assert_eq!(reason.to_string(), "UNSUPPORTED_TIME_IN_FORCE: GTD");
}
#[rstest]
fn renders_adapter_messages() {
let invalid_client_order_id = OrderDeniedReason::InvalidClientOrderId {
detail: "clOrdId must be alphanumeric".to_string(),
};
let unsupported_order_list = OrderDeniedReason::UnsupportedOrderList {
detail: "spread instruments are not supported in order lists".to_string(),
};
let unsupported_order_type = OrderDeniedReason::UnsupportedOrderType {
order_type: OrderType::TrailingStopMarket,
};
let unsupported_tp_sl = OrderDeniedReason::UnsupportedTpSl {
detail: "TP/SL trigger prices are not supported in demo mode".to_string(),
};
let validation_failed = OrderDeniedReason::ValidationFailed {
detail: "`bbo_side_type` and `bbo_level` are only supported for linear products"
.to_string(),
};
assert_eq!(
invalid_client_order_id.to_string(),
"INVALID_CLIENT_ORDER_ID: clOrdId must be alphanumeric"
);
assert_eq!(
unsupported_order_list.to_string(),
"UNSUPPORTED_ORDER_LIST: spread instruments are not supported in order lists"
);
assert_eq!(
unsupported_order_type.to_string(),
"UNSUPPORTED_ORDER_TYPE: TRAILING_STOP_MARKET"
);
assert_eq!(
unsupported_tp_sl.to_string(),
"UNSUPPORTED_TP_SL: TP/SL trigger prices are not supported in demo mode"
);
assert_eq!(
validation_failed.to_string(),
"VALIDATION_FAILED: `bbo_side_type` and `bbo_level` are only supported for linear products"
);
assert_eq!(
OrderDeniedReason::StreamReconciling.to_string(),
"STREAM_RECONCILING: post-reconnect reconciliation in progress, retry once it completes"
);
}
#[rstest]
fn message_prefix_matches_code() {
let usd = || Money::from("100.00 USD");
let samples = [
OrderDeniedReason::QuantityExceedsMaximum {
effective_quantity: Quantity::from("15"),
max_quantity: Quantity::from("10"),
},
OrderDeniedReason::QuantityBelowMinimum {
effective_quantity: Quantity::from("1"),
min_quantity: Quantity::from("5"),
},
OrderDeniedReason::NotionalExceedsMaxPerOrder {
max_notional: usd(),
notional: usd(),
},
OrderDeniedReason::NotionalExceedsMaximum {
max_notional: usd(),
notional: usd(),
},
OrderDeniedReason::NotionalBelowMinimum {
min_notional: usd(),
notional: usd(),
},
OrderDeniedReason::NotionalExceedsFreeBalance {
free: usd(),
notional: usd(),
},
OrderDeniedReason::CumNotionalExceedsFreeBalance {
free: usd(),
cum_notional: usd(),
},
OrderDeniedReason::MarginExceedsFreeBalance {
free: usd(),
margin_required: usd(),
},
OrderDeniedReason::CumMarginExceedsFreeBalance {
free: usd(),
cum_margin: usd(),
},
OrderDeniedReason::InvalidMaxNotionalPerOrder {
instrument_id: InstrumentId::from("AUD/USD.SIM"),
value: Decimal::ONE,
},
OrderDeniedReason::InvalidOrderSide {
order_side: OrderSide::NoOrderSide,
},
OrderDeniedReason::MissingExpireTime,
OrderDeniedReason::ExpireTimeInPast {
expire_time: "1970-01-01T00:00:00Z".to_string(),
},
OrderDeniedReason::MissingTriggerType,
OrderDeniedReason::MissingTrailingOffset,
OrderDeniedReason::MissingTrailingOffsetType,
OrderDeniedReason::UnsupportedTrailingOffsetType {
offset_type: TrailingOffsetType::Price,
},
OrderDeniedReason::TrailingStopCalcFailed {
detail: "boom".to_string(),
},
OrderDeniedReason::QuantityConversionFailed {
detail: "boom".to_string(),
},
OrderDeniedReason::InstrumentNotFound {
instrument_id: InstrumentId::from("AUD/USD.SIM"),
},
OrderDeniedReason::PositionNotFound {
position_id: PositionId::from("P-1"),
},
OrderDeniedReason::ReduceOnlyWouldIncreasePosition {
position_id: PositionId::from("P-1"),
},
OrderDeniedReason::OrderListIncomplete {
order_list_id: OrderListId::from("OL-1"),
},
OrderDeniedReason::OrderListDenied {
order_list_id: OrderListId::from("OL-1"),
},
OrderDeniedReason::TradingHalted,
OrderDeniedReason::TradingStateReducing {
order_side: OrderSide::Buy,
instrument_id: InstrumentId::from("AUD/USD.SIM"),
},
OrderDeniedReason::RateLimitExceeded,
OrderDeniedReason::NoExecutionClient {
client_id: Some(ClientId::from("SIM")),
routing_context: "venue=SIM".to_string(),
},
OrderDeniedReason::ClientVenueMismatch {
client_id: ClientId::from("IB"),
order_venue: Venue::from("XCME"),
client_venue: Venue::from("IB"),
},
OrderDeniedReason::SubmitFailed {
detail: "boom".to_string(),
},
OrderDeniedReason::InvalidPositionId {
position_id: PositionId::from("P-1"),
detail: "boom".to_string(),
},
OrderDeniedReason::UnsupportedTimeInForce(TimeInForce::Gtd),
OrderDeniedReason::InvalidClientOrderId {
detail: "boom".to_string(),
},
OrderDeniedReason::UnsupportedOrderList {
detail: "boom".to_string(),
},
OrderDeniedReason::UnsupportedOrderType {
order_type: OrderType::TrailingStopMarket,
},
OrderDeniedReason::UnsupportedTpSl {
detail: "boom".to_string(),
},
OrderDeniedReason::ValidationFailed {
detail: "boom".to_string(),
},
OrderDeniedReason::StreamReconciling,
];
for reason in samples {
let code = OrderDeniedCode::from(&reason).to_string();
assert!(
reason.to_string().starts_with(&code),
"message `{reason}` must start with code `{code}`"
);
}
}
#[rstest]
fn generated_table_is_in_sync() {
let committed = std::fs::read_to_string(DOC_PATH).expect("execution.md should exist");
assert!(
committed.contains(&generated_block()),
"the order-denied-reasons table in docs/concepts/execution.md is stale; regenerate \
with `cargo test -p nautilus-model regenerate_order_denied_reasons_doc -- --ignored`"
);
}
#[rstest]
#[ignore = "rewrites the generated table in execution.md; run after changing OrderDeniedReason variants"]
fn regenerate_order_denied_reasons_doc() {
let doc = std::fs::read_to_string(DOC_PATH).expect("execution.md should exist");
let start = doc.find(BLOCK_BEGIN).expect("begin marker present");
let end = doc.find(BLOCK_END).expect("end marker present") + BLOCK_END.len();
let updated = format!("{}{}{}", &doc[..start], generated_block(), &doc[end..]);
std::fs::write(DOC_PATH, updated).expect("should write execution.md");
}
fn generated_block() -> String {
format!("{BLOCK_BEGIN}\n\n{}\n\n{BLOCK_END}", markdown_table())
}
fn markdown_table() -> String {
const CODE_HEADER: &str = "Code";
const DESC_HEADER: &str = "Description";
let mut codes: Vec<OrderDeniedCode> = OrderDeniedCode::iter().collect();
codes.sort_by_key(ToString::to_string);
let rows: Vec<(String, &'static str)> = codes
.iter()
.map(|code| (format!("`{code}`"), code.description()))
.collect();
let code_w = rows
.iter()
.map(|(code, _)| code.len())
.max()
.unwrap_or(0)
.max(CODE_HEADER.len());
let desc_w = rows
.iter()
.map(|(_, desc)| desc.len())
.max()
.unwrap_or(0)
.max(DESC_HEADER.len());
let mut lines = vec![
format!("| {CODE_HEADER:<code_w$} | {DESC_HEADER:<desc_w$} |"),
format!("| {:-<code_w$} | {:-<desc_w$} |", "", ""),
];
for (code, desc) in rows {
lines.push(format!("| {code:<code_w$} | {desc:<desc_w$} |"));
}
lines.join("\n")
}
}