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