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 order in &orders {
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 return; }
835 }
836
837 let representative = if let Some(instrument) = instruments.get(&command.instrument_id) {
838 instrument.clone()
839 } else {
840 self.deny_order_list(
841 &orders,
842 &OrderDeniedReason::InstrumentNotFound {
843 instrument_id: command.instrument_id,
844 }
845 .to_string(),
846 );
847 return; };
849
850 let Some(reservations) = self.check_orders_risk(
851 &representative,
852 &orders,
853 false,
854 RiskCheck::Submit,
855 command.client_id,
856 ) else {
857 self.deny_order_list(
858 &orders,
859 &OrderDeniedReason::OrderListDenied {
860 order_list_id: command.order_list.id,
861 }
862 .to_string(),
863 );
864 return; };
866
867 self.execution_gateway(TradingCommand::SubmitOrderList(command), reservations);
868 }
869
870 fn handle_modify_order(&mut self, command: ModifyOrder) {
871 if self.config.bypass {
872 Self::send_to_execution(TradingCommand::ModifyOrder(command));
873 return;
874 }
875
876 if !self.validate_modify_order(&command)
877 || !self.check_modify_orders_risk(std::slice::from_ref(&command), command.client_id)
878 {
879 return;
880 }
881
882 self.throttler_modify.send(command);
883 }
884
885 fn handle_batch_modify_orders(&mut self, command: BatchModifyOrders) {
886 if self.config.bypass {
887 Self::send_to_execution(TradingCommand::ModifyOrders(command));
888 return;
889 }
890
891 if command.modifies.is_empty() {
892 log::warn!("Cannot handle BatchModifyOrders: no modify commands");
893 return;
894 }
895
896 if !self.validate_batch_modify_orders(&command) {
897 return;
898 }
899
900 if !self.check_modify_orders_risk(&command.modifies, command.client_id) {
901 return;
902 }
903
904 if !self.throttler_modify.try_reserve(command.modifies.len()) {
905 let reason = "Exceeded MAX_ORDER_MODIFY_RATE";
906
907 for modify in &command.modifies {
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
915 return;
916 }
917
918 Self::send_to_execution(TradingCommand::ModifyOrders(command));
919 }
920
921 fn validate_batch_modify_orders(&self, command: &BatchModifyOrders) -> bool {
922 let mut rejected_client_order_ids = Vec::new();
923 let mut valid = true;
924
925 for modify in &command.modifies {
926 if !self.validate_batch_modify_order(command, modify) {
927 rejected_client_order_ids.push(modify.client_order_id);
928 valid = false;
929 }
930 }
931
932 if !valid {
933 let reason = "BatchModifyOrders rejected because one or more child modifications failed validation";
934
935 for modify in &command.modifies {
936 if rejected_client_order_ids.contains(&modify.client_order_id) {
937 continue;
938 }
939
940 let Some(order) = Self::get_existing_order(&self.cache, modify) else {
941 continue;
942 };
943
944 self.reject_modify_order(&order, reason);
945 }
946
947 return false;
948 }
949
950 true
951 }
952
953 fn validate_batch_modify_order(
954 &self,
955 command: &BatchModifyOrders,
956 modify: &ModifyOrder,
957 ) -> bool {
958 if modify.instrument_id != command.instrument_id {
959 let order = self
960 .cache
961 .borrow()
962 .order(&modify.client_order_id)
963 .map(|order| order.clone());
964
965 if let Some(order) = order {
966 self.reject_modify_order(
967 &order,
968 &format!(
969 "BatchModifyOrders instrument {} does not match child instrument {}",
970 command.instrument_id, modify.instrument_id
971 ),
972 );
973 }
974
975 return false;
976 }
977
978 self.validate_modify_order(modify)
979 }
980
981 fn validate_modify_order(&self, command: &ModifyOrder) -> bool {
982 let order_exists = {
983 let cache = self.cache.borrow();
984 cache.order(&command.client_order_id).map(|o| o.clone())
985 };
986
987 let Some(order) = order_exists else {
988 log::error!(
989 "ModifyOrder DENIED: Order with command.client_order_id: {} not found",
990 command.client_order_id
991 );
992 return false;
993 };
994
995 if order.is_closed() {
996 self.reject_modify_order(
997 &order,
998 &format!(
999 "Order with command.client_order_id: {} already closed",
1000 command.client_order_id
1001 ),
1002 );
1003 return false;
1004 } else if order.status() == OrderStatus::PendingCancel {
1005 self.reject_modify_order(
1006 &order,
1007 &format!(
1008 "Order with command.client_order_id: {} is already pending cancel",
1009 command.client_order_id
1010 ),
1011 );
1012 return false;
1013 }
1014
1015 let maybe_instrument = {
1016 let cache = self.cache.borrow();
1017 cache.instrument(&command.instrument_id).cloned()
1018 };
1019
1020 let Some(instrument) = maybe_instrument else {
1021 self.reject_modify_order(
1022 &order,
1023 &format!("no instrument found for {:?}", command.instrument_id),
1024 );
1025 return false;
1026 };
1027
1028 let mut reason = Self::check_price(&instrument, command.price, OrderPriceField::Price);
1030 if let Some(reason) = reason {
1031 self.reject_modify_order(&order, &reason.to_string());
1032 return false;
1033 }
1034
1035 reason = Self::check_price(
1037 &instrument,
1038 command.trigger_price,
1039 OrderPriceField::TriggerPrice,
1040 );
1041
1042 if let Some(reason) = reason {
1043 self.reject_modify_order(&order, &reason.to_string());
1044 return false;
1045 }
1046
1047 reason = Self::check_quantity(
1049 &instrument,
1050 command.quantity,
1051 order.is_quote_quantity(),
1052 false,
1053 );
1054
1055 if let Some(reason) = reason {
1056 self.reject_modify_order(&order, &reason.to_string());
1057 return false;
1058 }
1059
1060 let state_reason = match self.trading_state {
1061 TradingState::Halted => Some(OrderDeniedReason::TradingHalted.to_string()),
1062 TradingState::Reducing => Some(
1063 OrderDeniedReason::TradingStateReducing {
1064 order_side: order.order_side(),
1065 instrument_id: instrument.id(),
1066 }
1067 .to_string(),
1068 ),
1069 TradingState::Active => None,
1070 };
1071
1072 if let Some(reason) = state_reason {
1073 self.reject_modify_order(&order, &reason);
1074 return false;
1075 }
1076
1077 true
1078 }
1079
1080 fn check_modify_orders_risk(
1081 &self,
1082 commands: &[ModifyOrder],
1083 client_id: Option<ClientId>,
1084 ) -> bool {
1085 let mut originals = Vec::with_capacity(commands.len());
1086 let mut orders = Vec::with_capacity(commands.len());
1087 let cache = self.cache.borrow();
1088 for command in commands {
1089 let Some(order) = cache.order(&command.client_order_id) else {
1090 return false;
1091 };
1092
1093 originals.push(order.clone());
1094 let mut projected = order.clone();
1095
1096 projected.update(&OrderUpdated::new(
1098 order.trader_id(),
1099 order.strategy_id(),
1100 order.instrument_id(),
1101 order.client_order_id(),
1102 command.quantity.unwrap_or(order.quantity()),
1103 command.command_id,
1104 command.ts_init,
1105 command.ts_init,
1106 false,
1107 order.venue_order_id(),
1108 order.account_id(),
1109 command.price.filter(|_| {
1110 LIMIT_ORDER_TYPES.contains(&order.order_type())
1111 || order.order_type() == OrderType::MarketToLimit
1112 }),
1113 command.trigger_price.filter(|_| {
1114 STOP_ORDER_TYPES.contains(&order.order_type())
1115 || matches!(
1116 order.order_type(),
1117 OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
1118 )
1119 }),
1120 None,
1121 order.is_quote_quantity(),
1122 ));
1123
1124 orders.push(projected);
1125 }
1126
1127 let instrument = cache.instrument(&commands[0].instrument_id).cloned();
1128 drop(cache);
1129 let check = RiskCheck::Modify(&originals);
1130
1131 let Some(instrument) = instrument else {
1132 return false;
1133 };
1134
1135 self.check_orders_risk(&instrument, &orders, false, check, client_id)
1136 .is_some()
1137 }
1138
1139 fn check_order(
1140 &self,
1141 instrument: &InstrumentAny,
1142 order: &OrderAny,
1143 full_position_exit: bool,
1144 ) -> bool {
1145 if !self.check_order_price(instrument, order)
1146 || !self.check_order_quantity(instrument, order, full_position_exit)
1147 {
1148 return false; }
1150
1151 if order.time_in_force() == TimeInForce::Gtd {
1152 let Some(expire_time) = order.expire_time() else {
1153 self.deny_order(order, &OrderDeniedReason::MissingExpireTime.to_string());
1154 return false; };
1156
1157 if expire_time <= self.clock.borrow().timestamp_ns() {
1158 self.deny_order(
1159 order,
1160 &OrderDeniedReason::ExpireTimeInPast {
1161 expire_time: expire_time.to_rfc3339(),
1162 }
1163 .to_string(),
1164 );
1165 return false; }
1167 }
1168
1169 true
1170 }
1171
1172 fn check_order_price(&self, instrument: &InstrumentAny, order: &OrderAny) -> bool {
1173 if order.price().is_some() {
1174 let reason = Self::check_price(instrument, order.price(), OrderPriceField::Price);
1175 if let Some(reason) = reason {
1176 self.deny_order(order, &reason.to_string());
1177 return false; }
1179 }
1180
1181 if order.trigger_price().is_some() {
1182 let reason = Self::check_price(
1183 instrument,
1184 order.trigger_price(),
1185 OrderPriceField::TriggerPrice,
1186 );
1187
1188 if let Some(reason) = reason {
1189 self.deny_order(order, &reason.to_string());
1190 return false; }
1192 }
1193
1194 true
1195 }
1196
1197 fn check_order_quantity(
1198 &self,
1199 instrument: &InstrumentAny,
1200 order: &OrderAny,
1201 full_position_exit: bool,
1202 ) -> bool {
1203 let reason = Self::check_quantity(
1204 instrument,
1205 Some(order.quantity()),
1206 order.is_quote_quantity(),
1207 full_position_exit,
1208 );
1209
1210 if let Some(reason) = reason {
1211 self.deny_order(order, &reason.to_string());
1212 return false; }
1214
1215 true
1216 }
1217
1218 fn check_orders_risk(
1219 &self,
1220 instrument: &InstrumentAny,
1221 orders: &[OrderAny],
1222 full_position_exit: bool,
1223 check: RiskCheck<'_>,
1224 client_id: Option<ClientId>,
1225 ) -> Option<Vec<Reservation>> {
1226 let venue = instrument.id().venue;
1227 let mut orders_by_account: AHashMap<Option<AccountId>, Vec<&OrderAny>> = AHashMap::new();
1228 for order in orders {
1229 orders_by_account
1230 .entry(self.order_account_id(order, client_id, venue))
1231 .or_default()
1232 .push(order);
1233 }
1234
1235 let mut reservations = Vec::new();
1236
1237 for (account_id, account_orders) in &orders_by_account {
1238 reservations.extend(self.check_orders_risk_for_account(
1239 instrument,
1240 account_orders,
1241 *account_id,
1242 client_id,
1243 full_position_exit,
1244 check,
1245 )?);
1246 }
1247
1248 Some(reservations)
1249 }
1250
1251 fn record_reservations(&mut self, reservations: Vec<Reservation>) {
1254 self.reservations.retain(|held| {
1255 reservations
1256 .iter()
1257 .all(|reservation| reservation.client_order_id != held.client_order_id)
1258 });
1259 self.reservations.extend(reservations);
1260 }
1261
1262 fn release_reservations(&mut self) {
1263 if self.reservations.is_empty() {
1264 return;
1265 }
1266
1267 let cache = self.cache.borrow();
1268 self.reservations.retain(|reservation| {
1269 cache
1270 .order(&reservation.client_order_id)
1271 .is_some_and(|order| !reservation.is_released(&order))
1272 });
1273 }
1274
1275 fn unreflected_reservations(&self, account_id: AccountId, orders: &[&OrderAny]) -> Vec<Money> {
1277 self.reservations
1278 .iter()
1279 .filter(|reservation| {
1280 reservation.account_id == account_id
1281 && orders
1282 .iter()
1283 .all(|order| order.client_order_id() != reservation.client_order_id)
1284 })
1285 .map(|reservation| reservation.amount)
1286 .collect()
1287 }
1288
1289 fn order_account_id(
1294 &self,
1295 order: &OrderAny,
1296 client_id: Option<ClientId>,
1297 venue: Venue,
1298 ) -> Option<AccountId> {
1299 if let Some(account_id) = order.account_id() {
1300 return Some(account_id);
1301 }
1302
1303 let cache = self.cache.borrow();
1304
1305 let routed_account_id = || {
1306 cache
1307 .client_id_for_venue(&venue)
1308 .and_then(|client_id| cache.account_id_for_client(client_id))
1309 };
1310
1311 let account_id = match client_id {
1312 Some(client_id) if cache.is_external_client(&client_id) => None,
1313 Some(client_id) => cache
1314 .account_id_for_client(&client_id)
1315 .or_else(routed_account_id),
1316 None => routed_account_id(),
1317 };
1318
1319 account_id.or_else(|| cache.account_id(&venue)).copied()
1320 }
1321
1322 #[allow(
1323 clippy::too_many_lines,
1324 reason = "risk checks keep related denial branches together for auditability"
1325 )]
1326 fn check_orders_risk_for_account(
1327 &self,
1328 instrument: &InstrumentAny,
1329 orders: &[&OrderAny],
1330 account_id: Option<AccountId>,
1331 client_id: Option<ClientId>,
1332 full_position_exit: bool,
1333 check: RiskCheck<'_>,
1334 ) -> Option<Vec<Reservation>> {
1335 let max_notional = match self.order_notional_limit(instrument) {
1336 Ok(limit) => limit,
1337 Err(reason) => {
1338 check.reject_orders(self, orders, &reason.to_string());
1339 return None;
1340 }
1341 };
1342
1343 let mut market_prices = Vec::with_capacity(orders.len());
1344
1345 for order in orders {
1346 let price = match order {
1347 OrderAny::Market(_) | OrderAny::MarketToLimit(_) => {
1348 self.market_order_price(instrument.id(), order.order_side())
1349 }
1350 _ => None,
1351 };
1352
1353 market_prices.push(price);
1354 }
1355
1356 let resolved_account = account_id.and_then(|account_id| {
1357 self.cache
1358 .borrow()
1359 .account(&account_id)
1360 .map(|account| account.clone_without_events())
1361 });
1362
1363 let Some(account) = resolved_account else {
1364 check.reject_orders(
1365 self,
1366 orders,
1367 &OrderDeniedReason::ValidationFailed {
1368 detail: format!(
1369 "No account available for risk checks: instrument_id={}, client_id={client_id:?}, account_id={account_id:?}",
1370 instrument.id()
1371 ),
1372 }
1373 .to_string(),
1374 );
1375
1376 return None;
1377 };
1378
1379 let allow_borrowing = match &account {
1380 AccountAny::Cash(cash) => cash.allow_borrowing,
1381 AccountAny::Margin(_) | AccountAny::Betting(_) | AccountAny::Wallet(_) => false,
1382 };
1383
1384 let available_long_qty_raw = self.available_position_quantity(
1385 instrument.id(),
1386 account.id(),
1387 PositionSide::Long,
1388 OrderSide::Sell,
1389 check,
1390 );
1391
1392 let available_short_qty_raw =
1393 if matches!(account, AccountAny::Margin(_) | AccountAny::Betting(_)) {
1394 self.available_position_quantity(
1395 instrument.id(),
1396 account.id(),
1397 PositionSide::Short,
1398 OrderSide::Buy,
1399 check,
1400 )
1401 } else {
1402 0
1403 };
1404
1405 let unreflected = match check {
1407 RiskCheck::Submit => self.unreflected_reservations(account.id(), orders),
1408 RiskCheck::Modify(_) => Vec::new(),
1409 };
1410
1411 let mut risk = AccountRisk {
1412 engine: self,
1413 instrument,
1414 account,
1415 check,
1416 full_position_exit,
1417 max_notional,
1418 allow_borrowing,
1419 available_long_qty_raw,
1420 available_short_qty_raw,
1421 cum_sell_qty_raw: 0,
1422 cum_buy_qty_raw: 0,
1423 cum_original_sell_qty_raw: 0,
1424 cum_original_buy_qty_raw: 0,
1425 cum_notional_buy: None,
1426 cum_notional_sell: None,
1427 cum_margin_required: None,
1428 unreflected,
1429 reservations: Vec::new(),
1430 };
1431
1432 for (&order, market_price) in orders.iter().zip(market_prices) {
1433 if !risk.check_order(order, market_price) {
1434 return None;
1435 }
1436 }
1437
1438 Some(risk.reservations)
1439 }
1440
1441 fn order_notional_limit(
1442 &self,
1443 instrument: &InstrumentAny,
1444 ) -> Result<Option<Money>, OrderDeniedReason> {
1445 let Some(value) = self.max_notional_per_order.get(&instrument.id()).copied() else {
1446 return Ok(None);
1447 };
1448
1449 Money::from_decimal(value, instrument.quote_currency())
1450 .map(Some)
1451 .map_err(|_| OrderDeniedReason::InvalidMaxNotionalPerOrder {
1452 instrument_id: instrument.id(),
1453 value,
1454 })
1455 }
1456
1457 fn available_position_quantity(
1458 &self,
1459 instrument_id: InstrumentId,
1460 account_id: AccountId,
1461 position_side: PositionSide,
1462 order_side: OrderSide,
1463 check: RiskCheck<'_>,
1464 ) -> QuantityRaw {
1465 let cache = self.cache.borrow();
1466 let position_quantity: QuantityRaw = cache
1467 .positions_open(
1468 None,
1469 Some(&instrument_id),
1470 None,
1471 Some(&account_id),
1472 Some(position_side),
1473 )
1474 .iter()
1475 .map(|position| position.quantity.raw())
1476 .sum();
1477 let pending_quantity: QuantityRaw = cache
1478 .orders_open(
1479 None,
1480 Some(&instrument_id),
1481 None,
1482 Some(&account_id),
1483 Some(order_side),
1484 )
1485 .iter()
1486 .filter(|order| check.original(order).is_none())
1487 .map(|order| order.leaves_qty().raw())
1488 .sum();
1489 let available = position_quantity.saturating_sub(pending_quantity);
1490
1491 if self.config.debug && position_quantity > 0 {
1492 log::debug!(
1493 "Net {position_side} qty (raw): {position_quantity}, pending {order_side}: {pending_quantity}, available: {available}"
1494 );
1495 }
1496
1497 available
1498 }
1499
1500 fn order_risk_quantity(
1501 &self,
1502 check: RiskCheck<'_>,
1503 instrument: &InstrumentAny,
1504 order: &OrderAny,
1505 quantity: Quantity,
1506 price: Price,
1507 ) -> Result<Quantity, ()> {
1508 if !order.is_quote_quantity() || instrument.is_inverse() {
1509 return Ok(quantity);
1510 }
1511
1512 let effective_price = if matches!(order, OrderAny::Limit(_) | OrderAny::StopLimit(_)) {
1513 self.cache
1514 .borrow()
1515 .quote(&instrument.id())
1516 .map_or(price, |quote| match order.order_side() {
1517 OrderSide::Buy => price.min(quote.ask_price),
1518 OrderSide::Sell => price.max(quote.bid_price),
1519 })
1520 } else {
1521 price
1522 };
1523
1524 instrument
1525 .try_calculate_base_quantity(quantity, effective_price)
1526 .map_err(|e| {
1527 check.reject(
1528 self,
1529 order,
1530 &OrderDeniedReason::QuantityConversionFailed {
1531 detail: e.to_string(),
1532 }
1533 .to_string(),
1534 );
1535 })
1536 }
1537
1538 fn check_risk_increase(
1539 &self,
1540 check: RiskCheck<'_>,
1541 order: &OrderAny,
1542 current: Money,
1543 previous: Money,
1544 ) -> Option<Money> {
1545 let increase = if current.currency == previous.currency {
1547 Money::from_decimal(
1548 (current.as_decimal().max(Decimal::ZERO)
1549 - previous.as_decimal().max(Decimal::ZERO))
1550 .max(Decimal::ZERO),
1551 current.currency,
1552 )
1553 .ok()
1554 } else {
1555 None
1556 };
1557
1558 if increase.is_none() {
1559 check.reject(
1560 self,
1561 order,
1562 &OrderDeniedReason::NotionalCalculationFailed {
1563 detail:
1564 "amendment risk increase exceeds Money bounds or has incompatible currency"
1565 .to_string(),
1566 }
1567 .to_string(),
1568 );
1569 }
1570
1571 increase
1572 }
1573
1574 fn market_order_price(
1575 &self,
1576 instrument_id: InstrumentId,
1577 order_side: OrderSide,
1578 ) -> Option<Price> {
1579 let price_type = match order_side {
1580 OrderSide::Buy => PriceType::Ask,
1581 OrderSide::Sell => PriceType::Bid,
1582 };
1583
1584 let cache = self.cache.borrow();
1585
1586 if let Some(price) = cache.price(&instrument_id, price_type) {
1587 return Some(price);
1588 }
1589
1590 if let Some(price) = cache.price(&instrument_id, PriceType::Last) {
1591 return Some(price);
1592 }
1593
1594 let bar_price = |price_type| {
1595 cache
1596 .bar_types(
1597 Some(&instrument_id),
1598 Some(&price_type),
1599 AggregationSource::External,
1600 )
1601 .into_iter()
1602 .filter_map(|bar_type| {
1603 cache
1604 .bar(bar_type)
1605 .map(|bar| (bar.ts_init, *bar_type, bar.close))
1606 })
1607 .max_by_key(|(ts_init, bar_type, _)| (*ts_init, *bar_type))
1608 .map(|(_, _, price)| price)
1609 };
1610
1611 bar_price(price_type).or_else(|| bar_price(PriceType::Last))
1612 }
1613
1614 #[allow(
1615 clippy::too_many_arguments,
1616 reason = "cash sell validation shares the account, cumulative exposure, and rejection context"
1617 )]
1618 fn check_cash_sell_balance(
1619 &self,
1620 check: RiskCheck<'_>,
1621 account: &dyn Account,
1622 allow_borrowing: bool,
1623 order: &OrderAny,
1624 quantity: Quantity,
1625 base_free: Money,
1626 cum_notional_sell: &mut Option<Money>,
1627 ) -> Result<Money, ()> {
1628 let base_currency = base_free.currency;
1629 let cash_value = match Money::from_quantity(quantity, base_currency) {
1630 Ok(value) => value,
1631 Err(e) => {
1632 check.reject(
1633 self,
1634 order,
1635 &OrderDeniedReason::QuantityConversionFailed {
1636 detail: e.to_string(),
1637 }
1638 .to_string(),
1639 );
1640
1641 return Err(());
1642 }
1643 };
1644
1645 if self.config.debug {
1646 log::debug!("Cash value: {cash_value:?}");
1647 log::debug!("Total: {:?}", account.balance_total(Some(base_currency)));
1648 log::debug!("Locked: {:?}", account.balance_locked(Some(base_currency)));
1649 log::debug!("Free: {base_free:?}");
1650 }
1651
1652 if !self.accumulate_notional(check, order, cum_notional_sell, cash_value) {
1653 return Err(());
1654 }
1655
1656 if self.config.debug {
1657 log::debug!("Cumulative notional SELL: {cum_notional_sell:?}");
1658 }
1659
1660 if !allow_borrowing
1661 && let Some(cum_notional_sell) = *cum_notional_sell
1662 && cum_notional_sell > base_free
1663 {
1664 check.reject(
1665 self,
1666 order,
1667 &OrderDeniedReason::CumulativeNotionalExceedsFreeBalance {
1668 free_balance: base_free,
1669 cumulative_notional: cum_notional_sell,
1670 }
1671 .to_string(),
1672 );
1673 return Err(());
1674 }
1675
1676 Ok(cash_value)
1677 }
1678
1679 fn accumulate_notional(
1680 &self,
1681 check: RiskCheck<'_>,
1682 order: &OrderAny,
1683 total: &mut Option<Money>,
1684 value: Money,
1685 ) -> bool {
1686 let next = match *total {
1687 Some(current) if current.currency == value.currency => current.checked_add(value),
1688 Some(_) => None,
1689 None => Some(value),
1690 };
1691
1692 let Some(next) = next else {
1693 check.reject(self,
1694 order,
1695 &OrderDeniedReason::NotionalCalculationFailed {
1696 detail: "cumulative notional exceeds Money bounds or has incompatible currency or scale".to_string(),
1697 }
1698 .to_string(),
1699 );
1700
1701 return false;
1702 };
1703
1704 *total = Some(next);
1705 true
1706 }
1707
1708 fn deny_no_market_price(
1709 &self,
1710 instrument_id: InstrumentId,
1711 order: &OrderAny,
1712 check: RiskCheck<'_>,
1713 ) {
1714 check.reject(
1715 self,
1716 order,
1717 &OrderDeniedReason::MarketPriceUnavailable {
1718 order_type: order.order_type(),
1719 instrument_id,
1720 }
1721 .to_string(),
1722 );
1723 }
1724
1725 fn check_price(
1726 instrument: &InstrumentAny,
1727 price: Option<Price>,
1728 field: OrderPriceField,
1729 ) -> Option<OrderDeniedReason> {
1730 let price_val = price?;
1731
1732 if price_val.precision > instrument.price_precision() {
1733 return Some(OrderDeniedReason::PricePrecisionExceedsMaximum {
1734 field,
1735 price: price_val,
1736 price_precision: price_val.precision,
1737 max_precision: instrument.price_precision(),
1738 });
1739 }
1740
1741 if !instrument.allows_negative_price() && (price_val.is_zero() || price_val.is_negative()) {
1742 return Some(OrderDeniedReason::PriceNotPositive {
1743 field,
1744 price: price_val,
1745 });
1746 }
1747
1748 None
1749 }
1750
1751 fn check_quantity(
1752 instrument: &InstrumentAny,
1753 quantity: Option<Quantity>,
1754 is_quote_quantity: bool,
1755 full_position_exit: bool,
1756 ) -> Option<OrderDeniedReason> {
1757 let quantity_val = quantity?;
1758
1759 if quantity_val.precision > instrument.size_precision() {
1761 return Some(OrderDeniedReason::QuantityPrecisionExceedsMaximum {
1762 quantity: quantity_val,
1763 quantity_precision: quantity_val.precision,
1764 max_precision: instrument.size_precision(),
1765 });
1766 }
1767
1768 if is_quote_quantity || full_position_exit {
1771 return None;
1772 }
1773
1774 if let Some(max_quantity) = instrument.max_quantity()
1776 && quantity_val > max_quantity
1777 {
1778 return Some(OrderDeniedReason::QuantityExceedsMaximum {
1779 effective_quantity: quantity_val,
1780 max_quantity,
1781 });
1782 }
1783
1784 if let Some(min_quantity) = instrument.min_quantity()
1786 && quantity_val < min_quantity
1787 {
1788 return Some(OrderDeniedReason::QuantityBelowMinimum {
1789 effective_quantity: quantity_val,
1790 min_quantity,
1791 });
1792 }
1793
1794 None
1795 }
1796
1797 fn deny_command(&self, command: TradingCommand, reason: &str) {
1798 match command {
1799 TradingCommand::SubmitOrder(command) => {
1800 let order = {
1801 let cache = self.cache.borrow();
1802 cache.order(&command.client_order_id).map(|o| o.clone())
1803 };
1804
1805 if let Some(ref order) = order {
1806 self.deny_order(order, reason);
1807 } else {
1808 log::error!(
1809 "Cannot deny order: not found in cache for {}",
1810 command.client_order_id
1811 );
1812 }
1813 }
1814 TradingCommand::SubmitOrderList(command) => {
1815 let orders: Vec<OrderAny> = self
1816 .cache
1817 .borrow()
1818 .orders_for_ids(&command.order_list.client_order_ids, &command);
1819 self.deny_order_list(&orders, reason);
1820 }
1821 _ => {
1822 log::error!("Cannot deny command {command}");
1823 }
1824 }
1825 }
1826
1827 fn deny_order(&self, order: &OrderAny, reason: &str) {
1828 log::warn!(
1829 "SubmitOrder for {} DENIED: {}",
1830 order.client_order_id(),
1831 reason
1832 );
1833
1834 if order.status() != OrderStatus::Initialized {
1835 return;
1836 }
1837
1838 {
1840 let mut cache = self.cache.borrow_mut();
1841 if !cache.order_exists(&order.client_order_id())
1842 && let Err(e) = cache.add_order(order.clone(), None, None, false)
1843 {
1844 log::error!("Cannot add order to cache: {e}");
1845 return;
1846 }
1847 }
1848
1849 let denied = OrderEventAny::Denied(OrderDenied::new(
1850 order.trader_id(),
1851 order.strategy_id(),
1852 order.instrument_id(),
1853 order.client_order_id(),
1854 reason.into(),
1855 UUID4::new(),
1856 self.clock.borrow().timestamp_ns(),
1857 self.clock.borrow().timestamp_ns(),
1858 ));
1859
1860 let endpoint = MessagingSwitchboard::exec_engine_process();
1861 msgbus::send_order_event(endpoint, denied);
1862 }
1863
1864 fn deny_order_list(&self, orders: &[OrderAny], reason: &str) {
1865 for order in orders {
1866 if !order.is_closed() {
1867 self.deny_order(order, reason);
1868 }
1869 }
1870 }
1871
1872 fn reject_modify_order(&self, order: &OrderAny, reason: &str) {
1873 let ts_event = self.clock.borrow().timestamp_ns();
1874 let denied = OrderEventAny::ModifyRejected(OrderModifyRejected::new(
1875 order.trader_id(),
1876 order.strategy_id(),
1877 order.instrument_id(),
1878 order.client_order_id(),
1879 reason.into(),
1880 UUID4::new(),
1881 ts_event,
1882 ts_event,
1883 false,
1884 order.venue_order_id(),
1885 order.account_id(),
1886 ));
1887
1888 let endpoint = MessagingSwitchboard::exec_engine_process();
1889 msgbus::send_order_event(endpoint, denied);
1890 }
1891
1892 fn execution_gateway(&mut self, command: TradingCommand, reservations: Vec<Reservation>) {
1893 match self.trading_state {
1894 TradingState::Halted => match command {
1895 TradingCommand::SubmitOrder(submit_order) => {
1896 let order = {
1897 let cache = self.cache.borrow();
1898 cache
1899 .order(&submit_order.client_order_id)
1900 .map(|order| order.clone())
1901 };
1902
1903 if let Some(order) = order {
1904 self.deny_order(&order, &OrderDeniedReason::TradingHalted.to_string());
1905 }
1906 }
1907 TradingCommand::SubmitOrderList(submit_order_list) => {
1908 let orders: Vec<OrderAny> = self.cache.borrow().orders_for_ids(
1909 &submit_order_list.order_list.client_order_ids,
1910 &submit_order_list,
1911 );
1912 self.deny_order_list(&orders, &OrderDeniedReason::TradingHalted.to_string());
1913 }
1914 _ => {}
1915 },
1916 TradingState::Reducing => match command {
1917 TradingCommand::SubmitOrder(submit_order) => {
1918 let order = {
1919 let cache = self.cache.borrow();
1920 cache
1921 .order(&submit_order.client_order_id)
1922 .map(|order| order.clone())
1923 };
1924 let Some(order) = order else {
1925 return;
1926 };
1927
1928 if self.is_reducing_submission(&submit_order, &order) {
1929 self.record_reservations(reservations);
1930 self.throttler_submit
1931 .send(TradingCommand::SubmitOrder(submit_order));
1932 } else {
1933 self.deny_order(
1934 &order,
1935 &OrderDeniedReason::TradingStateReducing {
1936 order_side: order.order_side(),
1937 instrument_id: order.instrument_id(),
1938 }
1939 .to_string(),
1940 );
1941 }
1942 }
1943 TradingCommand::SubmitOrderList(submit_order_list) => {
1944 let orders: Vec<OrderAny> = self.cache.borrow().orders_for_ids(
1945 &submit_order_list.order_list.client_order_ids,
1946 &submit_order_list,
1947 );
1948
1949 for order in &orders {
1950 self.deny_order(
1951 order,
1952 &OrderDeniedReason::TradingStateReducing {
1953 order_side: order.order_side(),
1954 instrument_id: order.instrument_id(),
1955 }
1956 .to_string(),
1957 );
1958 }
1959 }
1960 _ => {}
1961 },
1962 TradingState::Active => match command {
1963 TradingCommand::SubmitOrder(_) | TradingCommand::SubmitOrderList(_) => {
1964 self.record_reservations(reservations);
1965 self.throttler_submit.send(command);
1966 }
1967 _ => {}
1968 },
1969 }
1970 }
1971
1972 fn send_to_execution(command: TradingCommand) {
1973 let endpoint = MessagingSwitchboard::exec_engine_queue_execute();
1974 msgbus::send_trading_command(endpoint, command);
1975 }
1976
1977 fn handle_event(&self, event: &OrderEventAny) {
1978 if self.config.debug {
1981 log::debug!("{RECV}{EVT} {event}");
1982 }
1983 }
1984
1985 fn handle_position_event(&self, event: &PositionEvent) {
1986 if self.config.debug {
1987 log::debug!("{RECV}{EVT} {event:?}");
1988 }
1989 }
1990}
1991
1992struct Reservation {
1994 client_order_id: ClientOrderId,
1995 account_id: AccountId,
1996 amount: Money,
1997 wallet: bool,
1998}
1999
2000impl Reservation {
2001 fn is_released(&self, order: &OrderAny) -> bool {
2004 order.is_open() || order.is_closed() || (self.wallet && order.is_inflight())
2005 }
2006}
2007
2008#[derive(Clone, Copy)]
2009enum RiskCheck<'a> {
2010 Submit,
2011 Modify(&'a [OrderAny]),
2012}
2013
2014impl<'a> RiskCheck<'a> {
2015 fn reject_orders(self, engine: &RiskEngine, orders: &[&OrderAny], reason: &str) {
2016 for order in orders {
2017 self.reject(engine, order, reason);
2018
2019 if matches!(self, Self::Modify(_)) {
2020 break;
2021 }
2022 }
2023 }
2024
2025 fn reject(self, engine: &RiskEngine, order: &OrderAny, reason: &str) {
2026 match self {
2027 Self::Submit => engine.deny_order(order, reason),
2028 Self::Modify(originals) => {
2029 for (index, original) in originals.iter().enumerate() {
2030 if originals[..index]
2031 .iter()
2032 .any(|previous| previous.client_order_id() == original.client_order_id())
2033 {
2034 continue;
2035 }
2036
2037 engine.reject_modify_order(original, reason);
2038 }
2039 }
2040 }
2041 }
2042
2043 fn original(self, order: &OrderAny) -> Option<&'a OrderAny> {
2044 match self {
2045 Self::Submit => None,
2046 Self::Modify(originals) => originals
2047 .iter()
2048 .find(|original| original.client_order_id() == order.client_order_id()),
2049 }
2050 }
2051}
2052
2053struct AccountRisk<'a> {
2054 engine: &'a RiskEngine,
2055 instrument: &'a InstrumentAny,
2056 account: AccountAny,
2057 check: RiskCheck<'a>,
2058 full_position_exit: bool,
2059 max_notional: Option<Money>,
2060 allow_borrowing: bool,
2061 available_long_qty_raw: QuantityRaw,
2062 available_short_qty_raw: QuantityRaw,
2063 cum_sell_qty_raw: QuantityRaw,
2064 cum_buy_qty_raw: QuantityRaw,
2065 cum_original_sell_qty_raw: QuantityRaw,
2066 cum_original_buy_qty_raw: QuantityRaw,
2067 cum_notional_buy: Option<Money>,
2068 cum_notional_sell: Option<Money>,
2069 cum_margin_required: Option<Money>,
2070 unreflected: Vec<Money>,
2071 reservations: Vec<Reservation>,
2072}
2073
2074impl AccountRisk<'_> {
2075 fn balance_free(&self, order: &OrderAny, currency: Currency) -> Result<Money, ()> {
2077 let free = self
2078 .account
2079 .balance_free(Some(currency))
2080 .unwrap_or_else(|| Money::zero(currency));
2081
2082 self.unreflected
2083 .iter()
2084 .filter(|amount| amount.currency == currency)
2085 .try_fold(free, |free, amount| self.deduct_reservation(free, *amount))
2086 .map_err(|detail| {
2087 self.check.reject(
2088 self.engine,
2089 order,
2090 &OrderDeniedReason::NotionalCalculationFailed { detail }.to_string(),
2091 );
2092 })
2093 }
2094
2095 fn deduct_reservation(&self, free: Money, amount: Money) -> Result<Money, String> {
2098 if matches!(self.account, AccountAny::Wallet(_))
2099 && amount.currency.precision != free.currency.precision
2100 {
2101 return Err(format!(
2102 "reserved balance precision {} differed from balance precision {} for {}",
2103 amount.currency.precision, free.currency.precision, free.currency
2104 ));
2105 }
2106
2107 free.checked_sub(amount).ok_or_else(|| {
2108 "reserved balance exceeds Money bounds or has incompatible scale".to_string()
2109 })
2110 }
2111
2112 fn reserve_balance(&mut self, order: &OrderAny, amount: Money) {
2113 if amount.is_positive() {
2114 self.reservations.push(Reservation {
2115 client_order_id: order.client_order_id(),
2116 account_id: self.account.id(),
2117 amount,
2118 wallet: matches!(self.account, AccountAny::Wallet(_)),
2119 });
2120 }
2121 }
2122
2123 fn check_order(&mut self, order: &OrderAny, market_price: Option<Price>) -> bool {
2124 let Ok(last_px) = self.order_price(order, market_price) else {
2125 return false;
2126 };
2127
2128 let Some(last_px) = last_px else {
2129 self.engine
2130 .deny_no_market_price(self.instrument.id(), order, self.check);
2131 return false;
2132 };
2133
2134 let Ok(effective_quantity) = self.engine.order_risk_quantity(
2135 self.check,
2136 self.instrument,
2137 order,
2138 order.quantity(),
2139 last_px,
2140 ) else {
2141 return false;
2142 };
2143
2144 if !self.check_order_limits(order, effective_quantity, last_px) {
2145 return false;
2146 }
2147
2148 let effective_quantity = if matches!(self.check, RiskCheck::Modify(_)) {
2150 let Ok(quantity) = self.engine.order_risk_quantity(
2151 self.check,
2152 self.instrument,
2153 order,
2154 order.leaves_qty(),
2155 last_px,
2156 ) else {
2157 return false;
2158 };
2159
2160 quantity
2161 } else {
2162 effective_quantity
2163 };
2164
2165 let Ok(original) = self.original_exposure(order, market_price) else {
2166 return false;
2167 };
2168
2169 let reserved_quantity = original.map_or(effective_quantity.raw(), |(_, quantity, _)| {
2171 quantity.raw().max(effective_quantity.raw())
2172 });
2173
2174 if matches!(self.account, AccountAny::Margin(_)) {
2175 return self.check_margin(
2176 order,
2177 effective_quantity,
2178 last_px,
2179 original,
2180 reserved_quantity,
2181 );
2182 }
2183
2184 self.check_balance(
2185 order,
2186 effective_quantity,
2187 last_px,
2188 original,
2189 reserved_quantity,
2190 )
2191 }
2192
2193 fn original_exposure(
2194 &mut self,
2195 order: &OrderAny,
2196 market_price: Option<Price>,
2197 ) -> Result<Option<(Price, Quantity, bool)>, ()> {
2198 let Some(original) = self.check.original(order).filter(|original| {
2199 original.is_open()
2200 || (matches!(self.account, AccountAny::Wallet(_)) && original.is_inflight())
2201 }) else {
2202 return Ok(None);
2203 };
2204
2205 let original_price = match self.order_price(original, market_price) {
2206 Ok(Some(price)) => price,
2207 Ok(None) => {
2208 self.engine
2209 .deny_no_market_price(self.instrument.id(), order, self.check);
2210 return Err(());
2211 }
2212 Err(()) => return Err(()),
2213 };
2214
2215 let quantity = self.engine.order_risk_quantity(
2216 self.check,
2217 self.instrument,
2218 original,
2219 original.leaves_qty(),
2220 original_price,
2221 )?;
2222 let is_reducing = !matches!(self.account, AccountAny::Wallet(_))
2223 && ((original.is_reduce_only()
2224 && (matches!(self.account, AccountAny::Margin(_)) || original.is_sell()))
2225 || (original.is_sell()
2226 && self.cum_original_sell_qty_raw + quantity.raw()
2227 <= self.available_long_qty_raw)
2228 || (original.is_buy()
2229 && self.cum_original_buy_qty_raw + quantity.raw()
2230 <= self.available_short_qty_raw));
2231
2232 if original.is_sell() {
2233 self.cum_original_sell_qty_raw += quantity.raw();
2234 } else {
2235 self.cum_original_buy_qty_raw += quantity.raw();
2236 }
2237
2238 Ok(Some((original_price, quantity, is_reducing)))
2239 }
2240
2241 fn order_price(
2242 &mut self,
2243 order: &OrderAny,
2244 market_price: Option<Price>,
2245 ) -> Result<Option<Price>, ()> {
2246 match order {
2247 OrderAny::MarketToLimit(_) if order.price().is_some() => Ok(order.price()),
2248 OrderAny::Market(_) | OrderAny::MarketToLimit(_) => {
2249 let Some(price) = market_price else {
2250 let is_reducing = !matches!(self.account, AccountAny::Wallet(_))
2251 && (order.is_reduce_only()
2252 || (order.is_sell()
2253 && (self.cum_sell_qty_raw + order.quantity().raw())
2254 <= self.available_long_qty_raw));
2255
2256 if !order.is_quote_quantity()
2257 && order.is_sell()
2258 && !is_reducing
2259 && let Some(unleveraged) = cash_or_wallet_account(&self.account)
2260 && unleveraged.base_currency().is_none()
2261 && let Some(base_currency) = self.instrument.base_currency()
2262 {
2263 let base_free = self.balance_free(order, base_currency)?;
2264 self.engine.check_cash_sell_balance(
2265 self.check,
2266 unleveraged,
2267 self.allow_borrowing,
2268 order,
2269 order.quantity(),
2270 base_free,
2271 &mut self.cum_notional_sell,
2272 )?;
2273 }
2274
2275 self.engine
2276 .deny_no_market_price(self.instrument.id(), order, self.check);
2277 return Err(());
2278 };
2279
2280 Ok(Some(price))
2281 }
2282 OrderAny::StopMarket(_) | OrderAny::MarketIfTouched(_) => Ok(order.trigger_price()),
2283 OrderAny::TrailingStopMarket(_) | OrderAny::TrailingStopLimit(_) => {
2284 self.trailing_order_price(order)
2285 }
2286 _ => Ok(order.price()),
2287 }
2288 }
2289
2290 fn trailing_order_price(&self, order: &OrderAny) -> Result<Option<Price>, ()> {
2291 if let Some(price) = order.trigger_price() {
2292 return Ok(order.price().or(Some(price)));
2293 }
2294
2295 let Some(offset_type) = order.trailing_offset_type() else {
2297 self.check.reject(
2298 self.engine,
2299 order,
2300 &OrderDeniedReason::MissingTrailingOffsetType.to_string(),
2301 );
2302 return Err(()); };
2304
2305 if !matches!(
2306 offset_type,
2307 TrailingOffsetType::Price | TrailingOffsetType::BasisPoints | TrailingOffsetType::Ticks
2308 ) {
2309 self.check.reject(
2310 self.engine,
2311 order,
2312 &OrderDeniedReason::UnsupportedTrailingOffsetType { offset_type }.to_string(),
2313 );
2314 return Err(());
2315 }
2316
2317 let Some(trigger_type) = order.trigger_type() else {
2318 self.check.reject(
2319 self.engine,
2320 order,
2321 &OrderDeniedReason::MissingTriggerType.to_string(),
2322 );
2323 return Err(()); };
2325
2326 let Some(trailing_offset) = order.trailing_offset() else {
2327 self.check.reject(
2328 self.engine,
2329 order,
2330 &OrderDeniedReason::MissingTrailingOffset.to_string(),
2331 );
2332 return Err(()); };
2334
2335 if let Some(price) = order.price() {
2336 return Ok(Some(price));
2337 }
2338
2339 self.calculate_trailing_price(order, offset_type, trigger_type, trailing_offset)
2341 .map_err(|detail| {
2342 self.check.reject(
2343 self.engine,
2344 order,
2345 &OrderDeniedReason::TrailingStopCalculationFailed { detail }.to_string(),
2346 );
2347 })
2348 }
2349
2350 fn calculate_trailing_price(
2351 &self,
2352 order: &OrderAny,
2353 offset_type: TrailingOffsetType,
2354 trigger_type: TriggerType,
2355 trailing_offset: Decimal,
2356 ) -> Result<Option<Price>, String> {
2357 let cache = self.engine.cache.borrow();
2358 if trigger_type != TriggerType::BidAsk
2359 && let Some(trade) = cache.trade(&self.instrument.id())
2360 {
2361 return trailing_stop_calculate_with_last(
2362 self.instrument.price_increment(),
2363 offset_type,
2364 order.order_side(),
2365 trailing_offset,
2366 trade.price,
2367 )
2368 .map(Some)
2369 .map_err(|e| e.to_string());
2370 }
2371
2372 if matches!(
2373 trigger_type,
2374 TriggerType::BidAsk | TriggerType::LastOrBidAsk
2375 ) && let Some(quote) = cache.quote(&self.instrument.id())
2376 {
2377 return trailing_stop_calculate_with_bid_ask(
2378 self.instrument.price_increment(),
2379 offset_type,
2380 order.order_side(),
2381 trailing_offset,
2382 quote.bid_price,
2383 quote.ask_price,
2384 )
2385 .map(Some)
2386 .map_err(|e| e.to_string());
2387 }
2388
2389 if trigger_type == TriggerType::BidAsk {
2390 log::warn!(
2391 "Cannot check {} order risk: no trigger price set and no bid/ask quotes available for {}",
2392 order.order_type(),
2393 self.instrument.id()
2394 );
2395 } else {
2396 log::warn!(
2397 "Cannot check {} order risk: no trigger price set and no market data available for {}",
2398 order.order_type(),
2399 self.instrument.id()
2400 );
2401 }
2402
2403 Ok(None)
2404 }
2405
2406 fn check_order_limits(
2407 &self,
2408 order: &OrderAny,
2409 effective_quantity: Quantity,
2410 last_px: Price,
2411 ) -> bool {
2412 if !order.is_quote_quantity() && !self.full_position_exit {
2418 if let Some(max_quantity) = self.instrument.max_quantity()
2419 && effective_quantity > max_quantity
2420 {
2421 self.check.reject(
2422 self.engine,
2423 order,
2424 &OrderDeniedReason::QuantityExceedsMaximum {
2425 effective_quantity,
2426 max_quantity,
2427 }
2428 .to_string(),
2429 );
2430
2431 return false; }
2433
2434 if let Some(min_quantity) = self.instrument.min_quantity()
2435 && effective_quantity < min_quantity
2436 {
2437 self.check.reject(
2438 self.engine,
2439 order,
2440 &OrderDeniedReason::QuantityBelowMinimum {
2441 effective_quantity,
2442 min_quantity,
2443 }
2444 .to_string(),
2445 );
2446
2447 return false; }
2449 }
2450
2451 let notional = match self.instrument.try_calculate_notional_value(
2453 effective_quantity,
2454 last_px,
2455 Some(true),
2456 ) {
2457 Ok(notional) => notional.abs(),
2458 Err(e) => {
2459 self.check.reject(
2460 self.engine,
2461 order,
2462 &OrderDeniedReason::NotionalCalculationFailed {
2463 detail: e.to_string(),
2464 }
2465 .to_string(),
2466 );
2467
2468 return false;
2469 }
2470 };
2471
2472 if self.engine.config.debug {
2473 log::debug!("Notional: {notional:?}");
2474 }
2475
2476 if !self.full_position_exit
2478 && let Some(max_notional_value) = self.max_notional
2479 && notional > max_notional_value
2480 {
2481 self.check.reject(
2482 self.engine,
2483 order,
2484 &OrderDeniedReason::NotionalExceedsMaxPerOrder {
2485 max_notional: max_notional_value,
2486 notional,
2487 }
2488 .to_string(),
2489 );
2490
2491 return false; }
2493
2494 if !order.is_reduce_only()
2497 && !self.full_position_exit
2498 && let Some(min_notional) = self.instrument.min_notional()
2499 && notional.currency == min_notional.currency
2500 && notional < min_notional
2501 {
2502 self.check.reject(
2503 self.engine,
2504 order,
2505 &OrderDeniedReason::NotionalBelowMinimum {
2506 min_notional,
2507 notional,
2508 }
2509 .to_string(),
2510 );
2511
2512 return false; }
2514
2515 if !self.full_position_exit
2517 && let Some(max_notional) = self.instrument.max_notional()
2518 && notional.currency == max_notional.currency
2519 && notional > max_notional
2520 {
2521 self.check.reject(
2522 self.engine,
2523 order,
2524 &OrderDeniedReason::NotionalExceedsMaximum {
2525 max_notional,
2526 notional,
2527 }
2528 .to_string(),
2529 );
2530
2531 return false; }
2533
2534 true
2535 }
2536
2537 fn check_margin(
2538 &mut self,
2539 order: &OrderAny,
2540 quantity: Quantity,
2541 price: Price,
2542 original: Option<(Price, Quantity, bool)>,
2543 reserved_quantity: QuantityRaw,
2544 ) -> bool {
2545 let Ok(required) = self.initial_margin(order, quantity, price) else {
2546 return false;
2547 };
2548
2549 if self.engine.config.debug {
2550 log::debug!("Initial margin required: {required}");
2551 }
2552
2553 if self.reserve_position(order, quantity, reserved_quantity) {
2554 if self.engine.config.debug {
2555 log::debug!("Position-reducing order skips margin check");
2556 }
2557
2558 return true;
2559 }
2560
2561 let Ok(required) = self.margin_increase(order, required, original) else {
2562 return false;
2563 };
2564
2565 self.check_margin_balance(order, required)
2566 }
2567
2568 fn margin_increase(
2569 &mut self,
2570 order: &OrderAny,
2571 required: Money,
2572 original: Option<(Price, Quantity, bool)>,
2573 ) -> Result<Money, ()> {
2574 let Some((price, quantity, was_reducing)) = original else {
2575 return Ok(required);
2576 };
2577
2578 let previous = if was_reducing {
2579 Money::zero(required.currency)
2580 } else {
2581 self.initial_margin(order, quantity, price)?
2582 };
2583
2584 self.engine
2585 .check_risk_increase(self.check, order, required, previous)
2586 .ok_or(())
2587 }
2588
2589 fn initial_margin(
2590 &mut self,
2591 order: &OrderAny,
2592 quantity: Quantity,
2593 price: Price,
2594 ) -> Result<Money, ()> {
2595 let AccountAny::Margin(margin) = &mut self.account else {
2596 unreachable!()
2597 };
2598
2599 margin
2600 .calculate_initial_margin(self.instrument, quantity, price, None)
2601 .map_err(|e| {
2602 self.check.reject(
2603 self.engine,
2604 order,
2605 &OrderDeniedReason::InitialMarginCalculationFailed {
2606 detail: e.to_string(),
2607 }
2608 .to_string(),
2609 );
2610 })
2611 }
2612
2613 fn check_margin_balance(&mut self, order: &OrderAny, required: Money) -> bool {
2614 if matches!(self.check, RiskCheck::Modify(_)) && required.is_zero() {
2615 return true;
2616 }
2617
2618 let Ok(required) = self.account_currency_amount(order, required) else {
2619 return false;
2620 };
2621
2622 let Ok(free) = self.balance_free(order, required.currency) else {
2624 return false;
2625 };
2626
2627 if required > free {
2628 self.check.reject(
2629 self.engine,
2630 order,
2631 &OrderDeniedReason::InitialMarginExceedsFreeBalance {
2632 free_balance: free,
2633 initial_margin: required,
2634 }
2635 .to_string(),
2636 );
2637
2638 return false;
2639 }
2640
2641 let total = match self.cum_margin_required {
2642 Some(total) => total.checked_add(required),
2643 None => Some(required),
2644 };
2645
2646 let Some(total) = total else {
2647 self.check.reject(
2648 self.engine,
2649 order,
2650 &OrderDeniedReason::CumulativeInitialMarginCalculationFailed {
2651 detail: "total exceeds Money bounds".to_string(),
2652 }
2653 .to_string(),
2654 );
2655
2656 return false;
2657 };
2658
2659 self.cum_margin_required = Some(total);
2660
2661 if self.engine.config.debug {
2662 log::debug!("Cumulative margin required: {:?}", self.cum_margin_required);
2663 }
2664
2665 if total > free {
2666 self.check.reject(
2667 self.engine,
2668 order,
2669 &OrderDeniedReason::CumulativeInitialMarginExceedsFreeBalance {
2670 free_balance: free,
2671 cumulative_initial_margin: total,
2672 }
2673 .to_string(),
2674 );
2675
2676 return false;
2677 }
2678
2679 self.reserve_balance(order, required);
2680 true
2681 }
2682
2683 fn check_balance(
2684 &mut self,
2685 order: &OrderAny,
2686 quantity: Quantity,
2687 price: Price,
2688 original: Option<(Price, Quantity, bool)>,
2689 reserved_quantity: QuantityRaw,
2690 ) -> bool {
2691 let Ok((notional, impact)) = self.balance_impact(order, quantity, price) else {
2692 return false;
2693 };
2694
2695 if self.engine.config.debug {
2696 log::debug!("Balance impact: {impact}");
2697 }
2698
2699 if self.reserve_position(order, quantity, reserved_quantity) {
2700 if self.engine.config.debug {
2701 log::debug!("Position-reducing order skips balance check");
2702 }
2703
2704 return true;
2705 }
2706
2707 let Ok(impact) = self.balance_increase(order, impact, original) else {
2708 return false;
2709 };
2710
2711 let is_debit = order.is_buy() || matches!(self.account, AccountAny::Betting(_));
2712 if matches!(self.check, RiskCheck::Modify(_))
2713 && impact.is_zero()
2714 && (is_debit || self.account.base_currency().is_some())
2715 {
2716 return true;
2717 }
2718
2719 let Ok(impact) = self.account_currency_amount(order, impact) else {
2720 return false;
2721 };
2722
2723 let Ok(free) = self.balance_free(order, impact.currency) else {
2724 return false;
2725 };
2726
2727 if !self.allow_borrowing && free.as_decimal() + impact.as_decimal() < Decimal::ZERO {
2728 self.check.reject(
2729 self.engine,
2730 order,
2731 &OrderDeniedReason::NotionalExceedsFreeBalance {
2732 free_balance: free,
2733 notional,
2734 }
2735 .to_string(),
2736 );
2737
2738 return false;
2739 }
2740
2741 if is_debit {
2742 return self.check_cumulative_balance(order, -impact);
2743 }
2744
2745 if self.instrument.base_currency().is_some() && self.account.base_currency().is_some() {
2748 return self.check_cumulative_balance(order, impact);
2749 }
2750
2751 self.check_asset_balance(order, quantity, original)
2752 }
2753
2754 fn reserve_position(
2755 &mut self,
2756 order: &OrderAny,
2757 quantity: Quantity,
2758 reserved: QuantityRaw,
2759 ) -> bool {
2760 let (cumulative, available) = match order.order_side() {
2761 OrderSide::Buy => (&mut self.cum_buy_qty_raw, self.available_short_qty_raw),
2762 OrderSide::Sell => (&mut self.cum_sell_qty_raw, self.available_long_qty_raw),
2763 };
2764
2765 let reducing = self.full_position_exit
2766 || (order.is_reduce_only()
2767 && (matches!(self.account, AccountAny::Margin(_)) || order.is_sell()))
2768 || *cumulative + quantity.raw() <= available;
2769 *cumulative += reserved;
2770 reducing && !matches!(self.account, AccountAny::Wallet(_))
2771 }
2772
2773 fn balance_impact(
2774 &mut self,
2775 order: &OrderAny,
2776 quantity: Quantity,
2777 price: Price,
2778 ) -> Result<(Money, Money), ()> {
2779 let notional = self
2780 .instrument
2781 .try_calculate_notional_value(quantity, price, None)
2782 .map_err(|e| {
2783 self.check.reject(
2784 self.engine,
2785 order,
2786 &OrderDeniedReason::NotionalCalculationFailed {
2787 detail: e.to_string(),
2788 }
2789 .to_string(),
2790 );
2791 })?;
2792
2793 let impact = if let AccountAny::Betting(betting) = &mut self.account {
2794 -betting
2795 .calculate_balance_locked(
2796 self.instrument,
2797 order.order_side(),
2798 quantity,
2799 price,
2800 None,
2801 )
2802 .map_err(|e| {
2803 self.check.reject(
2804 self.engine,
2805 order,
2806 &OrderDeniedReason::BettingBalanceLockedCalculationFailed {
2807 detail: e.to_string(),
2808 }
2809 .to_string(),
2810 );
2811 })?
2812 } else if let Some(funding) =
2813 self.wallet_buy_funding(order, quantity, price, notional.currency)
2814 {
2815 let funding = funding.map_err(|detail| {
2816 self.check.reject(
2817 self.engine,
2818 order,
2819 &OrderDeniedReason::NotionalCalculationFailed { detail }.to_string(),
2820 );
2821 })?;
2822
2823 return Ok((funding, -funding));
2825 } else {
2826 match order.order_side() {
2827 OrderSide::Buy => -notional,
2828 OrderSide::Sell => notional,
2829 }
2830 };
2831
2832 Ok((notional, impact))
2833 }
2834
2835 fn wallet_buy_funding(
2840 &self,
2841 order: &OrderAny,
2842 quantity: Quantity,
2843 price: Price,
2844 notional_currency: Currency,
2845 ) -> Option<Result<Money, String>> {
2846 let AccountAny::Wallet(wallet) = &self.account else {
2847 return None;
2848 };
2849
2850 if !order.is_buy() {
2851 return None;
2852 }
2853
2854 if order.is_quote_quantity() {
2855 let quote_currency = self.instrument.quote_currency();
2856 let currency = wallet
2857 .balance_total(Some(quote_currency))
2858 .map_or(quote_currency, |observed| observed.currency);
2859 return Some(
2860 Money::from_quantity(order.leaves_qty(), currency).map_err(|e| e.to_string()),
2861 );
2862 }
2863
2864 wallet.balance_total(Some(notional_currency))?;
2865 Some(
2866 wallet
2867 .calculate_balance_locked(self.instrument, OrderSide::Buy, quantity, price, None)
2868 .map_err(|e| e.to_string()),
2869 )
2870 }
2871
2872 fn balance_increase(
2873 &mut self,
2874 order: &OrderAny,
2875 impact: Money,
2876 original: Option<(Price, Quantity, bool)>,
2877 ) -> Result<Money, ()> {
2878 let Some((price, quantity, was_reducing)) = original else {
2879 return Ok(impact);
2880 };
2881
2882 let previous = if was_reducing {
2883 Ok(Money::zero(impact.currency))
2884 } else if let AccountAny::Betting(betting) = &mut self.account {
2885 betting
2886 .calculate_balance_locked(
2887 self.instrument,
2888 order.order_side(),
2889 quantity,
2890 price,
2891 None,
2892 )
2893 .map_err(|e| e.to_string())
2894 } else if let Some(funding) = self.check.original(order).and_then(|original| {
2895 self.wallet_buy_funding(original, quantity, price, impact.currency)
2896 }) {
2897 funding
2898 } else {
2899 self.instrument
2900 .try_calculate_notional_value(quantity, price, None)
2901 .map_err(|e| e.to_string())
2902 }
2903 .map_err(|detail| {
2904 self.check.reject(
2905 self.engine,
2906 order,
2907 &OrderDeniedReason::NotionalCalculationFailed { detail }.to_string(),
2908 );
2909 })?;
2910
2911 let is_debit = order.is_buy() || matches!(self.account, AccountAny::Betting(_));
2912 let current = if is_debit { -impact } else { impact };
2913 let increase = self
2914 .engine
2915 .check_risk_increase(self.check, order, current, previous)
2916 .ok_or(())?;
2917 Ok(if is_debit { -increase } else { increase })
2918 }
2919
2920 fn check_cumulative_balance(&mut self, order: &OrderAny, required: Money) -> bool {
2921 let cumulative = match order.order_side() {
2922 OrderSide::Buy => &mut self.cum_notional_buy,
2923 OrderSide::Sell => &mut self.cum_notional_sell,
2924 };
2925
2926 if !self
2927 .engine
2928 .accumulate_notional(self.check, order, cumulative, required)
2929 {
2930 return false;
2931 }
2932
2933 if self.engine.config.debug {
2934 log::debug!(
2935 "Cumulative balance required for {}: {cumulative:?}",
2936 order.order_side()
2937 );
2938 }
2939
2940 let cumulative = *cumulative;
2941 let Ok(free) = self.balance_free(order, required.currency) else {
2942 return false;
2943 };
2944
2945 if !self.allow_borrowing
2946 && let Some(total) = cumulative
2947 && total > free
2948 {
2949 self.check.reject(
2950 self.engine,
2951 order,
2952 &OrderDeniedReason::CumulativeNotionalExceedsFreeBalance {
2953 free_balance: free,
2954 cumulative_notional: total,
2955 }
2956 .to_string(),
2957 );
2958
2959 return false;
2960 }
2961
2962 self.reserve_balance(order, required);
2963 true
2964 }
2965
2966 fn account_currency_amount(&self, order: &OrderAny, amount: Money) -> Result<Money, ()> {
2967 let Some(currency) = self.account.base_currency() else {
2968 return Ok(amount);
2969 };
2970
2971 if amount.currency == currency {
2972 return Ok(amount);
2973 }
2974
2975 if amount.is_zero() {
2976 return Ok(Money::zero(currency));
2977 }
2978
2979 let price_type = match order.order_side() {
2981 OrderSide::Buy => PriceType::Bid,
2982 OrderSide::Sell => PriceType::Ask,
2983 };
2984
2985 let xrate = self.engine.cache.borrow().try_get_xrate(
2986 self.instrument.id().venue,
2987 amount.currency,
2988 currency,
2989 price_type,
2990 );
2991 xrate
2992 .map_err(|e| e.to_string())
2993 .and_then(|xrate| {
2994 let xrate = xrate.ok_or_else(|| {
2995 format!("No exchange rate from {} to {currency}", amount.currency)
2996 })?;
2997
2998 let value = amount.as_decimal().checked_mul(xrate).ok_or_else(|| {
2999 "Account currency conversion exceeds Decimal bounds".to_string()
3000 })?;
3001
3002 Money::from_decimal(value, currency).map_err(|e| e.to_string())
3003 })
3004 .map_err(|e| {
3005 self.check.reject(
3006 self.engine,
3007 order,
3008 &OrderDeniedReason::ValidationFailed {
3009 detail: format!("Account currency conversion failed: {e}"),
3010 }
3011 .to_string(),
3012 );
3013 })
3014 }
3015
3016 fn check_asset_balance(
3017 &mut self,
3018 order: &OrderAny,
3019 quantity: Quantity,
3020 original: Option<(Price, Quantity, bool)>,
3021 ) -> bool {
3022 let Some(base_currency) = self.instrument.base_currency() else {
3023 return true;
3024 };
3025
3026 let Ok(base_free) = self.balance_free(order, base_currency) else {
3027 return false;
3028 };
3029
3030 let Some(account) = cash_or_wallet_account(&self.account) else {
3031 unreachable!()
3032 };
3033
3034 let quantity = match original {
3035 Some((_, previous, false)) => quantity.saturating_sub(previous),
3036 _ => quantity,
3037 };
3038
3039 let Ok(cash_value) = self.engine.check_cash_sell_balance(
3040 self.check,
3041 account,
3042 self.allow_borrowing,
3043 order,
3044 quantity,
3045 base_free,
3046 &mut self.cum_notional_sell,
3047 ) else {
3048 return false;
3049 };
3050
3051 self.reserve_balance(order, cash_value);
3052 true
3053 }
3054}
3055
3056fn cash_or_wallet_account(account: &AccountAny) -> Option<&dyn Account> {
3059 match account {
3060 AccountAny::Cash(cash) => Some(cash),
3061 AccountAny::Wallet(wallet) => Some(wallet),
3062 AccountAny::Margin(_) | AccountAny::Betting(_) => None,
3063 }
3064}
3065
3066#[cfg(test)]
3067mod tests;