Skip to main content

nautilus_risk/engine/
mod.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Risk management engine implementation.
17
18pub 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/// Central risk management engine that validates and controls trading operations.
68///
69/// The `RiskEngine` provides pre-trade risk checks including order validation,
70/// balance verification, position sizing limits, and trading state management. It acts as
71/// a gateway between strategy orders and execution, ensuring all trades comply with
72/// defined risk parameters and regulatory constraints.
73#[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    /// Creates a new [`RiskEngine`] instance.
103    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    /// Registers all message bus handlers for the risk engine.
128    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        // Queued endpoint for deferred command execution (re-entrancy safe).
142        // When a strategy calls `submit_order()` from within an event handler
143        // (e.g., `on_order_filled`), the command is routed through this endpoint.
144        // In live mode the `TradingCommandSender` queues the command for the next
145        // event-loop iteration, preventing a synchronous `deny_order()` from
146        // dispatching an `OrderDenied` back into a strategy that still holds a
147        // mutable borrow - which would otherwise panic on `RefCell` re-entrancy.
148        // If no sender is installed, the queued endpoint falls back to direct dispatch.
149        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                // Risk-generated events can publish while `execute` still owns the engine,
179                // and processing is observational, so skipping reentrant events is safe.
180                // TODO: Revisit this if order-event processing gains stateful behavior
181                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    /// Executes a trading command through the risk management pipeline.
388    // Required by message bus dispatch
389    pub fn execute(&mut self, command: TradingCommand) {
390        self.command_count += 1;
391
392        // This will extend to other commands such as `RiskCommand`
393        self.handle_command(command);
394    }
395
396    /// Processes an order event for risk monitoring and state updates.
397    #[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        // This will extend to other events such as `RiskEvent`
405        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    /// Sets the trading state for risk control enforcement.
415    ///
416    /// [`TradingState::Halted`] denies all new submit and modify commands.
417    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    /// Sets the maximum notional value per order for the specified instrument.
438    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    /// Starts the risk engine.
446    pub fn start(&mut self) {
447        log::info!("Started");
448    }
449
450    /// Stops the risk engine.
451    pub fn stop(&mut self) {
452        log::info!("Stopped");
453    }
454
455    /// Resets the risk engine to its initial state.
456    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    /// Disposes of the risk engine, releasing resources.
468    pub fn dispose(&mut self) {
469        log::info!("Disposed");
470    }
471
472    /// Returns a reference to the clock.
473    #[must_use]
474    pub fn clock(&self) -> &Rc<RefCell<dyn Clock>> {
475        &self.clock
476    }
477
478    /// Returns a reference to the cache.
479    #[must_use]
480    pub fn cache(&self) -> &Rc<RefCell<Cache>> {
481        &self.cache
482    }
483
484    /// Returns a mutable reference to the portfolio.
485    pub fn portfolio_mut(&mut self) -> &mut Portfolio {
486        &mut self.portfolio
487    }
488
489    /// Returns a reference to the configuration.
490    #[must_use]
491    pub const fn config(&self) -> &RiskEngineConfig {
492        &self.config
493    }
494
495    /// Returns the total count of trading commands received by the engine.
496    #[must_use]
497    pub const fn command_count(&self) -> u64 {
498        self.command_count
499    }
500
501    /// Returns the total count of order events received by the engine.
502    #[must_use]
503    pub const fn event_count(&self) -> u64 {
504        self.event_count
505    }
506
507    /// Returns the current trading state.
508    #[must_use]
509    pub const fn trading_state(&self) -> TradingState {
510        self.trading_state
511    }
512
513    /// Returns a reference to the max notional per order settings.
514    #[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; // Denied
613                }
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; // Denied
637        };
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; // Denied
642        }
643
644        if !self.check_orders_risk(&instrument, &[order], full_position_exit, RiskCheck::Submit) {
645            return; // Denied
646        }
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; // Denied
774        }
775
776        // Per-order checks use each order's own instrument; the cumulative
777        // risk check uses the representative. See docs/concepts/orders.md
778        // (Order lists -> Caveats for mixed-instrument lists).
779        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; // Denied
793            };
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; // Denied
807            };
808
809            if !self.check_order(instrument, order, false) {
810                return; // Denied
811            }
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; // Denied
825        };
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; // Denied
836        }
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        // Check Price
1000        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        // Check Trigger
1007        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        // Check Quantity
1019        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            // Project values without applying a venue event or changing the cached order
1064            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; // Denied
1115        }
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; // Denied
1121            };
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; // Denied
1132            }
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; // Denied
1144            }
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; // Denied
1157            }
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; // Denied
1179        }
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        // Get account for risk checks: use explicit account_id if provided, otherwise venue lookup
1248        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        // A reduction cannot fund another amendment before the venue acknowledges it
1422        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        // Check precision
1639        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        // Base-quantity bounds do not apply to quote-denominated or validated whole-position
1648        // exits. Applicable quote-quantity notional limits are checked during account risk.
1649        if is_quote_quantity || full_position_exit {
1650            return None;
1651        }
1652
1653        // Check maximum quantity
1654        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        // Check minimum quantity
1664        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        // Scope the cache borrow to avoid RefCell conflict when sending to ExecEngine
1718        {
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        // We intend to extend the risk engine to be able to handle additional events.
1856        // For now we just log.
1857        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        // Caps apply to total size, but only unfilled exposure needs funds on amendment
1960        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        // Pending reductions cannot release closing capacity for other amendments
1981        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        // Validate trailing offset type is supported
2107        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(()); // Denied
2114        };
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(()); // Denied
2135        };
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(()); // Denied
2144        };
2145
2146        if let Some(price) = order.price() {
2147            return Ok(Some(price));
2148        }
2149
2150        // Release the cache borrow before publishing a rejection
2151        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        // Base-quantity bounds (`min_quantity`/`max_quantity`) do not apply to
2224        // quote-denominated orders: the client-side conversion uses an estimated
2225        // price and may differ from the venue fill, and some venues enforce
2226        // distinct per-order-type minimums. The venue is authoritative for
2227        // quote-denominated sizing; rely on `min_notional`/`max_notional` below.
2228        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; // Denied
2243            }
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; // Denied
2259            }
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        // Check MAX notional per order limit
2287        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; // Denied
2302        }
2303
2304        // Whole-position and reduce-only orders may close residual positions below the
2305        // venue minimum
2306        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; // Denied
2323        }
2324
2325        // Check MAX notional instrument limit
2326        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; // Denied
2342        }
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        // Inverse instruments can require collateral in the base currency
2433        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        // Match the portfolio's order-funding conversion convention
2733        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
2800// Returns cash and wallet accounts for sell-balance checks; margin and betting accounts
2801// follow their own sell paths.
2802fn 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;