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::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 OrderUpdated, PositionEvent,
54 },
55 identifiers::{AccountId, ClientId, ClientOrderId, InstrumentId, Venue},
56 instruments::{Instrument, InstrumentAny},
57 orders::{LIMIT_ORDER_TYPES, Order, OrderAny, STOP_ORDER_TYPES},
58 types::{Currency, Money, Price, Quantity, quantity::QuantityRaw},
59};
60use nautilus_portfolio::Portfolio;
61use rust_decimal::Decimal;
62use ustr::Ustr;
63
64type SubmitCommandFn = Box<dyn Fn(TradingCommand)>;
65type ModifyOrderFn = Box<dyn Fn(ModifyOrder)>;
66
67#[allow(dead_code)]
74pub struct RiskEngine {
75 clock: Rc<RefCell<dyn Clock>>,
76 cache: Rc<RefCell<Cache>>,
77 portfolio: Portfolio,
78 trading_state: TradingState,
79 config: RiskEngineConfig,
80 max_notional_per_order: AHashMap<InstrumentId, Decimal>,
81 throttler_submit: Throttler<TradingCommand, SubmitCommandFn>,
82 throttler_modify: Throttler<ModifyOrder, ModifyOrderFn>,
83 reservations: Vec<Reservation>,
84 command_count: u64,
85 event_count: u64,
86}
87
88impl Debug for RiskEngine {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 f.debug_struct(stringify!(RiskEngine))
91 .field("trading_state", &self.trading_state)
92 .field("config", &self.config)
93 .field("max_notional_per_order", &self.max_notional_per_order)
94 .field("throttler_submit", &self.throttler_submit)
95 .field("throttler_modify", &self.throttler_modify)
96 .field("command_count", &self.command_count)
97 .field("event_count", &self.event_count)
98 .finish_non_exhaustive()
99 }
100}
101
102impl RiskEngine {
103 pub fn new(
105 config: RiskEngineConfig,
106 portfolio: Portfolio,
107 clock: Rc<RefCell<dyn Clock>>,
108 cache: Rc<RefCell<Cache>>,
109 ) -> Self {
110 let throttler_submit =
111 Self::create_submit_throttler(&config, Rc::clone(&clock), Rc::clone(&cache));
112 let throttler_modify =
113 Self::create_modify_throttler(&config, Rc::clone(&clock), Rc::clone(&cache));
114 let max_notional_per_order = config.max_notional_per_order.clone();
115
116 Self {
117 clock,
118 cache,
119 portfolio,
120 trading_state: TradingState::Active,
121 config,
122 max_notional_per_order,
123 throttler_submit,
124 throttler_modify,
125 reservations: Vec::new(),
126 command_count: 0,
127 event_count: 0,
128 }
129 }
130
131 pub fn register_msgbus_handlers(engine: &Rc<RefCell<Self>>) {
133 let weak = WeakCell::from(Rc::downgrade(engine));
134
135 let weak_execute = weak.clone();
136 msgbus::register_trading_command_endpoint(
137 MessagingSwitchboard::risk_engine_execute(),
138 TypedIntoHandler::from(move |cmd: TradingCommand| {
139 if let Some(rc) = weak_execute.upgrade() {
140 rc.borrow_mut().execute(cmd);
141 }
142 }),
143 );
144
145 msgbus::register_trading_command_endpoint(
154 MessagingSwitchboard::risk_engine_queue_execute(),
155 TypedIntoHandler::from(move |cmd: TradingCommand| {
156 if let Some(sender) = try_get_trading_cmd_sender() {
157 sender.execute(TradingCommandMessage::new(
158 MessagingSwitchboard::risk_engine_execute(),
159 cmd,
160 ));
161 } else {
162 let endpoint = MessagingSwitchboard::risk_engine_execute();
163 msgbus::send_trading_command(endpoint, cmd);
164 }
165 }),
166 );
167
168 let weak_process = weak.clone();
169 msgbus::register_order_event_endpoint(
170 MessagingSwitchboard::risk_engine_process(),
171 TypedIntoHandler::from(move |event: OrderEventAny| {
172 if let Some(rc) = weak_process.upgrade() {
173 rc.borrow_mut().process(event);
174 }
175 }),
176 );
177
178 let weak_order_events = weak.clone();
179 msgbus::subscribe_order_events(
180 "events.order.*".into(),
181 TypedHandler::from(move |event: &OrderEventAny| {
182 if let Some(rc) = weak_order_events.upgrade()
186 && let Ok(mut engine) = rc.try_borrow_mut()
187 {
188 engine.process(event.clone());
189 }
190 }),
191 Some(10),
192 );
193
194 let weak_position_events = weak;
195 msgbus::subscribe_position_events(
196 "events.position.*".into(),
197 TypedHandler::from(move |event: &PositionEvent| {
198 if let Some(rc) = weak_position_events.upgrade() {
199 rc.borrow_mut().process_position_event(event);
200 }
201 }),
202 Some(10),
203 );
204 }
205
206 fn create_submit_throttler(
207 config: &RiskEngineConfig,
208 clock: Rc<RefCell<dyn Clock>>,
209 cache: Rc<RefCell<Cache>>,
210 ) -> Throttler<TradingCommand, SubmitCommandFn> {
211 let success_handler = {
212 Box::new(move |command: TradingCommand| {
213 let endpoint = MessagingSwitchboard::exec_engine_queue_execute();
214 msgbus::send_trading_command(endpoint, command);
215 }) as Box<dyn Fn(TradingCommand)>
216 };
217
218 let failure_handler = {
219 let cache = cache;
220 let clock = Rc::clone(&clock);
221 Box::new(move |command: TradingCommand| {
222 let reason = OrderDeniedReason::RateLimitExceeded.to_string();
223
224 match command {
225 TradingCommand::SubmitOrder(submit_order) => {
226 log::warn!(
227 "SubmitOrder for {} DENIED: {reason}",
228 submit_order.client_order_id,
229 );
230
231 Self::handle_submit_order_cache(&cache, &submit_order);
232
233 let denied = Self::create_order_denied(&submit_order, &reason, &clock);
234
235 let endpoint = MessagingSwitchboard::exec_engine_process();
236 msgbus::send_order_event(endpoint, denied);
237 }
238 TradingCommand::SubmitOrderList(submit_order_list) => {
239 log::warn!(
240 "SubmitOrderList for {} DENIED: {reason}",
241 submit_order_list.order_list.id,
242 );
243
244 let orders: Vec<OrderAny> = cache.borrow().orders_for_ids(
245 &submit_order_list.order_list.client_order_ids,
246 &submit_order_list,
247 );
248
249 let timestamp = clock.borrow().timestamp_ns();
250
251 for order in &orders {
252 if order.status() == OrderStatus::Initialized {
253 let denied = OrderEventAny::Denied(OrderDenied::new(
254 order.trader_id(),
255 order.strategy_id(),
256 order.instrument_id(),
257 order.client_order_id(),
258 reason.as_str().into(),
259 UUID4::new(),
260 timestamp,
261 timestamp,
262 ));
263 let endpoint = MessagingSwitchboard::exec_engine_process();
264 msgbus::send_order_event(endpoint, denied);
265 }
266 }
267 }
268 _ => {
269 log::error!("Unexpected command type in submit throttler: {command}");
270 }
271 }
272 }) as Box<dyn Fn(TradingCommand)>
273 };
274
275 Throttler::new(
276 config.max_order_submit,
277 clock,
278 "ORDER_SUBMIT_THROTTLER",
279 success_handler,
280 Some(failure_handler),
281 Ustr::from(UUID4::new().as_str()),
282 )
283 }
284
285 fn create_modify_throttler(
286 config: &RiskEngineConfig,
287 clock: Rc<RefCell<dyn Clock>>,
288 cache: Rc<RefCell<Cache>>,
289 ) -> Throttler<ModifyOrder, ModifyOrderFn> {
290 let success_handler = {
291 Box::new(move |order: ModifyOrder| {
292 let endpoint = MessagingSwitchboard::exec_engine_queue_execute();
293 msgbus::send_trading_command(endpoint, TradingCommand::ModifyOrder(order));
294 }) as Box<dyn Fn(ModifyOrder)>
295 };
296
297 let failure_handler = {
298 let cache = cache;
299 let clock = Rc::clone(&clock);
300 Box::new(move |order: ModifyOrder| {
301 let reason = "Exceeded MAX_ORDER_MODIFY_RATE";
302 log::warn!(
303 "SubmitOrder for {} DENIED: {}",
304 order.client_order_id,
305 reason
306 );
307
308 let Some(order) = Self::get_existing_order(&cache, &order) else {
309 return;
310 };
311
312 let rejected = Self::create_modify_rejected(&order, reason, &clock);
313
314 let endpoint = MessagingSwitchboard::exec_engine_process();
315 msgbus::send_order_event(endpoint, rejected);
316 }) as Box<dyn Fn(ModifyOrder)>
317 };
318
319 Throttler::new(
320 config.max_order_modify,
321 clock,
322 "ORDER_MODIFY_THROTTLER",
323 success_handler,
324 Some(failure_handler),
325 Ustr::from(UUID4::new().as_str()),
326 )
327 }
328
329 fn handle_submit_order_cache(cache: &Rc<RefCell<Cache>>, submit_order: &SubmitOrder) {
330 let cache = cache.borrow();
331 if !cache.order_exists(&submit_order.client_order_id) {
332 log::error!(
333 "Order not found in cache for client_order_id: {}",
334 submit_order.client_order_id
335 );
336 }
337 }
338
339 fn get_existing_order(cache: &Rc<RefCell<Cache>>, order: &ModifyOrder) -> Option<OrderAny> {
340 let cache = cache.borrow();
341 if let Some(order) = cache.order(&order.client_order_id) {
342 Some(order.clone())
343 } else {
344 log::error!(
345 "Order with command.client_order_id: {} not found",
346 order.client_order_id
347 );
348 None
349 }
350 }
351
352 fn create_order_denied(
353 submit_order: &SubmitOrder,
354 reason: &str,
355 clock: &Rc<RefCell<dyn Clock>>,
356 ) -> OrderEventAny {
357 let timestamp = clock.borrow().timestamp_ns();
358 OrderEventAny::Denied(OrderDenied::new(
359 submit_order.trader_id,
360 submit_order.strategy_id,
361 submit_order.instrument_id,
362 submit_order.client_order_id,
363 reason.into(),
364 UUID4::new(),
365 timestamp,
366 timestamp,
367 ))
368 }
369
370 fn create_modify_rejected(
371 order: &OrderAny,
372 reason: &str,
373 clock: &Rc<RefCell<dyn Clock>>,
374 ) -> OrderEventAny {
375 let timestamp = clock.borrow().timestamp_ns();
376 OrderEventAny::ModifyRejected(OrderModifyRejected::new(
377 order.trader_id(),
378 order.strategy_id(),
379 order.instrument_id(),
380 order.client_order_id(),
381 reason.into(),
382 UUID4::new(),
383 timestamp,
384 timestamp,
385 false,
386 order.venue_order_id(),
387 order.account_id(),
388 ))
389 }
390
391 pub fn execute(&mut self, command: TradingCommand) {
394 self.command_count += 1;
395
396 self.handle_command(command);
398 }
399
400 #[expect(
402 clippy::needless_pass_by_value,
403 reason = "message bus dispatch passes owned order events"
404 )]
405 pub fn process(&mut self, event: OrderEventAny) {
406 self.event_count += 1;
407
408 self.handle_event(&event);
410 }
411
412 fn process_position_event(&mut self, event: &PositionEvent) {
413 self.event_count += 1;
414
415 self.handle_position_event(event);
416 }
417
418 pub fn set_trading_state(&mut self, state: TradingState) {
422 if state == self.trading_state {
423 log::warn!("No change to trading state: already set to {state:?}");
424 return;
425 }
426
427 self.trading_state = state;
428
429 let ts_now = self.clock.borrow().timestamp_ns();
430 let trader_id = get_message_bus().borrow().trader_id;
431
432 let config = self.config_as_map();
433 let event =
434 TradingStateChanged::new(trader_id, state, config, UUID4::new(), ts_now, ts_now);
435
436 msgbus::publish_any(MessagingSwitchboard::risk_events_topic(), &event);
437
438 log::info!("Trading state set to {state:?}");
439 }
440
441 pub fn set_max_notional_per_order(&mut self, instrument_id: InstrumentId, new_value: Decimal) {
443 self.max_notional_per_order.insert(instrument_id, new_value);
444
445 let new_value_str = new_value.to_string();
446 log::info!("Set MAX_NOTIONAL_PER_ORDER: {instrument_id} {new_value_str}");
447 }
448
449 pub fn start(&mut self) {
451 log::info!("Started");
452 }
453
454 pub fn stop(&mut self) {
456 log::info!("Stopped");
457 }
458
459 pub fn reset(&mut self) {
461 self.throttler_submit.reset();
462 self.throttler_modify.reset();
463 self.max_notional_per_order = self.config.max_notional_per_order.clone();
464 self.reservations.clear();
465 self.command_count = 0;
466 self.event_count = 0;
467
468 if self.trading_state != TradingState::Active {
469 self.set_trading_state(TradingState::Active);
470 }
471
472 log::info!("Reset");
473 }
474
475 pub fn dispose(&mut self) {
477 log::info!("Disposed");
478 }
479
480 #[must_use]
482 pub fn clock(&self) -> &Rc<RefCell<dyn Clock>> {
483 &self.clock
484 }
485
486 #[must_use]
488 pub fn cache(&self) -> &Rc<RefCell<Cache>> {
489 &self.cache
490 }
491
492 pub fn portfolio_mut(&mut self) -> &mut Portfolio {
494 &mut self.portfolio
495 }
496
497 #[must_use]
499 pub const fn config(&self) -> &RiskEngineConfig {
500 &self.config
501 }
502
503 #[must_use]
505 pub const fn command_count(&self) -> u64 {
506 self.command_count
507 }
508
509 #[must_use]
511 pub const fn event_count(&self) -> u64 {
512 self.event_count
513 }
514
515 #[must_use]
517 pub const fn trading_state(&self) -> TradingState {
518 self.trading_state
519 }
520
521 #[must_use]
523 pub const fn max_notional_per_order(&self) -> &AHashMap<InstrumentId, Decimal> {
524 &self.max_notional_per_order
525 }
526
527 fn config_as_map(&self) -> IndexMap<String, String> {
528 let mut map = IndexMap::new();
529 map.insert("bypass".to_string(), self.config.bypass.to_string());
530 map.insert(
531 "max_order_submit_rate".to_string(),
532 self.config.max_order_submit.to_string(),
533 );
534 map.insert(
535 "max_order_modify_rate".to_string(),
536 self.config.max_order_modify.to_string(),
537 );
538
539 for (instrument_id, value) in &self.max_notional_per_order {
540 map.insert(
541 format!("max_notional_per_order.{instrument_id}"),
542 value.to_string(),
543 );
544 }
545
546 let mut full_position_exit_venues = self
547 .config
548 .full_position_exit_venues
549 .iter()
550 .map(ToString::to_string)
551 .collect::<Vec<_>>();
552 full_position_exit_venues.sort_unstable();
553 map.insert(
554 "full_position_exit_venues".to_string(),
555 full_position_exit_venues.join(","),
556 );
557
558 map.insert("debug".to_string(), self.config.debug.to_string());
559 map
560 }
561
562 fn handle_command(&mut self, command: TradingCommand) {
563 if self.config.debug {
564 log::debug!("{CMD}{RECV} {command}");
565 }
566
567 self.release_reservations();
568
569 match command {
570 TradingCommand::SubmitOrder(submit_order) => self.handle_submit_order(submit_order),
571 TradingCommand::SubmitOrderList(submit_order_list) => {
572 self.handle_submit_order_list(submit_order_list);
573 }
574 TradingCommand::ModifyOrder(modify_order) => self.handle_modify_order(modify_order),
575 TradingCommand::ModifyOrders(modify_orders) => {
576 self.handle_batch_modify_orders(modify_orders);
577 }
578 TradingCommand::QueryAccount(query_account) => {
579 Self::send_to_execution(TradingCommand::QueryAccount(query_account));
580 }
581 _ => {
582 log::error!("Cannot handle command: {command}");
583 }
584 }
585 }
586
587 fn handle_submit_order(&mut self, command: SubmitOrder) {
588 if self.config.bypass {
589 Self::send_to_execution(TradingCommand::SubmitOrder(command));
590 return;
591 }
592
593 let order = {
594 let cache = self.cache.borrow();
595 let Some(order) = cache.order(&command.client_order_id) else {
596 log::error!(
597 "Cannot handle submit order: order not found in cache for {}",
598 command.client_order_id
599 );
600 return;
601 };
602 order.clone()
603 };
604
605 if let Some(position_id) = command.position_id
606 && order.is_reduce_only()
607 {
608 let position_exists = {
609 let cache = self.cache.borrow();
610 cache
611 .position(&position_id)
612 .map(|pos| (pos.side, pos.quantity))
613 };
614
615 if let Some((pos_side, pos_quantity)) = position_exists {
616 if !order.would_reduce_only(pos_side, pos_quantity) {
617 self.deny_command(
618 TradingCommand::SubmitOrder(command),
619 &OrderDeniedReason::ReduceOnlyWouldIncreasePosition { position_id }
620 .to_string(),
621 );
622 return; }
624 } else {
625 self.deny_command(
626 TradingCommand::SubmitOrder(command),
627 &OrderDeniedReason::PositionNotFound { position_id }.to_string(),
628 );
629 return;
630 }
631 }
632
633 let instrument_exists = {
634 let cache = self.cache.borrow();
635 cache.instrument(&command.instrument_id).cloned()
636 };
637
638 let Some(instrument) = instrument_exists else {
639 self.deny_command(
640 TradingCommand::SubmitOrder(command.clone()),
641 &OrderDeniedReason::InstrumentNotFound {
642 instrument_id: command.instrument_id,
643 }
644 .to_string(),
645 );
646 return; };
648
649 let full_position_exit = self.is_full_position_exit(&command, &instrument, &order);
650 if !self.check_order(&instrument, &order, full_position_exit) {
651 return; }
653
654 let Some(reservations) = self.check_orders_risk(
655 &instrument,
656 &[order],
657 full_position_exit,
658 RiskCheck::Submit,
659 command.client_id,
660 ) else {
661 return; };
663
664 self.execution_gateway(TradingCommand::SubmitOrder(command), reservations);
665 }
666
667 fn is_full_position_exit(
668 &self,
669 command: &SubmitOrder,
670 instrument: &InstrumentAny,
671 order: &OrderAny,
672 ) -> bool {
673 if !self
674 .config
675 .full_position_exit_venues
676 .contains(&instrument.id().venue)
677 {
678 return false;
679 }
680
681 if !Self::has_full_position_exit_intent(command) {
682 return false;
683 }
684
685 if command.instrument_id != order.instrument_id() {
686 return false;
687 }
688
689 if !Self::is_full_position_exit_instrument(instrument)
690 || !Self::is_full_position_exit_order(order)
691 {
692 return false;
693 }
694
695 self.reduces_identified_open_position(command, order)
696 }
697
698 fn has_full_position_exit_intent(command: &SubmitOrder) -> bool {
699 command
700 .params
701 .as_ref()
702 .and_then(|params| params.get_bool(PARAMS_CLOSE_POSITION))
703 .unwrap_or(false)
704 }
705
706 fn is_full_position_exit_instrument(instrument: &InstrumentAny) -> bool {
707 match instrument {
708 InstrumentAny::CryptoFuture(_) | InstrumentAny::CryptoPerpetual(_) => true,
709 InstrumentAny::PerpetualContract(_) => !instrument.is_inverse(),
710 _ => false,
711 }
712 }
713
714 fn is_full_position_exit_order(order: &OrderAny) -> bool {
715 matches!(
716 order.order_type(),
717 OrderType::StopMarket | OrderType::MarketIfTouched
718 ) && order.trigger_price().is_some()
719 && order.is_reduce_only()
720 && order.quantity().is_positive()
721 }
722
723 fn is_reducing_submission(&self, command: &SubmitOrder, order: &OrderAny) -> bool {
724 order.is_reduce_only()
725 && order.quantity().is_positive()
726 && command.instrument_id == order.instrument_id()
727 && self
728 .identified_open_position(command, order)
729 .is_some_and(|(side, quantity)| {
730 order.would_reduce_only(side, quantity) && order.quantity() <= quantity
731 })
732 }
733
734 fn reduces_identified_open_position(&self, command: &SubmitOrder, order: &OrderAny) -> bool {
735 self.identified_open_position(command, order)
736 .is_some_and(|(side, quantity)| order.would_reduce_only(side, quantity))
737 }
738
739 fn identified_open_position(
740 &self,
741 command: &SubmitOrder,
742 order: &OrderAny,
743 ) -> Option<(PositionSide, Quantity)> {
744 let position_id = command.position_id?;
745 let account_id =
746 self.order_account_id(order, command.client_id, command.instrument_id.venue);
747
748 let position = {
749 let cache = self.cache.borrow();
750 if cache.position_id(&order.client_order_id()).copied() != Some(position_id) {
751 return None;
752 }
753
754 cache
755 .position(&position_id)
756 .filter(|position| account_id == Some(position.account_id))
757 .map(|position| {
758 (
759 position.is_open(),
760 position.instrument_id,
761 position.side,
762 position.quantity,
763 )
764 })
765 };
766 let (is_open, position_instrument_id, position_side, position_quantity) = position?;
767
768 (is_open
769 && position_instrument_id == order.instrument_id()
770 && matches!(
771 (order.order_side(), position_side),
772 (OrderSide::Buy, PositionSide::Short) | (OrderSide::Sell, PositionSide::Long)
773 ))
774 .then_some((position_side, position_quantity))
775 }
776
777 fn handle_submit_order_list(&mut self, command: SubmitOrderList) {
778 if self.config.bypass {
779 Self::send_to_execution(TradingCommand::SubmitOrderList(command));
780 return;
781 }
782
783 let orders: Vec<OrderAny> = self
784 .cache
785 .borrow()
786 .orders_for_ids(&command.order_list.client_order_ids, &command);
787
788 if orders.len() != command.order_list.client_order_ids.len() {
789 self.deny_order_list(
790 &orders,
791 &OrderDeniedReason::OrderListIncomplete {
792 order_list_id: command.order_list.id,
793 }
794 .to_string(),
795 );
796 return; }
798
799 let mut instruments: AHashMap<InstrumentId, InstrumentAny> = AHashMap::new();
803
804 for order in &orders {
805 let instrument_id = order.instrument_id();
806 if instruments.contains_key(&instrument_id) {
807 continue;
808 }
809 let resolved = self.cache.borrow().instrument(&instrument_id).cloned();
810 let Some(instrument) = resolved else {
811 self.deny_command(
812 TradingCommand::SubmitOrderList(command),
813 &OrderDeniedReason::InstrumentNotFound { instrument_id }.to_string(),
814 );
815 return; };
817 instruments.insert(instrument_id, instrument);
818 }
819
820 for (index, order) in orders.iter().enumerate() {
821 let Some(instrument) = instruments.get(&order.instrument_id()) else {
822 self.deny_order(
823 order,
824 &OrderDeniedReason::InstrumentNotFound {
825 instrument_id: order.instrument_id(),
826 }
827 .to_string(),
828 );
829 return; };
831
832 if !self.check_order(instrument, order, false) {
833 let reason = OrderDeniedReason::OrderListDenied {
834 order_list_id: command.order_list.id,
835 }
836 .to_string();
837
838 self.deny_order_list(&orders[..index], &reason);
839 self.deny_order_list(&orders[index + 1..], &reason);
840 return; }
842 }
843
844 let representative = if let Some(instrument) = instruments.get(&command.instrument_id) {
845 instrument.clone()
846 } else {
847 self.deny_order_list(
848 &orders,
849 &OrderDeniedReason::InstrumentNotFound {
850 instrument_id: command.instrument_id,
851 }
852 .to_string(),
853 );
854 return; };
856
857 let Some(reservations) = self.check_orders_risk(
858 &representative,
859 &orders,
860 false,
861 RiskCheck::Submit,
862 command.client_id,
863 ) else {
864 self.deny_order_list(
865 &orders,
866 &OrderDeniedReason::OrderListDenied {
867 order_list_id: command.order_list.id,
868 }
869 .to_string(),
870 );
871 return; };
873
874 self.execution_gateway(TradingCommand::SubmitOrderList(command), reservations);
875 }
876
877 fn handle_modify_order(&mut self, command: ModifyOrder) {
878 if self.config.bypass {
879 Self::send_to_execution(TradingCommand::ModifyOrder(command));
880 return;
881 }
882
883 if !self.validate_modify_order(&command)
884 || !self.check_modify_orders_risk(std::slice::from_ref(&command), command.client_id)
885 {
886 return;
887 }
888
889 self.throttler_modify.send(command);
890 }
891
892 fn handle_batch_modify_orders(&mut self, command: BatchModifyOrders) {
893 if self.config.bypass {
894 Self::send_to_execution(TradingCommand::ModifyOrders(command));
895 return;
896 }
897
898 if command.modifies.is_empty() {
899 log::warn!("Cannot handle BatchModifyOrders: no modify commands");
900 return;
901 }
902
903 if !self.validate_batch_modify_orders(&command) {
904 return;
905 }
906
907 if !self.check_modify_orders_risk(&command.modifies, command.client_id) {
908 return;
909 }
910
911 if !self.throttler_modify.try_reserve(command.modifies.len()) {
912 let reason = "Exceeded MAX_ORDER_MODIFY_RATE";
913
914 for modify in &command.modifies {
915 let Some(order) = Self::get_existing_order(&self.cache, modify) else {
916 continue;
917 };
918
919 self.reject_modify_order(&order, reason);
920 }
921
922 return;
923 }
924
925 Self::send_to_execution(TradingCommand::ModifyOrders(command));
926 }
927
928 fn validate_batch_modify_orders(&self, command: &BatchModifyOrders) -> bool {
929 let mut rejected_client_order_ids = Vec::new();
930 let mut valid = true;
931
932 for modify in &command.modifies {
933 if !self.validate_batch_modify_order(command, modify) {
934 rejected_client_order_ids.push(modify.client_order_id);
935 valid = false;
936 }
937 }
938
939 if !valid {
940 let reason = "BatchModifyOrders rejected because one or more child modifications failed validation";
941
942 for modify in &command.modifies {
943 if rejected_client_order_ids.contains(&modify.client_order_id) {
944 continue;
945 }
946
947 let Some(order) = Self::get_existing_order(&self.cache, modify) else {
948 continue;
949 };
950
951 self.reject_modify_order(&order, reason);
952 }
953
954 return false;
955 }
956
957 true
958 }
959
960 fn validate_batch_modify_order(
961 &self,
962 command: &BatchModifyOrders,
963 modify: &ModifyOrder,
964 ) -> bool {
965 if modify.instrument_id != command.instrument_id {
966 let order = self
967 .cache
968 .borrow()
969 .order(&modify.client_order_id)
970 .map(|order| order.clone());
971
972 if let Some(order) = order {
973 self.reject_modify_order(
974 &order,
975 &format!(
976 "BatchModifyOrders instrument {} does not match child instrument {}",
977 command.instrument_id, modify.instrument_id
978 ),
979 );
980 }
981
982 return false;
983 }
984
985 self.validate_modify_order(modify)
986 }
987
988 fn validate_modify_order(&self, command: &ModifyOrder) -> bool {
989 let order_exists = {
990 let cache = self.cache.borrow();
991 cache.order(&command.client_order_id).map(|o| o.clone())
992 };
993
994 let Some(order) = order_exists else {
995 log::error!(
996 "ModifyOrder DENIED: Order with command.client_order_id: {} not found",
997 command.client_order_id
998 );
999 return false;
1000 };
1001
1002 if order.is_closed() {
1003 self.reject_modify_order(
1004 &order,
1005 &format!(
1006 "Order with command.client_order_id: {} already closed",
1007 command.client_order_id
1008 ),
1009 );
1010 return false;
1011 } else if order.status() == OrderStatus::PendingCancel {
1012 self.reject_modify_order(
1013 &order,
1014 &format!(
1015 "Order with command.client_order_id: {} is already pending cancel",
1016 command.client_order_id
1017 ),
1018 );
1019 return false;
1020 }
1021
1022 let maybe_instrument = {
1023 let cache = self.cache.borrow();
1024 cache.instrument(&command.instrument_id).cloned()
1025 };
1026
1027 let Some(instrument) = maybe_instrument else {
1028 self.reject_modify_order(
1029 &order,
1030 &format!("no instrument found for {:?}", command.instrument_id),
1031 );
1032 return false;
1033 };
1034
1035 let mut reason = Self::check_price(&instrument, command.price, OrderPriceField::Price);
1037 if let Some(reason) = reason {
1038 self.reject_modify_order(&order, &reason.to_string());
1039 return false;
1040 }
1041
1042 reason = Self::check_price(
1044 &instrument,
1045 command.trigger_price,
1046 OrderPriceField::TriggerPrice,
1047 );
1048
1049 if let Some(reason) = reason {
1050 self.reject_modify_order(&order, &reason.to_string());
1051 return false;
1052 }
1053
1054 reason = Self::check_quantity(
1056 &instrument,
1057 command.quantity,
1058 order.is_quote_quantity(),
1059 false,
1060 );
1061
1062 if let Some(reason) = reason {
1063 self.reject_modify_order(&order, &reason.to_string());
1064 return false;
1065 }
1066
1067 let state_reason = match self.trading_state {
1068 TradingState::Halted => Some(OrderDeniedReason::TradingHalted.to_string()),
1069 TradingState::Reducing => Some(
1070 OrderDeniedReason::TradingStateReducing {
1071 order_side: order.order_side(),
1072 instrument_id: instrument.id(),
1073 }
1074 .to_string(),
1075 ),
1076 TradingState::Active => None,
1077 };
1078
1079 if let Some(reason) = state_reason {
1080 self.reject_modify_order(&order, &reason);
1081 return false;
1082 }
1083
1084 true
1085 }
1086
1087 fn check_modify_orders_risk(
1088 &self,
1089 commands: &[ModifyOrder],
1090 client_id: Option<ClientId>,
1091 ) -> bool {
1092 let mut originals = Vec::with_capacity(commands.len());
1093 let mut orders = Vec::with_capacity(commands.len());
1094 let cache = self.cache.borrow();
1095 for command in commands {
1096 let Some(order) = cache.order(&command.client_order_id) else {
1097 return false;
1098 };
1099
1100 originals.push(order.clone());
1101 let mut projected = order.clone();
1102
1103 projected.update(&OrderUpdated::new(
1105 order.trader_id(),
1106 order.strategy_id(),
1107 order.instrument_id(),
1108 order.client_order_id(),
1109 command.quantity.unwrap_or(order.quantity()),
1110 command.command_id,
1111 command.ts_init,
1112 command.ts_init,
1113 false,
1114 order.venue_order_id(),
1115 order.account_id(),
1116 command.price.filter(|_| {
1117 LIMIT_ORDER_TYPES.contains(&order.order_type())
1118 || order.order_type() == OrderType::MarketToLimit
1119 }),
1120 command.trigger_price.filter(|_| {
1121 STOP_ORDER_TYPES.contains(&order.order_type())
1122 || matches!(
1123 order.order_type(),
1124 OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
1125 )
1126 }),
1127 None,
1128 order.is_quote_quantity(),
1129 ));
1130
1131 orders.push(projected);
1132 }
1133
1134 let instrument = cache.instrument(&commands[0].instrument_id).cloned();
1135 drop(cache);
1136 let check = RiskCheck::Modify(&originals);
1137
1138 let Some(instrument) = instrument else {
1139 return false;
1140 };
1141
1142 self.check_orders_risk(&instrument, &orders, false, check, client_id)
1143 .is_some()
1144 }
1145
1146 fn check_order(
1147 &self,
1148 instrument: &InstrumentAny,
1149 order: &OrderAny,
1150 full_position_exit: bool,
1151 ) -> bool {
1152 if !self.check_order_price(instrument, order)
1153 || !self.check_order_quantity(instrument, order, full_position_exit)
1154 {
1155 return false; }
1157
1158 if order.time_in_force() == TimeInForce::Gtd {
1159 let Some(expire_time) = order.expire_time() else {
1160 self.deny_order(order, &OrderDeniedReason::MissingExpireTime.to_string());
1161 return false; };
1163
1164 if expire_time <= self.clock.borrow().timestamp_ns() {
1165 self.deny_order(
1166 order,
1167 &OrderDeniedReason::ExpireTimeInPast {
1168 expire_time: expire_time.to_rfc3339(),
1169 }
1170 .to_string(),
1171 );
1172 return false; }
1174 }
1175
1176 true
1177 }
1178
1179 fn check_order_price(&self, instrument: &InstrumentAny, order: &OrderAny) -> bool {
1180 if order.price().is_some() {
1181 let reason = Self::check_price(instrument, order.price(), OrderPriceField::Price);
1182 if let Some(reason) = reason {
1183 self.deny_order(order, &reason.to_string());
1184 return false; }
1186 }
1187
1188 if order.trigger_price().is_some() {
1189 let reason = Self::check_price(
1190 instrument,
1191 order.trigger_price(),
1192 OrderPriceField::TriggerPrice,
1193 );
1194
1195 if let Some(reason) = reason {
1196 self.deny_order(order, &reason.to_string());
1197 return false; }
1199 }
1200
1201 true
1202 }
1203
1204 fn check_order_quantity(
1205 &self,
1206 instrument: &InstrumentAny,
1207 order: &OrderAny,
1208 full_position_exit: bool,
1209 ) -> bool {
1210 let reason = Self::check_quantity(
1211 instrument,
1212 Some(order.quantity()),
1213 order.is_quote_quantity(),
1214 full_position_exit,
1215 );
1216
1217 if let Some(reason) = reason {
1218 self.deny_order(order, &reason.to_string());
1219 return false; }
1221
1222 true
1223 }
1224
1225 fn check_orders_risk(
1226 &self,
1227 instrument: &InstrumentAny,
1228 orders: &[OrderAny],
1229 full_position_exit: bool,
1230 check: RiskCheck<'_>,
1231 client_id: Option<ClientId>,
1232 ) -> Option<Vec<Reservation>> {
1233 let venue = instrument.id().venue;
1234 let mut orders_by_account: AHashMap<Option<AccountId>, Vec<&OrderAny>> = AHashMap::new();
1235 for order in orders {
1236 orders_by_account
1237 .entry(self.order_account_id(order, client_id, venue))
1238 .or_default()
1239 .push(order);
1240 }
1241
1242 let mut reservations = Vec::new();
1243
1244 for (account_id, account_orders) in &orders_by_account {
1245 reservations.extend(self.check_orders_risk_for_account(
1246 instrument,
1247 account_orders,
1248 *account_id,
1249 client_id,
1250 full_position_exit,
1251 check,
1252 )?);
1253 }
1254
1255 Some(reservations)
1256 }
1257
1258 fn record_reservations(&mut self, reservations: Vec<Reservation>) {
1261 self.reservations.retain(|held| {
1262 reservations
1263 .iter()
1264 .all(|reservation| reservation.client_order_id != held.client_order_id)
1265 });
1266 self.reservations.extend(reservations);
1267 }
1268
1269 fn release_reservations(&mut self) {
1270 if self.reservations.is_empty() {
1271 return;
1272 }
1273
1274 let cache = self.cache.borrow();
1275 self.reservations.retain(|reservation| {
1276 cache
1277 .order(&reservation.client_order_id)
1278 .is_some_and(|order| !reservation.is_released(&order))
1279 });
1280 }
1281
1282 fn unreflected_reservations(&self, account_id: AccountId, orders: &[&OrderAny]) -> Vec<Money> {
1284 self.reservations
1285 .iter()
1286 .filter(|reservation| {
1287 reservation.account_id == account_id
1288 && orders
1289 .iter()
1290 .all(|order| order.client_order_id() != reservation.client_order_id)
1291 })
1292 .map(|reservation| reservation.amount)
1293 .collect()
1294 }
1295
1296 fn order_account_id(
1301 &self,
1302 order: &OrderAny,
1303 client_id: Option<ClientId>,
1304 venue: Venue,
1305 ) -> Option<AccountId> {
1306 if let Some(account_id) = order.account_id() {
1307 return Some(account_id);
1308 }
1309
1310 let cache = self.cache.borrow();
1311
1312 let routed_account_id = || {
1313 cache
1314 .client_id_for_venue(&venue)
1315 .and_then(|client_id| cache.account_id_for_client(client_id))
1316 };
1317
1318 let account_id = match client_id {
1319 Some(client_id) if cache.is_external_exec_client(&client_id) => None,
1320 Some(client_id) => cache
1321 .account_id_for_client(&client_id)
1322 .or_else(routed_account_id),
1323 None => routed_account_id(),
1324 };
1325
1326 account_id.or_else(|| cache.account_id(&venue)).copied()
1327 }
1328
1329 #[allow(
1330 clippy::too_many_lines,
1331 reason = "risk checks keep related denial branches together for auditability"
1332 )]
1333 fn check_orders_risk_for_account(
1334 &self,
1335 instrument: &InstrumentAny,
1336 orders: &[&OrderAny],
1337 account_id: Option<AccountId>,
1338 client_id: Option<ClientId>,
1339 full_position_exit: bool,
1340 check: RiskCheck<'_>,
1341 ) -> Option<Vec<Reservation>> {
1342 let max_notional = match self.order_notional_limit(instrument) {
1343 Ok(limit) => limit,
1344 Err(reason) => {
1345 check.reject_orders(self, orders, &reason.to_string());
1346 return None;
1347 }
1348 };
1349
1350 let mut market_prices = Vec::with_capacity(orders.len());
1351
1352 for order in orders {
1353 let price = match order {
1354 OrderAny::Market(_) | OrderAny::MarketToLimit(_) => {
1355 self.market_order_price(instrument.id(), order.order_side())
1356 }
1357 _ => None,
1358 };
1359
1360 market_prices.push(price);
1361 }
1362
1363 let resolved_account = account_id.and_then(|account_id| {
1364 self.cache
1365 .borrow()
1366 .account(&account_id)
1367 .map(|account| account.clone_without_events())
1368 });
1369
1370 let Some(account) = resolved_account else {
1371 check.reject_orders(
1372 self,
1373 orders,
1374 &OrderDeniedReason::ValidationFailed {
1375 detail: format!(
1376 "No account available for risk checks: instrument_id={}, client_id={client_id:?}, account_id={account_id:?}",
1377 instrument.id()
1378 ),
1379 }
1380 .to_string(),
1381 );
1382
1383 return None;
1384 };
1385
1386 let allow_borrowing = match &account {
1387 AccountAny::Cash(cash) => cash.allow_borrowing,
1388 AccountAny::Margin(_) | AccountAny::Betting(_) | AccountAny::Wallet(_) => false,
1389 };
1390
1391 let available_long_qty_raw = self.available_position_quantity(
1392 instrument.id(),
1393 account.id(),
1394 PositionSide::Long,
1395 OrderSide::Sell,
1396 check,
1397 );
1398
1399 let available_short_qty_raw =
1400 if matches!(account, AccountAny::Margin(_) | AccountAny::Betting(_)) {
1401 self.available_position_quantity(
1402 instrument.id(),
1403 account.id(),
1404 PositionSide::Short,
1405 OrderSide::Buy,
1406 check,
1407 )
1408 } else {
1409 0
1410 };
1411
1412 let unreflected = match check {
1414 RiskCheck::Submit => self.unreflected_reservations(account.id(), orders),
1415 RiskCheck::Modify(_) => Vec::new(),
1416 };
1417
1418 let mut risk = AccountRisk {
1419 engine: self,
1420 instrument,
1421 account,
1422 check,
1423 full_position_exit,
1424 max_notional,
1425 allow_borrowing,
1426 available_long_qty_raw,
1427 available_short_qty_raw,
1428 cum_sell_qty_raw: 0,
1429 cum_buy_qty_raw: 0,
1430 cum_original_sell_qty_raw: 0,
1431 cum_original_buy_qty_raw: 0,
1432 cum_notional_buy: None,
1433 cum_notional_sell: None,
1434 cum_margin_required: None,
1435 unreflected,
1436 reservations: Vec::new(),
1437 };
1438
1439 for (&order, market_price) in orders.iter().zip(market_prices) {
1440 if !risk.check_order(order, market_price) {
1441 return None;
1442 }
1443 }
1444
1445 Some(risk.reservations)
1446 }
1447
1448 fn order_notional_limit(
1449 &self,
1450 instrument: &InstrumentAny,
1451 ) -> Result<Option<Money>, OrderDeniedReason> {
1452 let Some(value) = self.max_notional_per_order.get(&instrument.id()).copied() else {
1453 return Ok(None);
1454 };
1455
1456 Money::from_decimal(value, instrument.quote_currency())
1457 .map(Some)
1458 .map_err(|_| OrderDeniedReason::InvalidMaxNotionalPerOrder {
1459 instrument_id: instrument.id(),
1460 value,
1461 })
1462 }
1463
1464 fn available_position_quantity(
1465 &self,
1466 instrument_id: InstrumentId,
1467 account_id: AccountId,
1468 position_side: PositionSide,
1469 order_side: OrderSide,
1470 check: RiskCheck<'_>,
1471 ) -> QuantityRaw {
1472 let cache = self.cache.borrow();
1473 let position_quantity: QuantityRaw = cache
1474 .positions_open(
1475 None,
1476 Some(&instrument_id),
1477 None,
1478 Some(&account_id),
1479 Some(position_side),
1480 )
1481 .iter()
1482 .map(|position| position.quantity.raw())
1483 .sum();
1484 let pending_quantity: QuantityRaw = cache
1485 .orders_open(
1486 None,
1487 Some(&instrument_id),
1488 None,
1489 Some(&account_id),
1490 Some(order_side),
1491 )
1492 .iter()
1493 .filter(|order| check.original(order).is_none())
1494 .map(|order| order.leaves_qty().raw())
1495 .sum();
1496 let available = position_quantity.saturating_sub(pending_quantity);
1497
1498 if self.config.debug && position_quantity > 0 {
1499 log::debug!(
1500 "Net {position_side} qty (raw): {position_quantity}, pending {order_side}: {pending_quantity}, available: {available}"
1501 );
1502 }
1503
1504 available
1505 }
1506
1507 fn order_risk_quantity(
1508 &self,
1509 check: RiskCheck<'_>,
1510 instrument: &InstrumentAny,
1511 order: &OrderAny,
1512 quantity: Quantity,
1513 price: Price,
1514 ) -> Result<Quantity, ()> {
1515 if !order.is_quote_quantity() || instrument.is_inverse() {
1516 return Ok(quantity);
1517 }
1518
1519 let effective_price = if matches!(order, OrderAny::Limit(_) | OrderAny::StopLimit(_)) {
1520 self.cache
1521 .borrow()
1522 .quote(&instrument.id())
1523 .map_or(price, |quote| match order.order_side() {
1524 OrderSide::Buy => price.min(quote.ask_price),
1525 OrderSide::Sell => price.max(quote.bid_price),
1526 })
1527 } else {
1528 price
1529 };
1530
1531 instrument
1532 .try_calculate_base_quantity(quantity, effective_price)
1533 .map_err(|e| {
1534 check.reject(
1535 self,
1536 order,
1537 &OrderDeniedReason::QuantityConversionFailed {
1538 detail: e.to_string(),
1539 }
1540 .to_string(),
1541 );
1542 })
1543 }
1544
1545 fn check_risk_increase(
1546 &self,
1547 check: RiskCheck<'_>,
1548 order: &OrderAny,
1549 current: Money,
1550 previous: Money,
1551 ) -> Option<Money> {
1552 let increase = if current.currency == previous.currency {
1554 Money::from_decimal(
1555 (current.as_decimal().max(Decimal::ZERO)
1556 - previous.as_decimal().max(Decimal::ZERO))
1557 .max(Decimal::ZERO),
1558 current.currency,
1559 )
1560 .ok()
1561 } else {
1562 None
1563 };
1564
1565 if increase.is_none() {
1566 check.reject(
1567 self,
1568 order,
1569 &OrderDeniedReason::NotionalCalculationFailed {
1570 detail:
1571 "amendment risk increase exceeds Money bounds or has incompatible currency"
1572 .to_string(),
1573 }
1574 .to_string(),
1575 );
1576 }
1577
1578 increase
1579 }
1580
1581 fn market_order_price(
1582 &self,
1583 instrument_id: InstrumentId,
1584 order_side: OrderSide,
1585 ) -> Option<Price> {
1586 let price_type = match order_side {
1587 OrderSide::Buy => PriceType::Ask,
1588 OrderSide::Sell => PriceType::Bid,
1589 };
1590
1591 let cache = self.cache.borrow();
1592
1593 if let Some(price) = cache.price(&instrument_id, price_type) {
1594 return Some(price);
1595 }
1596
1597 if let Some(price) = cache.price(&instrument_id, PriceType::Last) {
1598 return Some(price);
1599 }
1600
1601 let bar_price = |price_type| {
1602 cache
1603 .bar_types(
1604 Some(&instrument_id),
1605 Some(&price_type),
1606 AggregationSource::External,
1607 )
1608 .into_iter()
1609 .filter_map(|bar_type| {
1610 cache
1611 .bar(bar_type)
1612 .map(|bar| (bar.ts_init, *bar_type, bar.close))
1613 })
1614 .max_by_key(|(ts_init, bar_type, _)| (*ts_init, *bar_type))
1615 .map(|(_, _, price)| price)
1616 };
1617
1618 bar_price(price_type).or_else(|| bar_price(PriceType::Last))
1619 }
1620
1621 #[allow(
1622 clippy::too_many_arguments,
1623 reason = "cash sell validation shares the account, cumulative exposure, and rejection context"
1624 )]
1625 fn check_cash_sell_balance(
1626 &self,
1627 check: RiskCheck<'_>,
1628 account: &dyn Account,
1629 allow_borrowing: bool,
1630 order: &OrderAny,
1631 quantity: Quantity,
1632 base_free: Money,
1633 cum_notional_sell: &mut Option<Money>,
1634 ) -> Result<Money, ()> {
1635 let base_currency = base_free.currency;
1636 let cash_value = match Money::from_quantity(quantity, base_currency) {
1637 Ok(value) => value,
1638 Err(e) => {
1639 check.reject(
1640 self,
1641 order,
1642 &OrderDeniedReason::QuantityConversionFailed {
1643 detail: e.to_string(),
1644 }
1645 .to_string(),
1646 );
1647
1648 return Err(());
1649 }
1650 };
1651
1652 if self.config.debug {
1653 log::debug!("Cash value: {cash_value:?}");
1654 log::debug!("Total: {:?}", account.balance_total(Some(base_currency)));
1655 log::debug!("Locked: {:?}", account.balance_locked(Some(base_currency)));
1656 log::debug!("Free: {base_free:?}");
1657 }
1658
1659 if !self.accumulate_notional(check, order, cum_notional_sell, cash_value) {
1660 return Err(());
1661 }
1662
1663 if self.config.debug {
1664 log::debug!("Cumulative notional SELL: {cum_notional_sell:?}");
1665 }
1666
1667 if !allow_borrowing
1668 && let Some(cum_notional_sell) = *cum_notional_sell
1669 && cum_notional_sell > base_free
1670 {
1671 check.reject(
1672 self,
1673 order,
1674 &OrderDeniedReason::CumulativeNotionalExceedsFreeBalance {
1675 free_balance: base_free,
1676 cumulative_notional: cum_notional_sell,
1677 }
1678 .to_string(),
1679 );
1680 return Err(());
1681 }
1682
1683 Ok(cash_value)
1684 }
1685
1686 fn accumulate_notional(
1687 &self,
1688 check: RiskCheck<'_>,
1689 order: &OrderAny,
1690 total: &mut Option<Money>,
1691 value: Money,
1692 ) -> bool {
1693 let next = match *total {
1694 Some(current) if current.currency == value.currency => current.checked_add(value),
1695 Some(_) => None,
1696 None => Some(value),
1697 };
1698
1699 let Some(next) = next else {
1700 check.reject(self,
1701 order,
1702 &OrderDeniedReason::NotionalCalculationFailed {
1703 detail: "cumulative notional exceeds Money bounds or has incompatible currency or scale".to_string(),
1704 }
1705 .to_string(),
1706 );
1707
1708 return false;
1709 };
1710
1711 *total = Some(next);
1712 true
1713 }
1714
1715 fn deny_no_market_price(
1716 &self,
1717 instrument_id: InstrumentId,
1718 order: &OrderAny,
1719 check: RiskCheck<'_>,
1720 ) {
1721 check.reject(
1722 self,
1723 order,
1724 &OrderDeniedReason::MarketPriceUnavailable {
1725 order_type: order.order_type(),
1726 instrument_id,
1727 }
1728 .to_string(),
1729 );
1730 }
1731
1732 fn check_price(
1733 instrument: &InstrumentAny,
1734 price: Option<Price>,
1735 field: OrderPriceField,
1736 ) -> Option<OrderDeniedReason> {
1737 let price_val = price?;
1738
1739 if price_val.precision > instrument.price_precision() {
1740 return Some(OrderDeniedReason::PricePrecisionExceedsMaximum {
1741 field,
1742 price: price_val,
1743 price_precision: price_val.precision,
1744 max_precision: instrument.price_precision(),
1745 });
1746 }
1747
1748 if !instrument.allows_negative_price() && (price_val.is_zero() || price_val.is_negative()) {
1749 return Some(OrderDeniedReason::PriceNotPositive {
1750 field,
1751 price: price_val,
1752 });
1753 }
1754
1755 None
1756 }
1757
1758 fn check_quantity(
1759 instrument: &InstrumentAny,
1760 quantity: Option<Quantity>,
1761 is_quote_quantity: bool,
1762 full_position_exit: bool,
1763 ) -> Option<OrderDeniedReason> {
1764 let quantity_val = quantity?;
1765
1766 if quantity_val.precision > instrument.size_precision() {
1768 return Some(OrderDeniedReason::QuantityPrecisionExceedsMaximum {
1769 quantity: quantity_val,
1770 quantity_precision: quantity_val.precision,
1771 max_precision: instrument.size_precision(),
1772 });
1773 }
1774
1775 if is_quote_quantity || full_position_exit {
1778 return None;
1779 }
1780
1781 if let Some(max_quantity) = instrument.max_quantity()
1783 && quantity_val > max_quantity
1784 {
1785 return Some(OrderDeniedReason::QuantityExceedsMaximum {
1786 effective_quantity: quantity_val,
1787 max_quantity,
1788 });
1789 }
1790
1791 if let Some(min_quantity) = instrument.min_quantity()
1793 && quantity_val < min_quantity
1794 {
1795 return Some(OrderDeniedReason::QuantityBelowMinimum {
1796 effective_quantity: quantity_val,
1797 min_quantity,
1798 });
1799 }
1800
1801 None
1802 }
1803
1804 fn deny_command(&self, command: TradingCommand, reason: &str) {
1805 match command {
1806 TradingCommand::SubmitOrder(command) => {
1807 let order = {
1808 let cache = self.cache.borrow();
1809 cache.order(&command.client_order_id).map(|o| o.clone())
1810 };
1811
1812 if let Some(ref order) = order {
1813 self.deny_order(order, reason);
1814 } else {
1815 log::error!(
1816 "Cannot deny order: not found in cache for {}",
1817 command.client_order_id
1818 );
1819 }
1820 }
1821 TradingCommand::SubmitOrderList(command) => {
1822 let orders: Vec<OrderAny> = self
1823 .cache
1824 .borrow()
1825 .orders_for_ids(&command.order_list.client_order_ids, &command);
1826 self.deny_order_list(&orders, reason);
1827 }
1828 _ => {
1829 log::error!("Cannot deny command {command}");
1830 }
1831 }
1832 }
1833
1834 fn deny_order(&self, order: &OrderAny, reason: &str) {
1835 log::warn!(
1836 "SubmitOrder for {} DENIED: {}",
1837 order.client_order_id(),
1838 reason
1839 );
1840
1841 if !matches!(
1842 order.status(),
1843 OrderStatus::Initialized | OrderStatus::Released
1844 ) {
1845 return;
1846 }
1847
1848 {
1850 let mut cache = self.cache.borrow_mut();
1851 if cache.is_order_dispatched(&order.client_order_id()) {
1852 return;
1853 }
1854
1855 if !cache.order_exists(&order.client_order_id())
1856 && let Err(e) = cache.add_order(order.clone(), None, None, false)
1857 {
1858 log::error!("Cannot add order to cache: {e}");
1859 return;
1860 }
1861 }
1862
1863 let denied = OrderEventAny::Denied(OrderDenied::new(
1864 order.trader_id(),
1865 order.strategy_id(),
1866 order.instrument_id(),
1867 order.client_order_id(),
1868 reason.into(),
1869 UUID4::new(),
1870 self.clock.borrow().timestamp_ns(),
1871 self.clock.borrow().timestamp_ns(),
1872 ));
1873
1874 let endpoint = MessagingSwitchboard::exec_engine_process();
1875 msgbus::send_order_event(endpoint, denied);
1876 }
1877
1878 fn deny_order_list(&self, orders: &[OrderAny], reason: &str) {
1879 for order in orders {
1880 if !order.is_closed() {
1881 self.deny_order(order, reason);
1882 }
1883 }
1884 }
1885
1886 fn reject_modify_order(&self, order: &OrderAny, reason: &str) {
1887 let ts_event = self.clock.borrow().timestamp_ns();
1888 let denied = OrderEventAny::ModifyRejected(OrderModifyRejected::new(
1889 order.trader_id(),
1890 order.strategy_id(),
1891 order.instrument_id(),
1892 order.client_order_id(),
1893 reason.into(),
1894 UUID4::new(),
1895 ts_event,
1896 ts_event,
1897 false,
1898 order.venue_order_id(),
1899 order.account_id(),
1900 ));
1901
1902 let endpoint = MessagingSwitchboard::exec_engine_process();
1903 msgbus::send_order_event(endpoint, denied);
1904 }
1905
1906 fn execution_gateway(&mut self, command: TradingCommand, reservations: Vec<Reservation>) {
1907 match self.trading_state {
1908 TradingState::Halted => match command {
1909 TradingCommand::SubmitOrder(submit_order) => {
1910 let order = {
1911 let cache = self.cache.borrow();
1912 cache
1913 .order(&submit_order.client_order_id)
1914 .map(|order| order.clone())
1915 };
1916
1917 if let Some(order) = order {
1918 self.deny_order(&order, &OrderDeniedReason::TradingHalted.to_string());
1919 }
1920 }
1921 TradingCommand::SubmitOrderList(submit_order_list) => {
1922 let orders: Vec<OrderAny> = self.cache.borrow().orders_for_ids(
1923 &submit_order_list.order_list.client_order_ids,
1924 &submit_order_list,
1925 );
1926 self.deny_order_list(&orders, &OrderDeniedReason::TradingHalted.to_string());
1927 }
1928 _ => {}
1929 },
1930 TradingState::Reducing => match command {
1931 TradingCommand::SubmitOrder(submit_order) => {
1932 let order = {
1933 let cache = self.cache.borrow();
1934 cache
1935 .order(&submit_order.client_order_id)
1936 .map(|order| order.clone())
1937 };
1938 let Some(order) = order else {
1939 return;
1940 };
1941
1942 if self.is_reducing_submission(&submit_order, &order) {
1943 self.record_reservations(reservations);
1944 self.throttler_submit
1945 .send(TradingCommand::SubmitOrder(submit_order));
1946 } else {
1947 self.deny_order(
1948 &order,
1949 &OrderDeniedReason::TradingStateReducing {
1950 order_side: order.order_side(),
1951 instrument_id: order.instrument_id(),
1952 }
1953 .to_string(),
1954 );
1955 }
1956 }
1957 TradingCommand::SubmitOrderList(submit_order_list) => {
1958 let orders: Vec<OrderAny> = self.cache.borrow().orders_for_ids(
1959 &submit_order_list.order_list.client_order_ids,
1960 &submit_order_list,
1961 );
1962
1963 for order in &orders {
1964 self.deny_order(
1965 order,
1966 &OrderDeniedReason::TradingStateReducing {
1967 order_side: order.order_side(),
1968 instrument_id: order.instrument_id(),
1969 }
1970 .to_string(),
1971 );
1972 }
1973 }
1974 _ => {}
1975 },
1976 TradingState::Active => match command {
1977 TradingCommand::SubmitOrder(_) | TradingCommand::SubmitOrderList(_) => {
1978 self.record_reservations(reservations);
1979 self.throttler_submit.send(command);
1980 }
1981 _ => {}
1982 },
1983 }
1984 }
1985
1986 fn send_to_execution(command: TradingCommand) {
1987 let endpoint = MessagingSwitchboard::exec_engine_queue_execute();
1988 msgbus::send_trading_command(endpoint, command);
1989 }
1990
1991 fn handle_event(&self, event: &OrderEventAny) {
1992 if self.config.debug {
1995 log::debug!("{RECV}{EVT} {event}");
1996 }
1997 }
1998
1999 fn handle_position_event(&self, event: &PositionEvent) {
2000 if self.config.debug {
2001 log::debug!("{RECV}{EVT} {event:?}");
2002 }
2003 }
2004}
2005
2006struct Reservation {
2008 client_order_id: ClientOrderId,
2009 account_id: AccountId,
2010 amount: Money,
2011 wallet: bool,
2012}
2013
2014impl Reservation {
2015 fn is_released(&self, order: &OrderAny) -> bool {
2018 order.is_open() || order.is_closed() || (self.wallet && order.is_inflight())
2019 }
2020}
2021
2022#[derive(Clone, Copy)]
2023enum RiskCheck<'a> {
2024 Submit,
2025 Modify(&'a [OrderAny]),
2026}
2027
2028impl<'a> RiskCheck<'a> {
2029 fn reject_orders(self, engine: &RiskEngine, orders: &[&OrderAny], reason: &str) {
2030 for order in orders {
2031 self.reject(engine, order, reason);
2032
2033 if matches!(self, Self::Modify(_)) {
2034 break;
2035 }
2036 }
2037 }
2038
2039 fn reject(self, engine: &RiskEngine, order: &OrderAny, reason: &str) {
2040 match self {
2041 Self::Submit => engine.deny_order(order, reason),
2042 Self::Modify(originals) => {
2043 for (index, original) in originals.iter().enumerate() {
2044 if originals[..index]
2045 .iter()
2046 .any(|previous| previous.client_order_id() == original.client_order_id())
2047 {
2048 continue;
2049 }
2050
2051 engine.reject_modify_order(original, reason);
2052 }
2053 }
2054 }
2055 }
2056
2057 fn original(self, order: &OrderAny) -> Option<&'a OrderAny> {
2058 match self {
2059 Self::Submit => None,
2060 Self::Modify(originals) => originals
2061 .iter()
2062 .find(|original| original.client_order_id() == order.client_order_id()),
2063 }
2064 }
2065}
2066
2067struct AccountRisk<'a> {
2068 engine: &'a RiskEngine,
2069 instrument: &'a InstrumentAny,
2070 account: AccountAny,
2071 check: RiskCheck<'a>,
2072 full_position_exit: bool,
2073 max_notional: Option<Money>,
2074 allow_borrowing: bool,
2075 available_long_qty_raw: QuantityRaw,
2076 available_short_qty_raw: QuantityRaw,
2077 cum_sell_qty_raw: QuantityRaw,
2078 cum_buy_qty_raw: QuantityRaw,
2079 cum_original_sell_qty_raw: QuantityRaw,
2080 cum_original_buy_qty_raw: QuantityRaw,
2081 cum_notional_buy: Option<Money>,
2082 cum_notional_sell: Option<Money>,
2083 cum_margin_required: Option<Money>,
2084 unreflected: Vec<Money>,
2085 reservations: Vec<Reservation>,
2086}
2087
2088impl AccountRisk<'_> {
2089 fn balance_free(&self, order: &OrderAny, currency: Currency) -> Result<Money, ()> {
2091 let free = self
2092 .account
2093 .balance_free(Some(currency))
2094 .unwrap_or_else(|| Money::zero(currency));
2095
2096 self.unreflected
2097 .iter()
2098 .filter(|amount| amount.currency == currency)
2099 .try_fold(free, |free, amount| self.deduct_reservation(free, *amount))
2100 .map_err(|detail| {
2101 self.check.reject(
2102 self.engine,
2103 order,
2104 &OrderDeniedReason::NotionalCalculationFailed { detail }.to_string(),
2105 );
2106 })
2107 }
2108
2109 fn deduct_reservation(&self, free: Money, amount: Money) -> Result<Money, String> {
2112 if matches!(self.account, AccountAny::Wallet(_))
2113 && amount.currency.precision != free.currency.precision
2114 {
2115 return Err(format!(
2116 "reserved balance precision {} differed from balance precision {} for {}",
2117 amount.currency.precision, free.currency.precision, free.currency
2118 ));
2119 }
2120
2121 free.checked_sub(amount).ok_or_else(|| {
2122 "reserved balance exceeds Money bounds or has incompatible scale".to_string()
2123 })
2124 }
2125
2126 fn reserve_balance(&mut self, order: &OrderAny, amount: Money) {
2127 if amount.is_positive() {
2128 self.reservations.push(Reservation {
2129 client_order_id: order.client_order_id(),
2130 account_id: self.account.id(),
2131 amount,
2132 wallet: matches!(self.account, AccountAny::Wallet(_)),
2133 });
2134 }
2135 }
2136
2137 fn check_order(&mut self, order: &OrderAny, market_price: Option<Price>) -> bool {
2138 let Ok(last_px) = self.order_price(order, market_price) else {
2139 return false;
2140 };
2141
2142 let Some(last_px) = last_px else {
2143 self.engine
2144 .deny_no_market_price(self.instrument.id(), order, self.check);
2145 return false;
2146 };
2147
2148 let Ok(effective_quantity) = self.engine.order_risk_quantity(
2149 self.check,
2150 self.instrument,
2151 order,
2152 order.quantity(),
2153 last_px,
2154 ) else {
2155 return false;
2156 };
2157
2158 if !self.check_order_limits(order, effective_quantity, last_px) {
2159 return false;
2160 }
2161
2162 let effective_quantity = if matches!(self.check, RiskCheck::Modify(_)) {
2164 let Ok(quantity) = self.engine.order_risk_quantity(
2165 self.check,
2166 self.instrument,
2167 order,
2168 order.leaves_qty(),
2169 last_px,
2170 ) else {
2171 return false;
2172 };
2173
2174 quantity
2175 } else {
2176 effective_quantity
2177 };
2178
2179 let Ok(original) = self.original_exposure(order, market_price) else {
2180 return false;
2181 };
2182
2183 let reserved_quantity = original.map_or(effective_quantity.raw(), |(_, quantity, _)| {
2185 quantity.raw().max(effective_quantity.raw())
2186 });
2187
2188 if matches!(self.account, AccountAny::Margin(_)) {
2189 return self.check_margin(
2190 order,
2191 effective_quantity,
2192 last_px,
2193 original,
2194 reserved_quantity,
2195 );
2196 }
2197
2198 self.check_balance(
2199 order,
2200 effective_quantity,
2201 last_px,
2202 original,
2203 reserved_quantity,
2204 )
2205 }
2206
2207 fn original_exposure(
2208 &mut self,
2209 order: &OrderAny,
2210 market_price: Option<Price>,
2211 ) -> Result<Option<(Price, Quantity, bool)>, ()> {
2212 let Some(original) = self.check.original(order).filter(|original| {
2213 original.is_open()
2214 || (matches!(self.account, AccountAny::Wallet(_)) && original.is_inflight())
2215 }) else {
2216 return Ok(None);
2217 };
2218
2219 let original_price = match self.order_price(original, market_price) {
2220 Ok(Some(price)) => price,
2221 Ok(None) => {
2222 self.engine
2223 .deny_no_market_price(self.instrument.id(), order, self.check);
2224 return Err(());
2225 }
2226 Err(()) => return Err(()),
2227 };
2228
2229 let quantity = self.engine.order_risk_quantity(
2230 self.check,
2231 self.instrument,
2232 original,
2233 original.leaves_qty(),
2234 original_price,
2235 )?;
2236 let is_reducing = !matches!(self.account, AccountAny::Wallet(_))
2237 && ((original.is_reduce_only()
2238 && (matches!(self.account, AccountAny::Margin(_)) || original.is_sell()))
2239 || (original.is_sell()
2240 && self.cum_original_sell_qty_raw + quantity.raw()
2241 <= self.available_long_qty_raw)
2242 || (original.is_buy()
2243 && self.cum_original_buy_qty_raw + quantity.raw()
2244 <= self.available_short_qty_raw));
2245
2246 if original.is_sell() {
2247 self.cum_original_sell_qty_raw += quantity.raw();
2248 } else {
2249 self.cum_original_buy_qty_raw += quantity.raw();
2250 }
2251
2252 Ok(Some((original_price, quantity, is_reducing)))
2253 }
2254
2255 fn order_price(
2256 &mut self,
2257 order: &OrderAny,
2258 market_price: Option<Price>,
2259 ) -> Result<Option<Price>, ()> {
2260 match order {
2261 OrderAny::MarketToLimit(_) if order.price().is_some() => Ok(order.price()),
2262 OrderAny::Market(_) | OrderAny::MarketToLimit(_) => {
2263 let Some(price) = market_price else {
2264 let is_reducing = !matches!(self.account, AccountAny::Wallet(_))
2265 && (order.is_reduce_only()
2266 || (order.is_sell()
2267 && (self.cum_sell_qty_raw + order.quantity().raw())
2268 <= self.available_long_qty_raw));
2269
2270 if !order.is_quote_quantity()
2271 && order.is_sell()
2272 && !is_reducing
2273 && let Some(unleveraged) = cash_or_wallet_account(&self.account)
2274 && unleveraged.base_currency().is_none()
2275 && let Some(base_currency) = self.instrument.base_currency()
2276 {
2277 let base_free = self.balance_free(order, base_currency)?;
2278 self.engine.check_cash_sell_balance(
2279 self.check,
2280 unleveraged,
2281 self.allow_borrowing,
2282 order,
2283 order.quantity(),
2284 base_free,
2285 &mut self.cum_notional_sell,
2286 )?;
2287 }
2288
2289 self.engine
2290 .deny_no_market_price(self.instrument.id(), order, self.check);
2291 return Err(());
2292 };
2293
2294 Ok(Some(price))
2295 }
2296 OrderAny::StopMarket(_) | OrderAny::MarketIfTouched(_) => Ok(order.trigger_price()),
2297 OrderAny::TrailingStopMarket(_) | OrderAny::TrailingStopLimit(_) => {
2298 self.trailing_order_price(order)
2299 }
2300 _ => Ok(order.price()),
2301 }
2302 }
2303
2304 fn trailing_order_price(&self, order: &OrderAny) -> Result<Option<Price>, ()> {
2305 if let Some(price) = order.trigger_price() {
2306 return Ok(order.price().or(Some(price)));
2307 }
2308
2309 let Some(offset_type) = order.trailing_offset_type() else {
2311 self.check.reject(
2312 self.engine,
2313 order,
2314 &OrderDeniedReason::MissingTrailingOffsetType.to_string(),
2315 );
2316 return Err(()); };
2318
2319 if !matches!(
2320 offset_type,
2321 TrailingOffsetType::Price | TrailingOffsetType::BasisPoints | TrailingOffsetType::Ticks
2322 ) {
2323 self.check.reject(
2324 self.engine,
2325 order,
2326 &OrderDeniedReason::UnsupportedTrailingOffsetType { offset_type }.to_string(),
2327 );
2328 return Err(());
2329 }
2330
2331 let Some(trigger_type) = order.trigger_type() else {
2332 self.check.reject(
2333 self.engine,
2334 order,
2335 &OrderDeniedReason::MissingTriggerType.to_string(),
2336 );
2337 return Err(()); };
2339
2340 let Some(trailing_offset) = order.trailing_offset() else {
2341 self.check.reject(
2342 self.engine,
2343 order,
2344 &OrderDeniedReason::MissingTrailingOffset.to_string(),
2345 );
2346 return Err(()); };
2348
2349 if let Some(price) = order.price() {
2350 return Ok(Some(price));
2351 }
2352
2353 self.calculate_trailing_price(order, offset_type, trigger_type, trailing_offset)
2355 .map_err(|detail| {
2356 self.check.reject(
2357 self.engine,
2358 order,
2359 &OrderDeniedReason::TrailingStopCalculationFailed { detail }.to_string(),
2360 );
2361 })
2362 }
2363
2364 fn calculate_trailing_price(
2365 &self,
2366 order: &OrderAny,
2367 offset_type: TrailingOffsetType,
2368 trigger_type: TriggerType,
2369 trailing_offset: Decimal,
2370 ) -> Result<Option<Price>, String> {
2371 let cache = self.engine.cache.borrow();
2372 if trigger_type != TriggerType::BidAsk
2373 && let Some(trade) = cache.trade(&self.instrument.id())
2374 {
2375 return trailing_stop_calculate_with_last(
2376 self.instrument.price_increment(),
2377 offset_type,
2378 order.order_side(),
2379 trailing_offset,
2380 trade.price,
2381 )
2382 .map(Some)
2383 .map_err(|e| e.to_string());
2384 }
2385
2386 if matches!(
2387 trigger_type,
2388 TriggerType::BidAsk | TriggerType::LastOrBidAsk
2389 ) && let Some(quote) = cache.quote(&self.instrument.id())
2390 {
2391 return trailing_stop_calculate_with_bid_ask(
2392 self.instrument.price_increment(),
2393 offset_type,
2394 order.order_side(),
2395 trailing_offset,
2396 quote.bid_price,
2397 quote.ask_price,
2398 )
2399 .map(Some)
2400 .map_err(|e| e.to_string());
2401 }
2402
2403 if trigger_type == TriggerType::BidAsk {
2404 log::warn!(
2405 "Cannot check {} order risk: no trigger price set and no bid/ask quotes available for {}",
2406 order.order_type(),
2407 self.instrument.id()
2408 );
2409 } else {
2410 log::warn!(
2411 "Cannot check {} order risk: no trigger price set and no market data available for {}",
2412 order.order_type(),
2413 self.instrument.id()
2414 );
2415 }
2416
2417 Ok(None)
2418 }
2419
2420 fn check_order_limits(
2421 &self,
2422 order: &OrderAny,
2423 effective_quantity: Quantity,
2424 last_px: Price,
2425 ) -> bool {
2426 if !order.is_quote_quantity() && !self.full_position_exit {
2432 if let Some(max_quantity) = self.instrument.max_quantity()
2433 && effective_quantity > max_quantity
2434 {
2435 self.check.reject(
2436 self.engine,
2437 order,
2438 &OrderDeniedReason::QuantityExceedsMaximum {
2439 effective_quantity,
2440 max_quantity,
2441 }
2442 .to_string(),
2443 );
2444
2445 return false; }
2447
2448 if let Some(min_quantity) = self.instrument.min_quantity()
2449 && effective_quantity < min_quantity
2450 {
2451 self.check.reject(
2452 self.engine,
2453 order,
2454 &OrderDeniedReason::QuantityBelowMinimum {
2455 effective_quantity,
2456 min_quantity,
2457 }
2458 .to_string(),
2459 );
2460
2461 return false; }
2463 }
2464
2465 let notional = match self.instrument.try_calculate_notional_value(
2467 effective_quantity,
2468 last_px,
2469 Some(true),
2470 ) {
2471 Ok(notional) => notional.abs(),
2472 Err(e) => {
2473 self.check.reject(
2474 self.engine,
2475 order,
2476 &OrderDeniedReason::NotionalCalculationFailed {
2477 detail: e.to_string(),
2478 }
2479 .to_string(),
2480 );
2481
2482 return false;
2483 }
2484 };
2485
2486 if self.engine.config.debug {
2487 log::debug!("Notional: {notional:?}");
2488 }
2489
2490 if !self.full_position_exit
2492 && let Some(max_notional_value) = self.max_notional
2493 && notional > max_notional_value
2494 {
2495 self.check.reject(
2496 self.engine,
2497 order,
2498 &OrderDeniedReason::NotionalExceedsMaxPerOrder {
2499 max_notional: max_notional_value,
2500 notional,
2501 }
2502 .to_string(),
2503 );
2504
2505 return false; }
2507
2508 if !order.is_reduce_only()
2511 && !self.full_position_exit
2512 && let Some(min_notional) = self.instrument.min_notional()
2513 && notional.currency == min_notional.currency
2514 && notional < min_notional
2515 {
2516 self.check.reject(
2517 self.engine,
2518 order,
2519 &OrderDeniedReason::NotionalBelowMinimum {
2520 min_notional,
2521 notional,
2522 }
2523 .to_string(),
2524 );
2525
2526 return false; }
2528
2529 if !self.full_position_exit
2531 && let Some(max_notional) = self.instrument.max_notional()
2532 && notional.currency == max_notional.currency
2533 && notional > max_notional
2534 {
2535 self.check.reject(
2536 self.engine,
2537 order,
2538 &OrderDeniedReason::NotionalExceedsMaximum {
2539 max_notional,
2540 notional,
2541 }
2542 .to_string(),
2543 );
2544
2545 return false; }
2547
2548 true
2549 }
2550
2551 fn check_margin(
2552 &mut self,
2553 order: &OrderAny,
2554 quantity: Quantity,
2555 price: Price,
2556 original: Option<(Price, Quantity, bool)>,
2557 reserved_quantity: QuantityRaw,
2558 ) -> bool {
2559 let Ok(required) = self.initial_margin(order, quantity, price) else {
2560 return false;
2561 };
2562
2563 if self.engine.config.debug {
2564 log::debug!("Initial margin required: {required}");
2565 }
2566
2567 if self.reserve_position(order, quantity, reserved_quantity) {
2568 if self.engine.config.debug {
2569 log::debug!("Position-reducing order skips margin check");
2570 }
2571
2572 return true;
2573 }
2574
2575 let Ok(required) = self.margin_increase(order, required, original) else {
2576 return false;
2577 };
2578
2579 self.check_margin_balance(order, required)
2580 }
2581
2582 fn margin_increase(
2583 &mut self,
2584 order: &OrderAny,
2585 required: Money,
2586 original: Option<(Price, Quantity, bool)>,
2587 ) -> Result<Money, ()> {
2588 let Some((price, quantity, was_reducing)) = original else {
2589 return Ok(required);
2590 };
2591
2592 let previous = if was_reducing {
2593 Money::zero(required.currency)
2594 } else {
2595 self.initial_margin(order, quantity, price)?
2596 };
2597
2598 self.engine
2599 .check_risk_increase(self.check, order, required, previous)
2600 .ok_or(())
2601 }
2602
2603 fn initial_margin(
2604 &mut self,
2605 order: &OrderAny,
2606 quantity: Quantity,
2607 price: Price,
2608 ) -> Result<Money, ()> {
2609 let AccountAny::Margin(margin) = &mut self.account else {
2610 unreachable!()
2611 };
2612
2613 margin
2614 .calculate_initial_margin(self.instrument, quantity, price, None)
2615 .map_err(|e| {
2616 self.check.reject(
2617 self.engine,
2618 order,
2619 &OrderDeniedReason::InitialMarginCalculationFailed {
2620 detail: e.to_string(),
2621 }
2622 .to_string(),
2623 );
2624 })
2625 }
2626
2627 fn check_margin_balance(&mut self, order: &OrderAny, required: Money) -> bool {
2628 if matches!(self.check, RiskCheck::Modify(_)) && required.is_zero() {
2629 return true;
2630 }
2631
2632 let Ok(required) = self.account_currency_amount(order, required) else {
2633 return false;
2634 };
2635
2636 let Ok(free) = self.balance_free(order, required.currency) else {
2638 return false;
2639 };
2640
2641 if required > free {
2642 self.check.reject(
2643 self.engine,
2644 order,
2645 &OrderDeniedReason::InitialMarginExceedsFreeBalance {
2646 free_balance: free,
2647 initial_margin: required,
2648 }
2649 .to_string(),
2650 );
2651
2652 return false;
2653 }
2654
2655 let total = match self.cum_margin_required {
2656 Some(total) => total.checked_add(required),
2657 None => Some(required),
2658 };
2659
2660 let Some(total) = total else {
2661 self.check.reject(
2662 self.engine,
2663 order,
2664 &OrderDeniedReason::CumulativeInitialMarginCalculationFailed {
2665 detail: "total exceeds Money bounds".to_string(),
2666 }
2667 .to_string(),
2668 );
2669
2670 return false;
2671 };
2672
2673 self.cum_margin_required = Some(total);
2674
2675 if self.engine.config.debug {
2676 log::debug!("Cumulative margin required: {:?}", self.cum_margin_required);
2677 }
2678
2679 if total > free {
2680 self.check.reject(
2681 self.engine,
2682 order,
2683 &OrderDeniedReason::CumulativeInitialMarginExceedsFreeBalance {
2684 free_balance: free,
2685 cumulative_initial_margin: total,
2686 }
2687 .to_string(),
2688 );
2689
2690 return false;
2691 }
2692
2693 self.reserve_balance(order, required);
2694 true
2695 }
2696
2697 fn check_balance(
2698 &mut self,
2699 order: &OrderAny,
2700 quantity: Quantity,
2701 price: Price,
2702 original: Option<(Price, Quantity, bool)>,
2703 reserved_quantity: QuantityRaw,
2704 ) -> bool {
2705 let Ok((notional, impact)) = self.balance_impact(order, quantity, price) else {
2706 return false;
2707 };
2708
2709 if self.engine.config.debug {
2710 log::debug!("Balance impact: {impact}");
2711 }
2712
2713 if self.reserve_position(order, quantity, reserved_quantity) {
2714 if self.engine.config.debug {
2715 log::debug!("Position-reducing order skips balance check");
2716 }
2717
2718 return true;
2719 }
2720
2721 let Ok(impact) = self.balance_increase(order, impact, original) else {
2722 return false;
2723 };
2724
2725 let is_debit = order.is_buy() || matches!(self.account, AccountAny::Betting(_));
2726 if matches!(self.check, RiskCheck::Modify(_))
2727 && impact.is_zero()
2728 && (is_debit || self.account.base_currency().is_some())
2729 {
2730 return true;
2731 }
2732
2733 let Ok(impact) = self.account_currency_amount(order, impact) else {
2734 return false;
2735 };
2736
2737 let Ok(free) = self.balance_free(order, impact.currency) else {
2738 return false;
2739 };
2740
2741 if !self.allow_borrowing && free.as_decimal() + impact.as_decimal() < Decimal::ZERO {
2742 self.check.reject(
2743 self.engine,
2744 order,
2745 &OrderDeniedReason::NotionalExceedsFreeBalance {
2746 free_balance: free,
2747 notional,
2748 }
2749 .to_string(),
2750 );
2751
2752 return false;
2753 }
2754
2755 if is_debit {
2756 return self.check_cumulative_balance(order, -impact);
2757 }
2758
2759 if self.instrument.base_currency().is_some() && self.account.base_currency().is_some() {
2762 return self.check_cumulative_balance(order, impact);
2763 }
2764
2765 self.check_asset_balance(order, quantity, original)
2766 }
2767
2768 fn reserve_position(
2769 &mut self,
2770 order: &OrderAny,
2771 quantity: Quantity,
2772 reserved: QuantityRaw,
2773 ) -> bool {
2774 let (cumulative, available) = match order.order_side() {
2775 OrderSide::Buy => (&mut self.cum_buy_qty_raw, self.available_short_qty_raw),
2776 OrderSide::Sell => (&mut self.cum_sell_qty_raw, self.available_long_qty_raw),
2777 };
2778
2779 let reducing = self.full_position_exit
2780 || (order.is_reduce_only()
2781 && (matches!(self.account, AccountAny::Margin(_)) || order.is_sell()))
2782 || *cumulative + quantity.raw() <= available;
2783 *cumulative += reserved;
2784 reducing && !matches!(self.account, AccountAny::Wallet(_))
2785 }
2786
2787 fn balance_impact(
2788 &mut self,
2789 order: &OrderAny,
2790 quantity: Quantity,
2791 price: Price,
2792 ) -> Result<(Money, Money), ()> {
2793 let notional = self
2794 .instrument
2795 .try_calculate_notional_value(quantity, price, None)
2796 .map_err(|e| {
2797 self.check.reject(
2798 self.engine,
2799 order,
2800 &OrderDeniedReason::NotionalCalculationFailed {
2801 detail: e.to_string(),
2802 }
2803 .to_string(),
2804 );
2805 })?;
2806
2807 let impact = if let AccountAny::Betting(betting) = &mut self.account {
2808 -betting
2809 .calculate_balance_locked(
2810 self.instrument,
2811 order.order_side(),
2812 quantity,
2813 price,
2814 None,
2815 )
2816 .map_err(|e| {
2817 self.check.reject(
2818 self.engine,
2819 order,
2820 &OrderDeniedReason::BettingBalanceLockedCalculationFailed {
2821 detail: e.to_string(),
2822 }
2823 .to_string(),
2824 );
2825 })?
2826 } else if let Some(funding) =
2827 self.wallet_buy_funding(order, quantity, price, notional.currency)
2828 {
2829 let funding = funding.map_err(|detail| {
2830 self.check.reject(
2831 self.engine,
2832 order,
2833 &OrderDeniedReason::NotionalCalculationFailed { detail }.to_string(),
2834 );
2835 })?;
2836
2837 return Ok((funding, -funding));
2839 } else {
2840 match order.order_side() {
2841 OrderSide::Buy => -notional,
2842 OrderSide::Sell => notional,
2843 }
2844 };
2845
2846 Ok((notional, impact))
2847 }
2848
2849 fn wallet_buy_funding(
2854 &self,
2855 order: &OrderAny,
2856 quantity: Quantity,
2857 price: Price,
2858 notional_currency: Currency,
2859 ) -> Option<Result<Money, String>> {
2860 let AccountAny::Wallet(wallet) = &self.account else {
2861 return None;
2862 };
2863
2864 if !order.is_buy() {
2865 return None;
2866 }
2867
2868 if order.is_quote_quantity() {
2869 let quote_currency = self.instrument.quote_currency();
2870 let currency = wallet
2871 .balance_total(Some(quote_currency))
2872 .map_or(quote_currency, |observed| observed.currency);
2873 return Some(
2874 Money::from_quantity(order.leaves_qty(), currency).map_err(|e| e.to_string()),
2875 );
2876 }
2877
2878 wallet.balance_total(Some(notional_currency))?;
2879 Some(
2880 wallet
2881 .calculate_balance_locked(self.instrument, OrderSide::Buy, quantity, price, None)
2882 .map_err(|e| e.to_string()),
2883 )
2884 }
2885
2886 fn balance_increase(
2887 &mut self,
2888 order: &OrderAny,
2889 impact: Money,
2890 original: Option<(Price, Quantity, bool)>,
2891 ) -> Result<Money, ()> {
2892 let Some((price, quantity, was_reducing)) = original else {
2893 return Ok(impact);
2894 };
2895
2896 let previous = if was_reducing {
2897 Ok(Money::zero(impact.currency))
2898 } else if let AccountAny::Betting(betting) = &mut self.account {
2899 betting
2900 .calculate_balance_locked(
2901 self.instrument,
2902 order.order_side(),
2903 quantity,
2904 price,
2905 None,
2906 )
2907 .map_err(|e| e.to_string())
2908 } else if let Some(funding) = self.check.original(order).and_then(|original| {
2909 self.wallet_buy_funding(original, quantity, price, impact.currency)
2910 }) {
2911 funding
2912 } else {
2913 self.instrument
2914 .try_calculate_notional_value(quantity, price, None)
2915 .map_err(|e| e.to_string())
2916 }
2917 .map_err(|detail| {
2918 self.check.reject(
2919 self.engine,
2920 order,
2921 &OrderDeniedReason::NotionalCalculationFailed { detail }.to_string(),
2922 );
2923 })?;
2924
2925 let is_debit = order.is_buy() || matches!(self.account, AccountAny::Betting(_));
2926 let current = if is_debit { -impact } else { impact };
2927 let increase = self
2928 .engine
2929 .check_risk_increase(self.check, order, current, previous)
2930 .ok_or(())?;
2931 Ok(if is_debit { -increase } else { increase })
2932 }
2933
2934 fn check_cumulative_balance(&mut self, order: &OrderAny, required: Money) -> bool {
2935 let cumulative = match order.order_side() {
2936 OrderSide::Buy => &mut self.cum_notional_buy,
2937 OrderSide::Sell => &mut self.cum_notional_sell,
2938 };
2939
2940 if !self
2941 .engine
2942 .accumulate_notional(self.check, order, cumulative, required)
2943 {
2944 return false;
2945 }
2946
2947 if self.engine.config.debug {
2948 log::debug!(
2949 "Cumulative balance required for {}: {cumulative:?}",
2950 order.order_side()
2951 );
2952 }
2953
2954 let cumulative = *cumulative;
2955 let Ok(free) = self.balance_free(order, required.currency) else {
2956 return false;
2957 };
2958
2959 if !self.allow_borrowing
2960 && let Some(total) = cumulative
2961 && total > free
2962 {
2963 self.check.reject(
2964 self.engine,
2965 order,
2966 &OrderDeniedReason::CumulativeNotionalExceedsFreeBalance {
2967 free_balance: free,
2968 cumulative_notional: total,
2969 }
2970 .to_string(),
2971 );
2972
2973 return false;
2974 }
2975
2976 self.reserve_balance(order, required);
2977 true
2978 }
2979
2980 fn account_currency_amount(&self, order: &OrderAny, amount: Money) -> Result<Money, ()> {
2981 let Some(currency) = self.account.base_currency() else {
2982 return Ok(amount);
2983 };
2984
2985 if amount.currency == currency {
2986 return Ok(amount);
2987 }
2988
2989 if amount.is_zero() {
2990 return Ok(Money::zero(currency));
2991 }
2992
2993 let price_type = match order.order_side() {
2995 OrderSide::Buy => PriceType::Bid,
2996 OrderSide::Sell => PriceType::Ask,
2997 };
2998
2999 let xrate = self.engine.cache.borrow().try_get_xrate(
3000 self.instrument.id().venue,
3001 amount.currency,
3002 currency,
3003 price_type,
3004 );
3005 xrate
3006 .map_err(|e| e.to_string())
3007 .and_then(|xrate| {
3008 let xrate = xrate.ok_or_else(|| {
3009 format!("No exchange rate from {} to {currency}", amount.currency)
3010 })?;
3011
3012 let value = amount.as_decimal().checked_mul(xrate).ok_or_else(|| {
3013 "Account currency conversion exceeds Decimal bounds".to_string()
3014 })?;
3015
3016 Money::from_decimal(value, currency).map_err(|e| e.to_string())
3017 })
3018 .map_err(|e| {
3019 self.check.reject(
3020 self.engine,
3021 order,
3022 &OrderDeniedReason::ValidationFailed {
3023 detail: format!("Account currency conversion failed: {e}"),
3024 }
3025 .to_string(),
3026 );
3027 })
3028 }
3029
3030 fn check_asset_balance(
3031 &mut self,
3032 order: &OrderAny,
3033 quantity: Quantity,
3034 original: Option<(Price, Quantity, bool)>,
3035 ) -> bool {
3036 let Some(base_currency) = self.instrument.base_currency() else {
3037 return true;
3038 };
3039
3040 let Ok(base_free) = self.balance_free(order, base_currency) else {
3041 return false;
3042 };
3043
3044 let Some(account) = cash_or_wallet_account(&self.account) else {
3045 unreachable!()
3046 };
3047
3048 let quantity = match original {
3049 Some((_, previous, false)) => quantity.saturating_sub(previous),
3050 _ => quantity,
3051 };
3052
3053 let Ok(cash_value) = self.engine.check_cash_sell_balance(
3054 self.check,
3055 account,
3056 self.allow_borrowing,
3057 order,
3058 quantity,
3059 base_free,
3060 &mut self.cum_notional_sell,
3061 ) else {
3062 return false;
3063 };
3064
3065 self.reserve_balance(order, cash_value);
3066 true
3067 }
3068}
3069
3070fn cash_or_wallet_account(account: &AccountAny) -> Option<&dyn Account> {
3073 match account {
3074 AccountAny::Cash(cash) => Some(cash),
3075 AccountAny::Wallet(wallet) => Some(wallet),
3076 AccountAny::Margin(_) | AccountAny::Betting(_) => None,
3077 }
3078}
3079
3080#[cfg(test)]
3081mod tests;