1pub mod config;
19
20use std::{cell::RefCell, fmt::Debug, rc::Rc};
21
22use ahash::AHashMap;
23use config::RiskEngineConfig;
24use indexmap::IndexMap;
25use nautilus_common::{
26 cache::Cache,
27 clock::Clock,
28 logging::{CMD, EVT, RECV},
29 messages::{
30 execution::{
31 BatchModifyOrders, ModifyOrder, PARAMS_CLOSE_POSITION, SubmitOrder, SubmitOrderList,
32 TradingCommand,
33 },
34 system::trading::TradingStateChanged,
35 },
36 msgbus,
37 msgbus::{MessagingSwitchboard, TypedHandler, TypedIntoHandler, get_message_bus},
38 runner::{TradingCommandMessage, try_get_trading_cmd_sender},
39 throttler::{RateLimit, Throttler},
40};
41use nautilus_core::{UUID4, WeakCell};
42use nautilus_execution::trailing::{
43 trailing_stop_calculate_with_bid_ask, trailing_stop_calculate_with_last,
44};
45use nautilus_model::{
46 accounts::{Account, AccountAny},
47 enums::{
48 AggregationSource, OrderSide, OrderStatus, OrderType, PositionSide, PriceType, TimeInForce,
49 TradingState, TrailingOffsetType, TriggerType,
50 },
51 events::{
52 OrderDenied, OrderDeniedReason, OrderEventAny, OrderModifyRejected, OrderPriceField,
53 PositionEvent,
54 },
55 identifiers::{AccountId, InstrumentId},
56 instruments::{Instrument, InstrumentAny},
57 orders::{Order, OrderAny},
58 types::{Currency, Money, Price, Quantity, money::MoneyRaw, quantity::QuantityRaw},
59};
60use nautilus_portfolio::Portfolio;
61use rust_decimal::Decimal;
62use ustr::Ustr;
63
64fn cash_or_wallet_account(account: &AccountAny) -> Option<&dyn Account> {
67 match account {
68 AccountAny::Cash(cash) => Some(cash),
69 AccountAny::Wallet(wallet) => Some(wallet),
70 AccountAny::Margin(_) | AccountAny::Betting(_) => None,
71 }
72}
73
74fn format_rate_limit(rate_limit: &RateLimit) -> String {
75 let interval_ns = rate_limit.interval_ns();
76 let limit = rate_limit.limit();
77 let total_secs = interval_ns / 1_000_000_000;
78 let remainder_ns = interval_ns % 1_000_000_000;
79 let hours = total_secs / 3600;
80 let minutes = (total_secs % 3600) / 60;
81 let seconds = total_secs % 60;
82
83 if remainder_ns == 0 {
84 format!("{limit}/{hours:02}:{minutes:02}:{seconds:02}")
85 } else {
86 let micros = remainder_ns / 1_000;
87 format!("{limit}/{hours:02}:{minutes:02}:{seconds:02}.{micros:06}")
88 }
89}
90
91type SubmitCommandFn = Box<dyn Fn(TradingCommand)>;
92type ModifyOrderFn = Box<dyn Fn(ModifyOrder)>;
93
94#[allow(dead_code)]
101pub struct RiskEngine {
102 clock: Rc<RefCell<dyn Clock>>,
103 cache: Rc<RefCell<Cache>>,
104 portfolio: Portfolio,
105 pub throttled_submit: Throttler<TradingCommand, SubmitCommandFn>,
106 pub throttled_modify_order: Throttler<ModifyOrder, ModifyOrderFn>,
107 max_notional_per_order: AHashMap<InstrumentId, Decimal>,
108 trading_state: TradingState,
109 config: RiskEngineConfig,
110 command_count: u64,
111 event_count: u64,
112}
113
114impl Debug for RiskEngine {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 f.debug_struct(stringify!(RiskEngine)).finish()
117 }
118}
119
120impl RiskEngine {
121 pub fn new(
123 config: RiskEngineConfig,
124 portfolio: Portfolio,
125 clock: Rc<RefCell<dyn Clock>>,
126 cache: Rc<RefCell<Cache>>,
127 ) -> Self {
128 let throttled_submit = Self::create_submit_throttler(&config, clock.clone(), cache.clone());
129
130 let throttled_modify_order =
131 Self::create_modify_order_throttler(&config, clock.clone(), cache.clone());
132
133 Self {
134 clock,
135 cache,
136 portfolio,
137 throttled_submit,
138 throttled_modify_order,
139 max_notional_per_order: config.max_notional_per_order.clone(),
140 trading_state: TradingState::Active,
141 config,
142 command_count: 0,
143 event_count: 0,
144 }
145 }
146
147 pub fn register_msgbus_handlers(engine: &Rc<RefCell<Self>>) {
149 let weak = WeakCell::from(Rc::downgrade(engine));
150
151 let weak_execute = weak.clone();
152 msgbus::register_trading_command_endpoint(
153 MessagingSwitchboard::risk_engine_execute(),
154 TypedIntoHandler::from(move |cmd: TradingCommand| {
155 if let Some(rc) = weak_execute.upgrade() {
156 rc.borrow_mut().execute(cmd);
157 }
158 }),
159 );
160
161 msgbus::register_trading_command_endpoint(
170 MessagingSwitchboard::risk_engine_queue_execute(),
171 TypedIntoHandler::from(move |cmd: TradingCommand| {
172 if let Some(sender) = try_get_trading_cmd_sender() {
173 sender.execute(TradingCommandMessage::new(
174 MessagingSwitchboard::risk_engine_execute(),
175 cmd,
176 ));
177 } else {
178 let endpoint = MessagingSwitchboard::risk_engine_execute();
179 msgbus::send_trading_command(endpoint, cmd);
180 }
181 }),
182 );
183
184 let weak_process = weak.clone();
185 msgbus::register_order_event_endpoint(
186 MessagingSwitchboard::risk_engine_process(),
187 TypedIntoHandler::from(move |event: OrderEventAny| {
188 if let Some(rc) = weak_process.upgrade() {
189 rc.borrow_mut().process(event);
190 }
191 }),
192 );
193
194 let weak_order_events = weak.clone();
195 msgbus::subscribe_order_events(
196 "events.order.*".into(),
197 TypedHandler::from(move |event: &OrderEventAny| {
198 if let Some(rc) = weak_order_events.upgrade()
202 && let Ok(mut engine) = rc.try_borrow_mut()
203 {
204 engine.process(event.clone());
205 }
206 }),
207 Some(10),
208 );
209
210 let weak_position_events = weak;
211 msgbus::subscribe_position_events(
212 "events.position.*".into(),
213 TypedHandler::from(move |event: &PositionEvent| {
214 if let Some(rc) = weak_position_events.upgrade() {
215 rc.borrow_mut().process_position_event(event);
216 }
217 }),
218 Some(10),
219 );
220 }
221
222 fn create_submit_throttler(
223 config: &RiskEngineConfig,
224 clock: Rc<RefCell<dyn Clock>>,
225 cache: Rc<RefCell<Cache>>,
226 ) -> Throttler<TradingCommand, SubmitCommandFn> {
227 let success_handler = {
228 Box::new(move |command: TradingCommand| {
229 let endpoint = MessagingSwitchboard::exec_engine_queue_execute();
230 msgbus::send_trading_command(endpoint, command);
231 }) as Box<dyn Fn(TradingCommand)>
232 };
233
234 let failure_handler = {
235 let cache = cache;
236 let clock = clock.clone();
237 Box::new(move |command: TradingCommand| {
238 let reason = OrderDeniedReason::RateLimitExceeded.to_string();
239
240 match command {
241 TradingCommand::SubmitOrder(submit_order) => {
242 log::warn!(
243 "SubmitOrder for {} DENIED: {reason}",
244 submit_order.client_order_id,
245 );
246
247 Self::handle_submit_order_cache(&cache, &submit_order);
248
249 let denied = Self::create_order_denied(&submit_order, &reason, &clock);
250
251 let endpoint = MessagingSwitchboard::exec_engine_process();
252 msgbus::send_order_event(endpoint, denied);
253 }
254 TradingCommand::SubmitOrderList(submit_order_list) => {
255 log::warn!(
256 "SubmitOrderList for {} DENIED: {reason}",
257 submit_order_list.order_list.id,
258 );
259
260 let orders: Vec<OrderAny> = cache.borrow().orders_for_ids(
261 &submit_order_list.order_list.client_order_ids,
262 &submit_order_list,
263 );
264
265 let timestamp = clock.borrow().timestamp_ns();
266
267 for order in &orders {
268 if order.status() == OrderStatus::Initialized {
269 let denied = OrderEventAny::Denied(OrderDenied::new(
270 order.trader_id(),
271 order.strategy_id(),
272 order.instrument_id(),
273 order.client_order_id(),
274 reason.as_str().into(),
275 UUID4::new(),
276 timestamp,
277 timestamp,
278 ));
279 let endpoint = MessagingSwitchboard::exec_engine_process();
280 msgbus::send_order_event(endpoint, denied);
281 }
282 }
283 }
284 _ => {
285 log::error!("Unexpected command type in submit throttler: {command}");
286 }
287 }
288 }) as Box<dyn Fn(TradingCommand)>
289 };
290
291 Throttler::new(
292 config.max_order_submit,
293 clock,
294 "ORDER_SUBMIT_THROTTLER",
295 success_handler,
296 Some(failure_handler),
297 Ustr::from(UUID4::new().as_str()),
298 )
299 }
300
301 fn create_modify_order_throttler(
302 config: &RiskEngineConfig,
303 clock: Rc<RefCell<dyn Clock>>,
304 cache: Rc<RefCell<Cache>>,
305 ) -> Throttler<ModifyOrder, ModifyOrderFn> {
306 let success_handler = {
307 Box::new(move |order: ModifyOrder| {
308 let endpoint = MessagingSwitchboard::exec_engine_queue_execute();
309 msgbus::send_trading_command(endpoint, TradingCommand::ModifyOrder(order));
310 }) as Box<dyn Fn(ModifyOrder)>
311 };
312
313 let failure_handler = {
314 let cache = cache;
315 let clock = clock.clone();
316 Box::new(move |order: ModifyOrder| {
317 let reason = "Exceeded MAX_ORDER_MODIFY_RATE";
318 log::warn!(
319 "SubmitOrder for {} DENIED: {}",
320 order.client_order_id,
321 reason
322 );
323
324 let Some(order) = Self::get_existing_order(&cache, &order) else {
325 return;
326 };
327
328 let rejected = Self::create_modify_rejected(&order, reason, &clock);
329
330 let endpoint = MessagingSwitchboard::exec_engine_process();
331 msgbus::send_order_event(endpoint, rejected);
332 }) as Box<dyn Fn(ModifyOrder)>
333 };
334
335 Throttler::new(
336 config.max_order_modify,
337 clock,
338 "ORDER_MODIFY_THROTTLER",
339 success_handler,
340 Some(failure_handler),
341 Ustr::from(UUID4::new().as_str()),
342 )
343 }
344
345 fn handle_submit_order_cache(cache: &Rc<RefCell<Cache>>, submit_order: &SubmitOrder) {
346 let cache = cache.borrow();
347 if !cache.order_exists(&submit_order.client_order_id) {
348 log::error!(
349 "Order not found in cache for client_order_id: {}",
350 submit_order.client_order_id
351 );
352 }
353 }
354
355 fn get_existing_order(cache: &Rc<RefCell<Cache>>, order: &ModifyOrder) -> Option<OrderAny> {
356 let cache = cache.borrow();
357 if let Some(order) = cache.order(&order.client_order_id) {
358 Some(order.clone())
359 } else {
360 log::error!(
361 "Order with command.client_order_id: {} not found",
362 order.client_order_id
363 );
364 None
365 }
366 }
367
368 fn create_order_denied(
369 submit_order: &SubmitOrder,
370 reason: &str,
371 clock: &Rc<RefCell<dyn Clock>>,
372 ) -> OrderEventAny {
373 let timestamp = clock.borrow().timestamp_ns();
374 OrderEventAny::Denied(OrderDenied::new(
375 submit_order.trader_id,
376 submit_order.strategy_id,
377 submit_order.instrument_id,
378 submit_order.client_order_id,
379 reason.into(),
380 UUID4::new(),
381 timestamp,
382 timestamp,
383 ))
384 }
385
386 fn create_modify_rejected(
387 order: &OrderAny,
388 reason: &str,
389 clock: &Rc<RefCell<dyn Clock>>,
390 ) -> OrderEventAny {
391 let timestamp = clock.borrow().timestamp_ns();
392 OrderEventAny::ModifyRejected(OrderModifyRejected::new(
393 order.trader_id(),
394 order.strategy_id(),
395 order.instrument_id(),
396 order.client_order_id(),
397 reason.into(),
398 UUID4::new(),
399 timestamp,
400 timestamp,
401 false,
402 order.venue_order_id(),
403 order.account_id(),
404 ))
405 }
406
407 pub fn execute(&mut self, command: TradingCommand) {
410 self.command_count += 1;
411
412 self.handle_command(command);
414 }
415
416 #[expect(
418 clippy::needless_pass_by_value,
419 reason = "message bus dispatch passes owned order events"
420 )]
421 pub fn process(&mut self, event: OrderEventAny) {
422 self.event_count += 1;
423
424 self.handle_event(&event);
426 }
427
428 fn process_position_event(&mut self, event: &PositionEvent) {
429 self.event_count += 1;
430
431 self.handle_position_event(event);
432 }
433
434 pub fn set_trading_state(&mut self, state: TradingState) {
436 if state == self.trading_state {
437 log::warn!("No change to trading state: already set to {state:?}");
438 return;
439 }
440
441 self.trading_state = state;
442
443 let ts_now = self.clock.borrow().timestamp_ns();
444 let trader_id = get_message_bus().borrow().trader_id;
445
446 let config = self.config_as_map();
447 let event =
448 TradingStateChanged::new(trader_id, state, config, UUID4::new(), ts_now, ts_now);
449
450 msgbus::publish_any("events.risk".into(), &event);
451
452 log::info!("Trading state set to {state:?}");
453 }
454
455 pub fn set_max_notional_per_order(&mut self, instrument_id: InstrumentId, new_value: Decimal) {
457 self.max_notional_per_order.insert(instrument_id, new_value);
458
459 let new_value_str = new_value.to_string();
460 log::info!("Set MAX_NOTIONAL_PER_ORDER: {instrument_id} {new_value_str}");
461 }
462
463 pub fn start(&mut self) {
465 log::info!("Started");
466 }
467
468 pub fn stop(&mut self) {
470 log::info!("Stopped");
471 }
472
473 pub fn reset(&mut self) {
475 self.throttled_submit.reset();
476 self.throttled_modify_order.reset();
477 self.max_notional_per_order = self.config.max_notional_per_order.clone();
478 self.trading_state = TradingState::Active;
479 self.command_count = 0;
480 self.event_count = 0;
481
482 log::info!("Reset");
483 }
484
485 pub fn dispose(&mut self) {
487 log::info!("Disposed");
488 }
489
490 #[must_use]
492 pub fn clock(&self) -> &Rc<RefCell<dyn Clock>> {
493 &self.clock
494 }
495
496 #[must_use]
498 pub fn cache(&self) -> &Rc<RefCell<Cache>> {
499 &self.cache
500 }
501
502 pub fn portfolio_mut(&mut self) -> &mut Portfolio {
504 &mut self.portfolio
505 }
506
507 #[must_use]
509 pub const fn config(&self) -> &RiskEngineConfig {
510 &self.config
511 }
512
513 #[must_use]
515 pub const fn command_count(&self) -> u64 {
516 self.command_count
517 }
518
519 #[must_use]
521 pub const fn event_count(&self) -> u64 {
522 self.event_count
523 }
524
525 #[must_use]
527 pub const fn trading_state(&self) -> TradingState {
528 self.trading_state
529 }
530
531 #[must_use]
533 pub const fn max_notional_per_order(&self) -> &AHashMap<InstrumentId, Decimal> {
534 &self.max_notional_per_order
535 }
536
537 fn config_as_map(&self) -> IndexMap<String, String> {
538 let mut map = IndexMap::new();
539 map.insert("bypass".to_string(), self.config.bypass.to_string());
540 map.insert(
541 "max_order_submit_rate".to_string(),
542 format_rate_limit(&self.config.max_order_submit),
543 );
544 map.insert(
545 "max_order_modify_rate".to_string(),
546 format_rate_limit(&self.config.max_order_modify),
547 );
548
549 for (instrument_id, value) in &self.max_notional_per_order {
550 map.insert(
551 format!("max_notional_per_order.{instrument_id}"),
552 value.to_string(),
553 );
554 }
555
556 let mut full_position_exit_venues = self
557 .config
558 .full_position_exit_venues
559 .iter()
560 .map(ToString::to_string)
561 .collect::<Vec<_>>();
562 full_position_exit_venues.sort_unstable();
563 map.insert(
564 "full_position_exit_venues".to_string(),
565 full_position_exit_venues.join(","),
566 );
567
568 map.insert("debug".to_string(), self.config.debug.to_string());
569 map
570 }
571
572 fn handle_command(&mut self, command: TradingCommand) {
573 if self.config.debug {
574 log::debug!("{CMD}{RECV} {command:?}");
575 }
576
577 match command {
578 TradingCommand::SubmitOrder(submit_order) => self.handle_submit_order(submit_order),
579 TradingCommand::SubmitOrderList(submit_order_list) => {
580 self.handle_submit_order_list(submit_order_list);
581 }
582 TradingCommand::ModifyOrder(modify_order) => self.handle_modify_order(modify_order),
583 TradingCommand::ModifyOrders(modify_orders) => {
584 self.handle_batch_modify_orders(modify_orders);
585 }
586 TradingCommand::QueryAccount(query_account) => {
587 Self::send_to_execution(TradingCommand::QueryAccount(query_account));
588 }
589 _ => {
590 log::error!("Cannot handle command: {command}");
591 }
592 }
593 }
594
595 fn handle_submit_order(&mut self, command: SubmitOrder) {
596 if self.config.bypass {
597 Self::send_to_execution(TradingCommand::SubmitOrder(command));
598 return;
599 }
600
601 let order = {
602 let cache = self.cache.borrow();
603 let Some(order) = cache.order(&command.client_order_id) else {
604 log::error!(
605 "Cannot handle submit order: order not found in cache for {}",
606 command.client_order_id
607 );
608 return;
609 };
610 order.clone()
611 };
612
613 if let Some(position_id) = command.position_id
614 && order.is_reduce_only()
615 {
616 let position_exists = {
617 let cache = self.cache.borrow();
618 cache
619 .position(&position_id)
620 .map(|pos| (pos.side, pos.quantity))
621 };
622
623 if let Some((pos_side, pos_quantity)) = position_exists {
624 if !order.would_reduce_only(pos_side, pos_quantity) {
625 self.deny_command(
626 TradingCommand::SubmitOrder(command),
627 &OrderDeniedReason::ReduceOnlyWouldIncreasePosition { position_id }
628 .to_string(),
629 );
630 return; }
632 } else {
633 self.deny_command(
634 TradingCommand::SubmitOrder(command),
635 &OrderDeniedReason::PositionNotFound { position_id }.to_string(),
636 );
637 return;
638 }
639 }
640
641 let instrument_exists = {
642 let cache = self.cache.borrow();
643 cache.instrument(&command.instrument_id).cloned()
644 };
645
646 let Some(instrument) = instrument_exists else {
647 self.deny_command(
648 TradingCommand::SubmitOrder(command.clone()),
649 &OrderDeniedReason::InstrumentNotFound {
650 instrument_id: command.instrument_id,
651 }
652 .to_string(),
653 );
654 return; };
656
657 let full_position_exit = self.is_full_position_exit(&command, &instrument, &order);
658
659 if !self.check_order(&instrument, &order, full_position_exit) {
660 return; }
662
663 if !self.check_orders_risk(&instrument, &[order], full_position_exit) {
664 return; }
666
667 self.execution_gateway(&instrument, TradingCommand::SubmitOrder(command));
669 }
670
671 fn is_full_position_exit(
672 &self,
673 command: &SubmitOrder,
674 instrument: &InstrumentAny,
675 order: &OrderAny,
676 ) -> bool {
677 if !self
678 .config
679 .full_position_exit_venues
680 .contains(&instrument.id().venue)
681 {
682 return false;
683 }
684
685 if !Self::has_full_position_exit_intent(command) {
686 return false;
687 }
688
689 if command.instrument_id != order.instrument_id() {
690 return false;
691 }
692
693 if !Self::is_full_position_exit_instrument(instrument)
694 || !Self::is_full_position_exit_order(order)
695 {
696 return false;
697 }
698
699 self.full_position_exit_reduces(command, order)
700 }
701
702 fn has_full_position_exit_intent(command: &SubmitOrder) -> bool {
703 command
704 .params
705 .as_ref()
706 .and_then(|params| params.get_bool(PARAMS_CLOSE_POSITION))
707 .unwrap_or(false)
708 }
709
710 fn is_full_position_exit_instrument(instrument: &InstrumentAny) -> bool {
711 match instrument {
712 InstrumentAny::CryptoFuture(_) | InstrumentAny::CryptoPerpetual(_) => true,
713 InstrumentAny::PerpetualContract(_) => !instrument.is_inverse(),
714 _ => false,
715 }
716 }
717
718 fn is_full_position_exit_order(order: &OrderAny) -> bool {
719 matches!(
720 order.order_type(),
721 OrderType::StopMarket | OrderType::MarketIfTouched
722 ) && order.trigger_price().is_some()
723 && !order.is_reduce_only()
724 && order.quantity().is_positive()
725 }
726
727 fn full_position_exit_reduces(&self, command: &SubmitOrder, order: &OrderAny) -> bool {
728 let Some(position_id) = command.position_id else {
729 return false;
730 };
731 let position = {
732 let cache = self.cache.borrow();
733 if cache.position_id(&order.client_order_id()).copied() != Some(position_id) {
734 return false;
735 }
736 cache.position(&position_id).map(|position| {
737 (
738 position.is_open(),
739 position.instrument_id,
740 position.side,
741 position.quantity,
742 )
743 })
744 };
745 let Some((is_open, position_instrument_id, position_side, position_quantity)) = position
746 else {
747 return false;
748 };
749
750 is_open
751 && position_instrument_id == order.instrument_id()
752 && matches!(
753 (order.order_side(), position_side),
754 (OrderSide::Buy, PositionSide::Short) | (OrderSide::Sell, PositionSide::Long)
755 )
756 && order.would_reduce_only(position_side, position_quantity)
757 }
758
759 fn handle_submit_order_list(&mut self, command: SubmitOrderList) {
760 if self.config.bypass {
761 Self::send_to_execution(TradingCommand::SubmitOrderList(command));
762 return;
763 }
764
765 let orders: Vec<OrderAny> = self
766 .cache
767 .borrow()
768 .orders_for_ids(&command.order_list.client_order_ids, &command);
769
770 if orders.len() != command.order_list.client_order_ids.len() {
771 self.deny_order_list(
772 &orders,
773 &OrderDeniedReason::OrderListIncomplete {
774 order_list_id: command.order_list.id,
775 }
776 .to_string(),
777 );
778 return; }
780
781 let mut instruments: AHashMap<InstrumentId, InstrumentAny> = AHashMap::new();
785
786 for order in &orders {
787 let instrument_id = order.instrument_id();
788 if instruments.contains_key(&instrument_id) {
789 continue;
790 }
791 let resolved = self.cache.borrow().instrument(&instrument_id).cloned();
792 let Some(instrument) = resolved else {
793 self.deny_command(
794 TradingCommand::SubmitOrderList(command),
795 &OrderDeniedReason::InstrumentNotFound { instrument_id }.to_string(),
796 );
797 return; };
799 instruments.insert(instrument_id, instrument);
800 }
801
802 for order in &orders {
803 let Some(instrument) = instruments.get(&order.instrument_id()) else {
804 self.deny_order(
805 order,
806 &OrderDeniedReason::InstrumentNotFound {
807 instrument_id: order.instrument_id(),
808 }
809 .to_string(),
810 );
811 return; };
813
814 if !self.check_order(instrument, order, false) {
815 return; }
817 }
818
819 let representative = if let Some(instrument) = instruments.get(&command.instrument_id) {
820 instrument.clone()
821 } else {
822 self.deny_order_list(
823 &orders,
824 &OrderDeniedReason::InstrumentNotFound {
825 instrument_id: command.instrument_id,
826 }
827 .to_string(),
828 );
829 return; };
831
832 if !self.check_orders_risk(&representative, &orders, false) {
833 self.deny_order_list(
834 &orders,
835 &OrderDeniedReason::OrderListDenied {
836 order_list_id: command.order_list.id,
837 }
838 .to_string(),
839 );
840 return; }
842
843 self.execution_gateway(&representative, TradingCommand::SubmitOrderList(command));
844 }
845
846 fn handle_modify_order(&mut self, command: ModifyOrder) {
847 if self.config.bypass {
848 Self::send_to_execution(TradingCommand::ModifyOrder(command));
849 return;
850 }
851
852 if !self.validate_modify_order(&command) {
853 return;
854 }
855
856 self.throttled_modify_order.send(command);
857 }
858
859 fn handle_batch_modify_orders(&mut self, command: BatchModifyOrders) {
860 if self.config.bypass {
861 Self::send_to_execution(TradingCommand::ModifyOrders(command));
862 return;
863 }
864
865 if command.modifies.is_empty() {
866 log::warn!("Cannot handle BatchModifyOrders: no modify commands");
867 return;
868 }
869
870 let mut rejected_client_order_ids = Vec::new();
871 let mut valid = true;
872
873 for modify in &command.modifies {
874 if modify.instrument_id != command.instrument_id {
875 if let Some(order) = self
876 .cache
877 .borrow()
878 .order(&modify.client_order_id)
879 .map(|o| o.clone())
880 {
881 self.reject_modify_order(
882 &order,
883 &format!(
884 "BatchModifyOrders instrument {} does not match child instrument {}",
885 command.instrument_id, modify.instrument_id
886 ),
887 );
888 }
889 rejected_client_order_ids.push(modify.client_order_id);
890 valid = false;
891 continue;
892 }
893
894 if !self.validate_modify_order(modify) {
895 rejected_client_order_ids.push(modify.client_order_id);
896 valid = false;
897 }
898 }
899
900 if !valid {
901 let reason = "BatchModifyOrders rejected because one or more child modifications failed validation";
902
903 for modify in &command.modifies {
904 if rejected_client_order_ids.contains(&modify.client_order_id) {
905 continue;
906 }
907
908 let Some(order) = Self::get_existing_order(&self.cache, modify) else {
909 continue;
910 };
911
912 self.reject_modify_order(&order, reason);
913 }
914 return;
915 }
916
917 if !self
918 .throttled_modify_order
919 .try_reserve(command.modifies.len())
920 {
921 let reason = "Exceeded MAX_ORDER_MODIFY_RATE";
922
923 for modify in &command.modifies {
924 let Some(order) = Self::get_existing_order(&self.cache, modify) else {
925 continue;
926 };
927 self.reject_modify_order(&order, reason);
928 }
929 return;
930 }
931
932 Self::send_to_execution(TradingCommand::ModifyOrders(command));
933 }
934
935 fn validate_modify_order(&self, command: &ModifyOrder) -> bool {
936 let order_exists = {
937 let cache = self.cache.borrow();
938 cache.order(&command.client_order_id).map(|o| o.clone())
939 };
940
941 let Some(order) = order_exists else {
942 log::error!(
943 "ModifyOrder DENIED: Order with command.client_order_id: {} not found",
944 command.client_order_id
945 );
946 return false;
947 };
948
949 if order.is_closed() {
950 self.reject_modify_order(
951 &order,
952 &format!(
953 "Order with command.client_order_id: {} already closed",
954 command.client_order_id
955 ),
956 );
957 return false;
958 } else if order.status() == OrderStatus::PendingCancel {
959 self.reject_modify_order(
960 &order,
961 &format!(
962 "Order with command.client_order_id: {} is already pending cancel",
963 command.client_order_id
964 ),
965 );
966 return false;
967 }
968
969 let maybe_instrument = {
970 let cache = self.cache.borrow();
971 cache.instrument(&command.instrument_id).cloned()
972 };
973
974 let Some(instrument) = maybe_instrument else {
975 self.reject_modify_order(
976 &order,
977 &format!("no instrument found for {:?}", command.instrument_id),
978 );
979 return false;
980 };
981
982 let mut reason = Self::check_price(&instrument, command.price, OrderPriceField::Price);
984 if let Some(reason) = reason {
985 self.reject_modify_order(&order, &reason.to_string());
986 return false;
987 }
988
989 reason = Self::check_price(
991 &instrument,
992 command.trigger_price,
993 OrderPriceField::TriggerPrice,
994 );
995
996 if let Some(reason) = reason {
997 self.reject_modify_order(&order, &reason.to_string());
998 return false;
999 }
1000
1001 reason = Self::check_quantity(
1003 &instrument,
1004 command.quantity,
1005 order.is_quote_quantity(),
1006 false,
1007 );
1008
1009 if let Some(reason) = reason {
1010 self.reject_modify_order(&order, &reason.to_string());
1011 return false;
1012 }
1013
1014 match self.trading_state {
1016 TradingState::Halted => {
1017 self.reject_modify_order(&order, "TradingState is HALTED: Cannot modify order");
1018 return false;
1019 }
1020 TradingState::Reducing => {
1021 if let Some(quantity) = command.quantity
1022 && quantity > order.quantity()
1023 && ((order.is_buy() && self.portfolio.is_net_long(&instrument.id()))
1024 || (order.is_sell() && self.portfolio.is_net_short(&instrument.id())))
1025 {
1026 self.reject_modify_order(
1027 &order,
1028 &format!(
1029 "TradingState is REDUCING and update will increase exposure {}",
1030 instrument.id()
1031 ),
1032 );
1033 return false;
1034 }
1035 }
1036 TradingState::Active => {}
1037 }
1038
1039 true
1040 }
1041
1042 fn check_order(
1043 &self,
1044 instrument: &InstrumentAny,
1045 order: &OrderAny,
1046 full_position_exit: bool,
1047 ) -> bool {
1048 if !self.check_order_price(instrument, order)
1049 || !self.check_order_quantity(instrument, order, full_position_exit)
1050 {
1051 return false; }
1053
1054 if order.time_in_force() == TimeInForce::Gtd {
1055 let Some(expire_time) = order.expire_time() else {
1056 self.deny_order(order, &OrderDeniedReason::MissingExpireTime.to_string());
1057 return false; };
1059
1060 if expire_time <= self.clock.borrow().timestamp_ns() {
1061 self.deny_order(
1062 order,
1063 &OrderDeniedReason::ExpireTimeInPast {
1064 expire_time: expire_time.to_rfc3339(),
1065 }
1066 .to_string(),
1067 );
1068 return false; }
1070 }
1071
1072 true
1073 }
1074
1075 fn check_order_price(&self, instrument: &InstrumentAny, order: &OrderAny) -> bool {
1076 if order.price().is_some() {
1077 let reason = Self::check_price(instrument, order.price(), OrderPriceField::Price);
1078 if let Some(reason) = reason {
1079 self.deny_order(order, &reason.to_string());
1080 return false; }
1082 }
1083
1084 if order.trigger_price().is_some() {
1085 let reason = Self::check_price(
1086 instrument,
1087 order.trigger_price(),
1088 OrderPriceField::TriggerPrice,
1089 );
1090
1091 if let Some(reason) = reason {
1092 self.deny_order(order, &reason.to_string());
1093 return false; }
1095 }
1096
1097 true
1098 }
1099
1100 fn check_order_quantity(
1101 &self,
1102 instrument: &InstrumentAny,
1103 order: &OrderAny,
1104 full_position_exit: bool,
1105 ) -> bool {
1106 let reason = Self::check_quantity(
1107 instrument,
1108 Some(order.quantity()),
1109 order.is_quote_quantity(),
1110 full_position_exit,
1111 );
1112
1113 if let Some(reason) = reason {
1114 self.deny_order(order, &reason.to_string());
1115 return false; }
1117
1118 true
1119 }
1120
1121 fn check_orders_risk(
1122 &self,
1123 instrument: &InstrumentAny,
1124 orders: &[OrderAny],
1125 full_position_exit: bool,
1126 ) -> bool {
1127 let mut orders_by_account: AHashMap<Option<AccountId>, Vec<&OrderAny>> = AHashMap::new();
1128 for order in orders {
1129 orders_by_account
1130 .entry(order.account_id())
1131 .or_default()
1132 .push(order);
1133 }
1134
1135 for (account_id, account_orders) in &orders_by_account {
1136 if !self.check_orders_risk_for_account(
1137 instrument,
1138 account_orders,
1139 *account_id,
1140 full_position_exit,
1141 ) {
1142 return false;
1143 }
1144 }
1145
1146 true
1147 }
1148
1149 #[allow(
1150 clippy::too_many_lines,
1151 reason = "risk checks keep related denial branches together for auditability"
1152 )]
1153 fn check_orders_risk_for_account(
1154 &self,
1155 instrument: &InstrumentAny,
1156 orders: &[&OrderAny],
1157 account_id: Option<AccountId>,
1158 full_position_exit: bool,
1159 ) -> bool {
1160 let mut max_notional: Option<Money> = None;
1161
1162 let max_notional_setting = self.max_notional_per_order.get(&instrument.id());
1164 if let Some(max_notional_setting_val) = max_notional_setting.copied() {
1165 let Ok(max_notional_value) =
1166 Money::from_decimal(max_notional_setting_val, instrument.quote_currency())
1167 else {
1168 for order in orders {
1169 self.deny_order(
1170 order,
1171 &OrderDeniedReason::InvalidMaxNotionalPerOrder {
1172 instrument_id: instrument.id(),
1173 value: max_notional_setting_val,
1174 }
1175 .to_string(),
1176 );
1177 }
1178 return false; };
1180 max_notional = Some(max_notional_value);
1181 }
1182
1183 let mut market_prices = Vec::with_capacity(orders.len());
1184
1185 for order in orders {
1186 let price = match order {
1187 OrderAny::Market(_) | OrderAny::MarketToLimit(_) => {
1188 self.market_order_price(instrument.id(), order.order_side())
1189 }
1190 _ => None,
1191 };
1192
1193 market_prices.push(price);
1194 }
1195
1196 let resolved_account = {
1198 let cache = self.cache.borrow();
1199
1200 if let Some(account_id) = account_id {
1201 cache
1202 .account(&account_id)
1203 .map(|account| account.clone_without_events())
1204 } else {
1205 cache
1206 .account_for_venue(&instrument.id().venue)
1207 .map(|account| account.clone_without_events())
1208 }
1209 };
1210
1211 let Some(mut account) = resolved_account else {
1212 log::debug!(
1213 "Cannot find account for venue {} (account_id={account_id:?})",
1214 instrument.id().venue
1215 );
1216
1217 for (&order, price) in orders.iter().zip(&market_prices) {
1218 if matches!(order, OrderAny::Market(_) | OrderAny::MarketToLimit(_))
1219 && price.is_none()
1220 {
1221 self.deny_no_market_price(instrument.id(), order);
1222 return false;
1223 }
1224 }
1225
1226 return true;
1227 };
1228
1229 let is_margin = matches!(account, AccountAny::Margin(_));
1230 let is_betting = matches!(account, AccountAny::Betting(_));
1231 let is_wallet = matches!(account, AccountAny::Wallet(_));
1232 let free = match &account {
1233 AccountAny::Margin(margin) => margin.balance_free(Some(instrument.quote_currency())),
1234 AccountAny::Cash(cash) => cash.balance_free(Some(instrument.quote_currency())),
1235 AccountAny::Betting(betting) => betting.balance_free(Some(instrument.quote_currency())),
1236 AccountAny::Wallet(wallet) => Some(
1237 wallet
1238 .balance_free(Some(instrument.quote_currency()))
1239 .unwrap_or_else(|| Money::zero(instrument.quote_currency())),
1240 ),
1241 };
1242 let allow_borrowing = match &account {
1243 AccountAny::Cash(cash) => cash.allow_borrowing,
1244 AccountAny::Margin(_) | AccountAny::Betting(_) | AccountAny::Wallet(_) => false,
1245 };
1246
1247 if self.config.debug {
1248 log::debug!("Free balance: {free:?}");
1249 }
1250
1251 let (net_long_qty_raw, pending_sell_qty_raw) = {
1254 let cache = self.cache.borrow();
1255 let long_qty: QuantityRaw = cache
1256 .positions_open(
1257 None,
1258 Some(&instrument.id()),
1259 None,
1260 None,
1261 Some(PositionSide::Long),
1262 )
1263 .iter()
1264 .map(|pos| pos.quantity.raw)
1265 .sum();
1266 let pending_sells: QuantityRaw = cache
1267 .orders_open(
1268 None,
1269 Some(&instrument.id()),
1270 None,
1271 None,
1272 Some(OrderSide::Sell),
1273 )
1274 .iter()
1275 .map(|ord| ord.leaves_qty().raw)
1276 .sum();
1277 (long_qty, pending_sells)
1278 };
1279
1280 let available_long_qty_raw = net_long_qty_raw.saturating_sub(pending_sell_qty_raw);
1282
1283 if self.config.debug && net_long_qty_raw > 0 {
1284 log::debug!(
1285 "Net LONG qty (raw): {net_long_qty_raw}, pending sells: {pending_sell_qty_raw}, available: {available_long_qty_raw}"
1286 );
1287 }
1288
1289 let available_short_qty_raw = if is_margin || is_betting {
1291 let cache = self.cache.borrow();
1292 let short_qty: QuantityRaw = cache
1293 .positions_open(
1294 None,
1295 Some(&instrument.id()),
1296 None,
1297 None,
1298 Some(PositionSide::Short),
1299 )
1300 .iter()
1301 .map(|pos| pos.quantity.raw)
1302 .sum();
1303 let pending_buys: QuantityRaw = cache
1304 .orders_open(
1305 None,
1306 Some(&instrument.id()),
1307 None,
1308 None,
1309 Some(OrderSide::Buy),
1310 )
1311 .iter()
1312 .map(|ord| ord.leaves_qty().raw)
1313 .sum();
1314
1315 if self.config.debug && short_qty > 0 {
1316 log::debug!(
1317 "Net SHORT qty (raw): {short_qty}, pending buys: {pending_buys}, available: {}",
1318 short_qty.saturating_sub(pending_buys)
1319 );
1320 }
1321
1322 short_qty.saturating_sub(pending_buys)
1323 } else {
1324 0
1325 };
1326
1327 let mut cum_sell_qty_raw: QuantityRaw = 0;
1329 let mut cum_buy_qty_raw: QuantityRaw = 0;
1330
1331 let mut cum_notional_buy: Option<Money> = None;
1332 let mut cum_notional_sell: Option<Money> = None;
1333 let mut cum_margin_required: Option<Money> = None;
1334 let mut base_currency: Option<Currency> = None;
1335
1336 for (&order, market_price) in orders.iter().zip(market_prices) {
1337 let last_px = match order {
1339 OrderAny::Market(_) | OrderAny::MarketToLimit(_) => {
1340 let Some(price) = market_price else {
1341 let is_reducing = !is_wallet
1342 && (order.is_reduce_only()
1343 || (order.is_sell()
1344 && (cum_sell_qty_raw + order.quantity().raw)
1345 <= available_long_qty_raw));
1346
1347 if !order.is_quote_quantity()
1348 && order.is_sell()
1349 && !is_reducing
1350 && let Some(unleveraged) = cash_or_wallet_account(&account)
1351 && unleveraged.base_currency().is_none()
1352 && let Some(base_currency) = instrument.base_currency()
1353 && !self.check_cash_sell_balance(
1354 unleveraged,
1355 allow_borrowing,
1356 order,
1357 order.quantity(),
1358 base_currency,
1359 &mut cum_notional_sell,
1360 )
1361 {
1362 return false;
1363 }
1364
1365 self.deny_no_market_price(instrument.id(), order);
1366 return false;
1367 };
1368
1369 Some(price)
1370 }
1371 OrderAny::StopMarket(_) | OrderAny::MarketIfTouched(_) => order.trigger_price(),
1372 OrderAny::TrailingStopMarket(_) | OrderAny::TrailingStopLimit(_) => {
1373 if let Some(trigger_price) = order.trigger_price() {
1374 Some(trigger_price)
1375 } else {
1376 let Some(offset_type) = order.trailing_offset_type() else {
1378 self.deny_order(
1379 order,
1380 &OrderDeniedReason::MissingTrailingOffsetType.to_string(),
1381 );
1382 return false; };
1384
1385 if !matches!(
1386 offset_type,
1387 TrailingOffsetType::Price
1388 | TrailingOffsetType::BasisPoints
1389 | TrailingOffsetType::Ticks
1390 ) {
1391 self.deny_order(
1392 order,
1393 &OrderDeniedReason::UnsupportedTrailingOffsetType { offset_type }
1394 .to_string(),
1395 );
1396 return false;
1397 }
1398
1399 let Some(trigger_type) = order.trigger_type() else {
1400 self.deny_order(
1401 order,
1402 &OrderDeniedReason::MissingTriggerType.to_string(),
1403 );
1404 return false; };
1406 let Some(trailing_offset) = order.trailing_offset() else {
1407 self.deny_order(
1408 order,
1409 &OrderDeniedReason::MissingTrailingOffset.to_string(),
1410 );
1411 return false; };
1413
1414 let calc_result: Result<Option<Price>, String> = {
1417 let cache = self.cache.borrow();
1418
1419 if trigger_type == TriggerType::BidAsk {
1420 if let Some(quote) = cache.quote(&instrument.id()) {
1421 trailing_stop_calculate_with_bid_ask(
1422 instrument.price_increment(),
1423 offset_type,
1424 order.order_side(),
1425 trailing_offset,
1426 quote.bid_price,
1427 quote.ask_price,
1428 )
1429 .map(Some)
1430 .map_err(|e| e.to_string())
1431 } else {
1432 log::warn!(
1433 "Cannot check {} order risk: no trigger price set and no bid/ask quotes available for {}",
1434 order.order_type(),
1435 instrument.id()
1436 );
1437 Ok(None)
1438 }
1439 } else if let Some(last_trade) = cache.trade(&instrument.id()) {
1440 trailing_stop_calculate_with_last(
1441 instrument.price_increment(),
1442 offset_type,
1443 order.order_side(),
1444 trailing_offset,
1445 last_trade.price,
1446 )
1447 .map(Some)
1448 .map_err(|e| e.to_string())
1449 } else if trigger_type == TriggerType::LastOrBidAsk {
1450 if let Some(quote) = cache.quote(&instrument.id()) {
1451 trailing_stop_calculate_with_bid_ask(
1452 instrument.price_increment(),
1453 offset_type,
1454 order.order_side(),
1455 trailing_offset,
1456 quote.bid_price,
1457 quote.ask_price,
1458 )
1459 .map(Some)
1460 .map_err(|e| e.to_string())
1461 } else {
1462 log::warn!(
1463 "Cannot check {} order risk: no trigger price set and no market data available for {}",
1464 order.order_type(),
1465 instrument.id()
1466 );
1467 Ok(None)
1468 }
1469 } else {
1470 log::warn!(
1471 "Cannot check {} order risk: no trigger price set and no market data available for {}",
1472 order.order_type(),
1473 instrument.id()
1474 );
1475 Ok(None)
1476 }
1477 };
1478 match calc_result {
1481 Ok(Some(trigger)) => Some(trigger),
1482 Ok(None) => {
1483 continue;
1484 }
1485 Err(e) => {
1486 self.deny_order(
1487 order,
1488 &OrderDeniedReason::TrailingStopCalculationFailed { detail: e }
1489 .to_string(),
1490 );
1491 return false;
1492 }
1493 }
1494 }
1495 }
1496 _ => order.price(),
1497 };
1498
1499 let Some(last_px) = last_px else {
1500 log::error!("Cannot check order risk: no price available");
1501 continue;
1502 };
1503
1504 let effective_price = if order.is_quote_quantity()
1506 && !instrument.is_inverse()
1507 && matches!(order, OrderAny::Limit(_) | OrderAny::StopLimit(_))
1508 {
1509 let cache = self.cache.borrow();
1511 if let Some(quote_tick) = cache.quote(&instrument.id()) {
1512 match order.order_side() {
1513 OrderSide::Buy => last_px.min(quote_tick.ask_price),
1515 OrderSide::Sell => last_px.max(quote_tick.bid_price),
1517 }
1518 } else {
1519 last_px }
1521 } else {
1522 last_px
1523 };
1524
1525 let effective_quantity = if order.is_quote_quantity() && !instrument.is_inverse() {
1526 instrument.calculate_base_quantity(order.quantity(), effective_price)
1527 } else {
1528 order.quantity()
1529 };
1530
1531 if !order.is_quote_quantity() && !full_position_exit {
1537 if let Some(max_quantity) = instrument.max_quantity()
1538 && effective_quantity > max_quantity
1539 {
1540 self.deny_order(
1541 order,
1542 &OrderDeniedReason::QuantityExceedsMaximum {
1543 effective_quantity,
1544 max_quantity,
1545 }
1546 .to_string(),
1547 );
1548 return false; }
1550
1551 if let Some(min_quantity) = instrument.min_quantity()
1552 && effective_quantity < min_quantity
1553 {
1554 self.deny_order(
1555 order,
1556 &OrderDeniedReason::QuantityBelowMinimum {
1557 effective_quantity,
1558 min_quantity,
1559 }
1560 .to_string(),
1561 );
1562 return false; }
1564 }
1565
1566 let notional = match instrument.try_calculate_notional_value(
1567 effective_quantity,
1568 last_px,
1569 Some(true),
1570 ) {
1571 Ok(notional) => notional,
1572 Err(e) => {
1573 self.deny_order(
1574 order,
1575 &OrderDeniedReason::NotionalCalculationFailed {
1576 detail: e.to_string(),
1577 }
1578 .to_string(),
1579 );
1580 return false;
1581 }
1582 };
1583
1584 if self.config.debug {
1585 log::debug!("Notional: {notional:?}");
1586 }
1587
1588 if !full_position_exit
1590 && let Some(max_notional_value) = max_notional
1591 && notional > max_notional_value
1592 {
1593 self.deny_order(
1594 order,
1595 &OrderDeniedReason::NotionalExceedsMaxPerOrder {
1596 max_notional: max_notional_value,
1597 notional,
1598 }
1599 .to_string(),
1600 );
1601 return false; }
1603
1604 if !order.is_reduce_only()
1607 && !full_position_exit
1608 && let Some(min_notional) = instrument.min_notional()
1609 && notional.currency == min_notional.currency
1610 && notional < min_notional
1611 {
1612 self.deny_order(
1613 order,
1614 &OrderDeniedReason::NotionalBelowMinimum {
1615 min_notional,
1616 notional,
1617 }
1618 .to_string(),
1619 );
1620 return false; }
1622
1623 if !full_position_exit
1625 && let Some(max_notional) = instrument.max_notional()
1626 && notional.currency == max_notional.currency
1627 && notional > max_notional
1628 {
1629 self.deny_order(
1630 order,
1631 &OrderDeniedReason::NotionalExceedsMaximum {
1632 max_notional,
1633 notional,
1634 }
1635 .to_string(),
1636 );
1637 return false; }
1639
1640 if is_margin {
1641 let margin_req = match &mut account {
1643 AccountAny::Margin(margin) => match margin.calculate_initial_margin(
1644 instrument,
1645 effective_quantity,
1646 last_px,
1647 None,
1648 ) {
1649 Ok(margin) => margin,
1650 Err(e) => {
1651 self.deny_order(
1652 order,
1653 &OrderDeniedReason::InitialMarginCalculationFailed {
1654 detail: e.to_string(),
1655 }
1656 .to_string(),
1657 );
1658 return false;
1659 }
1660 },
1661 _ => unreachable!(),
1662 };
1663
1664 if self.config.debug {
1665 log::debug!("Initial margin required: {margin_req}");
1666 }
1667
1668 let is_reducing = order.is_reduce_only()
1670 || full_position_exit
1671 || (order.is_sell()
1672 && (cum_sell_qty_raw + effective_quantity.raw) <= available_long_qty_raw)
1673 || (order.is_buy()
1674 && (cum_buy_qty_raw + effective_quantity.raw) <= available_short_qty_raw);
1675
1676 if order.is_sell() {
1677 cum_sell_qty_raw += effective_quantity.raw;
1678 } else if order.is_buy() {
1679 cum_buy_qty_raw += effective_quantity.raw;
1680 }
1681
1682 if is_reducing {
1683 if self.config.debug {
1684 log::debug!("Position-reducing order skips margin check");
1685 }
1686 continue;
1687 }
1688
1689 let margin_free = match &account {
1692 AccountAny::Margin(margin) => margin.balance_free(Some(margin_req.currency)),
1693 _ => unreachable!(),
1694 };
1695
1696 let Some(margin_free_val) = margin_free else {
1697 if self.config.debug {
1698 log::debug!(
1699 "No balance for margin currency {}, skipping margin check",
1700 margin_req.currency
1701 );
1702 }
1703 continue;
1704 };
1705
1706 if margin_req > margin_free_val {
1708 self.deny_order(
1709 order,
1710 &OrderDeniedReason::InitialMarginExceedsFreeBalance {
1711 free_balance: margin_free_val,
1712 initial_margin: margin_req,
1713 }
1714 .to_string(),
1715 );
1716 return false;
1717 }
1718
1719 match cum_margin_required.as_mut() {
1721 Some(cum) => {
1722 let Some(total) = cum.checked_add(margin_req) else {
1723 self.deny_order(
1724 order,
1725 &OrderDeniedReason::CumulativeInitialMarginCalculationFailed {
1726 detail: "total exceeds Money bounds".to_string(),
1727 }
1728 .to_string(),
1729 );
1730 return false;
1731 };
1732 *cum = total;
1733 }
1734 None => cum_margin_required = Some(margin_req),
1735 }
1736
1737 if self.config.debug {
1738 log::debug!("Cumulative margin required: {cum_margin_required:?}");
1739 }
1740
1741 if let Some(cum_margin) = cum_margin_required
1742 && cum_margin > margin_free_val
1743 {
1744 self.deny_order(
1745 order,
1746 &OrderDeniedReason::CumulativeInitialMarginExceedsFreeBalance {
1747 free_balance: margin_free_val,
1748 cumulative_initial_margin: cum_margin,
1749 }
1750 .to_string(),
1751 );
1752 return false;
1753 }
1754 } else {
1755 let notional = match instrument.try_calculate_notional_value(
1757 effective_quantity,
1758 last_px,
1759 None,
1760 ) {
1761 Ok(notional) => notional,
1762 Err(e) => {
1763 self.deny_order(
1764 order,
1765 &OrderDeniedReason::NotionalCalculationFailed {
1766 detail: e.to_string(),
1767 }
1768 .to_string(),
1769 );
1770 return false;
1771 }
1772 };
1773 let order_balance_impact = if is_betting {
1774 match &mut account {
1775 AccountAny::Betting(betting) => {
1776 match betting.calculate_balance_locked(
1777 instrument,
1778 order.order_side(),
1779 effective_quantity,
1780 last_px,
1781 None,
1782 ) {
1783 Ok(locked) => {
1784 Money::from_raw(-locked.raw, instrument.quote_currency())
1785 }
1786 Err(e) => {
1787 self.deny_order(
1788 order,
1789 &OrderDeniedReason::BettingBalanceLockedCalculationFailed {
1790 detail: e.to_string(),
1791 }
1792 .to_string(),
1793 );
1794 return false;
1795 }
1796 }
1797 }
1798 _ => unreachable!(),
1799 }
1800 } else {
1801 match order.order_side() {
1802 OrderSide::Buy => Money::from_raw(-notional.raw, notional.currency),
1803 OrderSide::Sell => Money::from_raw(notional.raw, notional.currency),
1804 }
1805 };
1806
1807 if self.config.debug {
1808 log::debug!("Balance impact: {order_balance_impact}");
1809 }
1810
1811 let is_position_reducing = if order.is_buy() {
1813 let reducing = full_position_exit
1814 || (cum_buy_qty_raw + effective_quantity.raw) <= available_short_qty_raw;
1815 cum_buy_qty_raw += effective_quantity.raw;
1816 reducing
1817 } else if order.is_sell() {
1818 let reducing = order.is_reduce_only()
1819 || full_position_exit
1820 || (cum_sell_qty_raw + effective_quantity.raw) <= available_long_qty_raw;
1821 cum_sell_qty_raw += effective_quantity.raw;
1822 reducing
1823 } else {
1824 false
1825 };
1826
1827 if is_position_reducing && !is_wallet {
1828 if self.config.debug {
1829 log::debug!("Position-reducing order skips balance check");
1830 }
1831 continue;
1832 }
1833
1834 if !allow_borrowing
1836 && let Some(free_val) = free
1837 && (free_val.as_decimal() + order_balance_impact.as_decimal()) < Decimal::ZERO
1838 {
1839 self.deny_order(
1840 order,
1841 &OrderDeniedReason::NotionalExceedsFreeBalance {
1842 free_balance: free_val,
1843 notional,
1844 }
1845 .to_string(),
1846 );
1847 return false;
1848 }
1849
1850 if base_currency.is_none() {
1851 base_currency = instrument.base_currency();
1852 }
1853
1854 if order.is_buy() {
1855 match cum_notional_buy.as_mut() {
1856 Some(cum_notional_buy_val) => {
1857 cum_notional_buy_val.raw += -order_balance_impact.raw;
1858 }
1859 None => {
1860 cum_notional_buy = Some(Money::from_raw(
1861 -order_balance_impact.raw,
1862 order_balance_impact.currency,
1863 ));
1864 }
1865 }
1866
1867 if self.config.debug {
1868 log::debug!("Cumulative notional BUY: {cum_notional_buy:?}");
1869 }
1870
1871 if !allow_borrowing
1872 && let (Some(free), Some(cum_notional_buy)) = (free, cum_notional_buy)
1873 && cum_notional_buy > free
1874 {
1875 self.deny_order(
1876 order,
1877 &OrderDeniedReason::CumulativeNotionalExceedsFreeBalance {
1878 free_balance: free,
1879 cumulative_notional: cum_notional_buy,
1880 }
1881 .to_string(),
1882 );
1883 return false; }
1885 } else if order.is_sell() {
1886 if is_betting {
1887 match cum_notional_sell.as_mut() {
1888 Some(cum_notional_sell_val) => {
1889 cum_notional_sell_val.raw += -order_balance_impact.raw;
1890 }
1891 None => {
1892 cum_notional_sell = Some(Money::from_raw(
1893 -order_balance_impact.raw,
1894 order_balance_impact.currency,
1895 ));
1896 }
1897 }
1898
1899 if self.config.debug {
1900 log::debug!("Cumulative betting SELL liability: {cum_notional_sell:?}");
1901 }
1902
1903 if !allow_borrowing
1904 && let (Some(free), Some(cum_notional_sell)) = (free, cum_notional_sell)
1905 && cum_notional_sell > free
1906 {
1907 self.deny_order(
1908 order,
1909 &OrderDeniedReason::CumulativeNotionalExceedsFreeBalance {
1910 free_balance: free,
1911 cumulative_notional: cum_notional_sell,
1912 }
1913 .to_string(),
1914 );
1915 return false;
1916 }
1917
1918 continue;
1919 }
1920
1921 let has_base_currency = match &account {
1922 AccountAny::Margin(_) => false,
1923 AccountAny::Cash(cash) => cash.base_currency.is_some(),
1924 AccountAny::Betting(betting) => betting.base_currency.is_some(),
1925 AccountAny::Wallet(wallet) => wallet.base_currency.is_some(),
1926 };
1927
1928 if has_base_currency {
1929 match cum_notional_sell.as_mut() {
1930 Some(cum_notional_sell_val) => {
1931 cum_notional_sell_val.raw += order_balance_impact.raw;
1932 }
1933 None => {
1934 cum_notional_sell = Some(Money::from_raw(
1935 order_balance_impact.raw,
1936 order_balance_impact.currency,
1937 ));
1938 }
1939 }
1940
1941 if self.config.debug {
1942 log::debug!("Cumulative notional SELL: {cum_notional_sell:?}");
1943 }
1944
1945 if !allow_borrowing
1946 && let (Some(free), Some(cum_notional_sell)) = (free, cum_notional_sell)
1947 && cum_notional_sell > free
1948 {
1949 self.deny_order(
1950 order,
1951 &OrderDeniedReason::CumulativeNotionalExceedsFreeBalance {
1952 free_balance: free,
1953 cumulative_notional: cum_notional_sell,
1954 }
1955 .to_string(),
1956 );
1957 return false; }
1959 } else if let Some(base_currency) = base_currency {
1960 let Some(unleveraged) = cash_or_wallet_account(&account) else {
1961 unreachable!()
1962 };
1963
1964 if !self.check_cash_sell_balance(
1965 unleveraged,
1966 allow_borrowing,
1967 order,
1968 effective_quantity,
1969 base_currency,
1970 &mut cum_notional_sell,
1971 ) {
1972 return false;
1973 }
1974 }
1975 }
1976 }
1977 }
1978
1979 true }
1982
1983 fn market_order_price(
1984 &self,
1985 instrument_id: InstrumentId,
1986 order_side: OrderSide,
1987 ) -> Option<Price> {
1988 let price_type = match order_side {
1989 OrderSide::Buy => PriceType::Ask,
1990 OrderSide::Sell => PriceType::Bid,
1991 };
1992
1993 let cache = self.cache.borrow();
1994
1995 if let Some(price) = cache.price(&instrument_id, price_type) {
1996 return Some(price);
1997 }
1998
1999 if let Some(price) = cache.price(&instrument_id, PriceType::Last) {
2000 return Some(price);
2001 }
2002
2003 let bar_price = |price_type| {
2004 cache
2005 .bar_types(
2006 Some(&instrument_id),
2007 Some(&price_type),
2008 AggregationSource::External,
2009 )
2010 .into_iter()
2011 .filter_map(|bar_type| {
2012 cache
2013 .bar(bar_type)
2014 .map(|bar| (bar.ts_init, *bar_type, bar.close))
2015 })
2016 .max_by_key(|(ts_init, bar_type, _)| (*ts_init, *bar_type))
2017 .map(|(_, _, price)| price)
2018 };
2019
2020 bar_price(price_type).or_else(|| bar_price(PriceType::Last))
2021 }
2022
2023 fn check_cash_sell_balance(
2024 &self,
2025 account: &dyn Account,
2026 allow_borrowing: bool,
2027 order: &OrderAny,
2028 quantity: Quantity,
2029 base_currency: Currency,
2030 cum_notional_sell: &mut Option<Money>,
2031 ) -> bool {
2032 let cash_value_raw: MoneyRaw = match quantity.raw.try_into() {
2033 Ok(value) => value,
2034 Err(e) => {
2035 self.deny_order(
2036 order,
2037 &OrderDeniedReason::QuantityConversionFailed {
2038 detail: e.to_string(),
2039 }
2040 .to_string(),
2041 );
2042 return false;
2043 }
2044 };
2045
2046 let cash_value = Money::from_raw(cash_value_raw, base_currency);
2047 let base_free = account
2048 .balance_free(Some(base_currency))
2049 .unwrap_or_else(|| Money::zero(base_currency));
2050
2051 if self.config.debug {
2052 log::debug!("Cash value: {cash_value:?}");
2053 log::debug!("Total: {:?}", account.balance_total(Some(base_currency)));
2054 log::debug!("Locked: {:?}", account.balance_locked(Some(base_currency)));
2055 log::debug!("Free: {base_free:?}");
2056 }
2057
2058 match cum_notional_sell {
2059 Some(value) => value.raw += cash_value.raw,
2060 None => *cum_notional_sell = Some(cash_value),
2061 }
2062
2063 if self.config.debug {
2064 log::debug!("Cumulative notional SELL: {cum_notional_sell:?}");
2065 }
2066
2067 if !allow_borrowing
2068 && let Some(cum_notional_sell) = *cum_notional_sell
2069 && cum_notional_sell.raw > base_free.raw
2070 {
2071 self.deny_order(
2072 order,
2073 &OrderDeniedReason::CumulativeNotionalExceedsFreeBalance {
2074 free_balance: base_free,
2075 cumulative_notional: cum_notional_sell,
2076 }
2077 .to_string(),
2078 );
2079 return false;
2080 }
2081
2082 true
2083 }
2084
2085 fn deny_no_market_price(&self, instrument_id: InstrumentId, order: &OrderAny) {
2086 self.deny_order(
2087 order,
2088 &OrderDeniedReason::MarketPriceUnavailable {
2089 order_type: order.order_type(),
2090 instrument_id,
2091 }
2092 .to_string(),
2093 );
2094 }
2095
2096 fn check_price(
2097 instrument: &InstrumentAny,
2098 price: Option<Price>,
2099 field: OrderPriceField,
2100 ) -> Option<OrderDeniedReason> {
2101 let price_val = price?;
2102
2103 if price_val.precision > instrument.price_precision() {
2104 return Some(OrderDeniedReason::PricePrecisionExceedsMaximum {
2105 field,
2106 price: price_val,
2107 price_precision: price_val.precision,
2108 max_precision: instrument.price_precision(),
2109 });
2110 }
2111
2112 if !instrument.allows_negative_price() && price_val.raw <= 0 {
2113 return Some(OrderDeniedReason::PriceNotPositive {
2114 field,
2115 price: price_val,
2116 });
2117 }
2118
2119 None
2120 }
2121
2122 fn check_quantity(
2123 instrument: &InstrumentAny,
2124 quantity: Option<Quantity>,
2125 is_quote_quantity: bool,
2126 full_position_exit: bool,
2127 ) -> Option<OrderDeniedReason> {
2128 let quantity_val = quantity?;
2129
2130 if quantity_val.precision > instrument.size_precision() {
2132 return Some(OrderDeniedReason::QuantityPrecisionExceedsMaximum {
2133 quantity: quantity_val,
2134 quantity_precision: quantity_val.precision,
2135 max_precision: instrument.size_precision(),
2136 });
2137 }
2138
2139 if is_quote_quantity || full_position_exit {
2142 return None;
2143 }
2144
2145 if let Some(max_quantity) = instrument.max_quantity()
2147 && quantity_val > max_quantity
2148 {
2149 return Some(OrderDeniedReason::QuantityExceedsMaximum {
2150 effective_quantity: quantity_val,
2151 max_quantity,
2152 });
2153 }
2154
2155 if let Some(min_quantity) = instrument.min_quantity()
2157 && quantity_val < min_quantity
2158 {
2159 return Some(OrderDeniedReason::QuantityBelowMinimum {
2160 effective_quantity: quantity_val,
2161 min_quantity,
2162 });
2163 }
2164
2165 None
2166 }
2167
2168 fn deny_command(&self, command: TradingCommand, reason: &str) {
2169 match command {
2170 TradingCommand::SubmitOrder(command) => {
2171 let order = {
2172 let cache = self.cache.borrow();
2173 cache.order(&command.client_order_id).map(|o| o.clone())
2174 };
2175
2176 if let Some(ref order) = order {
2177 self.deny_order(order, reason);
2178 } else {
2179 log::error!(
2180 "Cannot deny order: not found in cache for {}",
2181 command.client_order_id
2182 );
2183 }
2184 }
2185 TradingCommand::SubmitOrderList(command) => {
2186 let orders: Vec<OrderAny> = self
2187 .cache
2188 .borrow()
2189 .orders_for_ids(&command.order_list.client_order_ids, &command);
2190 self.deny_order_list(&orders, reason);
2191 }
2192 _ => {
2193 log::error!("Cannot deny command {command}");
2194 }
2195 }
2196 }
2197
2198 fn deny_order(&self, order: &OrderAny, reason: &str) {
2199 log::warn!(
2200 "SubmitOrder for {} DENIED: {}",
2201 order.client_order_id(),
2202 reason
2203 );
2204
2205 if order.status() != OrderStatus::Initialized {
2206 return;
2207 }
2208
2209 {
2211 let mut cache = self.cache.borrow_mut();
2212 if !cache.order_exists(&order.client_order_id())
2213 && let Err(e) = cache.add_order(order.clone(), None, None, false)
2214 {
2215 log::error!("Cannot add order to cache: {e}");
2216 return;
2217 }
2218 }
2219
2220 let denied = OrderEventAny::Denied(OrderDenied::new(
2221 order.trader_id(),
2222 order.strategy_id(),
2223 order.instrument_id(),
2224 order.client_order_id(),
2225 reason.into(),
2226 UUID4::new(),
2227 self.clock.borrow().timestamp_ns(),
2228 self.clock.borrow().timestamp_ns(),
2229 ));
2230
2231 let endpoint = MessagingSwitchboard::exec_engine_process();
2232 msgbus::send_order_event(endpoint, denied);
2233 }
2234
2235 fn deny_order_list(&self, orders: &[OrderAny], reason: &str) {
2236 for order in orders {
2237 if !order.is_closed() {
2238 self.deny_order(order, reason);
2239 }
2240 }
2241 }
2242
2243 fn reject_modify_order(&self, order: &OrderAny, reason: &str) {
2244 let ts_event = self.clock.borrow().timestamp_ns();
2245 let denied = OrderEventAny::ModifyRejected(OrderModifyRejected::new(
2246 order.trader_id(),
2247 order.strategy_id(),
2248 order.instrument_id(),
2249 order.client_order_id(),
2250 reason.into(),
2251 UUID4::new(),
2252 ts_event,
2253 ts_event,
2254 false,
2255 order.venue_order_id(),
2256 order.account_id(),
2257 ));
2258
2259 let endpoint = MessagingSwitchboard::exec_engine_process();
2260 msgbus::send_order_event(endpoint, denied);
2261 }
2262
2263 fn execution_gateway(&mut self, instrument: &InstrumentAny, command: TradingCommand) {
2264 match self.trading_state {
2265 TradingState::Halted => match command {
2266 TradingCommand::SubmitOrder(submit_order) => {
2267 let order = {
2268 let cache = self.cache.borrow();
2269 cache
2270 .order(&submit_order.client_order_id)
2271 .map(|o| o.clone())
2272 };
2273
2274 if let Some(ref order) = order {
2275 self.deny_order(order, &OrderDeniedReason::TradingHalted.to_string());
2276 }
2277 }
2278 TradingCommand::SubmitOrderList(submit_order_list) => {
2279 let orders: Vec<OrderAny> = self.cache.borrow().orders_for_ids(
2280 &submit_order_list.order_list.client_order_ids,
2281 &submit_order_list,
2282 );
2283 self.deny_order_list(&orders, &OrderDeniedReason::TradingHalted.to_string());
2284 }
2285 _ => {}
2286 },
2287 TradingState::Reducing => {
2288 match &command {
2289 TradingCommand::SubmitOrder(submit_order) => {
2290 let order = {
2291 let cache = self.cache.borrow();
2292 cache
2293 .order(&submit_order.client_order_id)
2294 .map(|o| o.clone())
2295 };
2296
2297 if let Some(ref order) = order
2298 && ((order.is_buy() && self.portfolio.is_net_long(&instrument.id()))
2299 || (order.is_sell()
2300 && self.portfolio.is_net_short(&instrument.id())))
2301 {
2302 self.deny_order(
2303 order,
2304 &OrderDeniedReason::TradingStateReducing {
2305 order_side: order.order_side(),
2306 instrument_id: instrument.id(),
2307 }
2308 .to_string(),
2309 );
2310 return;
2311 }
2312 }
2313 TradingCommand::SubmitOrderList(submit_order_list) => {
2314 let orders: Vec<OrderAny> = self.cache.borrow().orders_for_ids(
2315 &submit_order_list.order_list.client_order_ids,
2316 &submit_order_list,
2317 );
2318
2319 for order in &orders {
2320 let order_instrument_id = order.instrument_id();
2321 if (order.is_buy() && self.portfolio.is_net_long(&order_instrument_id))
2322 || (order.is_sell()
2323 && self.portfolio.is_net_short(&order_instrument_id))
2324 {
2325 self.deny_order_list(
2326 &orders,
2327 &OrderDeniedReason::TradingStateReducing {
2328 order_side: order.order_side(),
2329 instrument_id: order_instrument_id,
2330 }
2331 .to_string(),
2332 );
2333 return;
2334 }
2335 }
2336 }
2337 _ => {}
2338 }
2339 self.throttled_submit.send(command);
2341 }
2342 TradingState::Active => match command {
2343 TradingCommand::SubmitOrder(_) | TradingCommand::SubmitOrderList(_) => {
2344 self.throttled_submit.send(command);
2345 }
2346 _ => {}
2347 },
2348 }
2349 }
2350
2351 fn send_to_execution(command: TradingCommand) {
2352 let endpoint = MessagingSwitchboard::exec_engine_queue_execute();
2353 msgbus::send_trading_command(endpoint, command);
2354 }
2355
2356 fn handle_event(&self, event: &OrderEventAny) {
2357 if self.config.debug {
2360 log::debug!("{RECV}{EVT} {event:?}");
2361 }
2362 }
2363
2364 fn handle_position_event(&self, event: &PositionEvent) {
2365 if self.config.debug {
2366 log::debug!("{RECV}{EVT} {event:?}");
2367 }
2368 }
2369}