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::{BatchModifyOrders, ModifyOrder, SubmitOrder, SubmitOrderList, TradingCommand},
31        system::trading::TradingStateChanged,
32    },
33    msgbus,
34    msgbus::{MessagingSwitchboard, TypedHandler, TypedIntoHandler, get_message_bus},
35    runner::try_get_trading_cmd_sender,
36    throttler::{RateLimit, Throttler},
37};
38use nautilus_core::{UUID4, WeakCell};
39use nautilus_execution::trailing::{
40    trailing_stop_calculate_with_bid_ask, trailing_stop_calculate_with_last,
41};
42use nautilus_model::{
43    accounts::{Account, AccountAny},
44    enums::{
45        OrderSide, OrderStatus, PositionSide, TimeInForce, TradingState, TrailingOffsetType,
46        TriggerType,
47    },
48    events::{OrderDenied, OrderDeniedReason, OrderEventAny, OrderModifyRejected, PositionEvent},
49    identifiers::{AccountId, InstrumentId},
50    instruments::{Instrument, InstrumentAny},
51    orders::{Order, OrderAny},
52    types::{Currency, Money, Price, Quantity, money::MoneyRaw, quantity::QuantityRaw},
53};
54use nautilus_portfolio::Portfolio;
55use rust_decimal::Decimal;
56use ustr::Ustr;
57
58fn format_rate_limit(rate_limit: &RateLimit) -> String {
59    let interval_ns = rate_limit.interval_ns();
60    let limit = rate_limit.limit();
61    let total_secs = interval_ns / 1_000_000_000;
62    let remainder_ns = interval_ns % 1_000_000_000;
63    let hours = total_secs / 3600;
64    let minutes = (total_secs % 3600) / 60;
65    let seconds = total_secs % 60;
66
67    if remainder_ns == 0 {
68        format!("{limit}/{hours:02}:{minutes:02}:{seconds:02}")
69    } else {
70        let micros = remainder_ns / 1_000;
71        format!("{limit}/{hours:02}:{minutes:02}:{seconds:02}.{micros:06}")
72    }
73}
74
75type SubmitCommandFn = Box<dyn Fn(TradingCommand)>;
76type ModifyOrderFn = Box<dyn Fn(ModifyOrder)>;
77
78/// Central risk management engine that validates and controls trading operations.
79///
80/// The `RiskEngine` provides pre-trade risk checks including order validation,
81/// balance verification, position sizing limits, and trading state management. It acts as
82/// a gateway between strategy orders and execution, ensuring all trades comply with
83/// defined risk parameters and regulatory constraints.
84#[allow(dead_code)]
85pub struct RiskEngine {
86    clock: Rc<RefCell<dyn Clock>>,
87    cache: Rc<RefCell<Cache>>,
88    portfolio: Portfolio,
89    pub throttled_submit: Throttler<TradingCommand, SubmitCommandFn>,
90    pub throttled_modify_order: Throttler<ModifyOrder, ModifyOrderFn>,
91    max_notional_per_order: AHashMap<InstrumentId, Decimal>,
92    trading_state: TradingState,
93    config: RiskEngineConfig,
94    command_count: u64,
95    event_count: u64,
96}
97
98impl Debug for RiskEngine {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.debug_struct(stringify!(RiskEngine)).finish()
101    }
102}
103
104impl RiskEngine {
105    /// Creates a new [`RiskEngine`] instance.
106    pub fn new(
107        config: RiskEngineConfig,
108        portfolio: Portfolio,
109        clock: Rc<RefCell<dyn Clock>>,
110        cache: Rc<RefCell<Cache>>,
111    ) -> Self {
112        let throttled_submit = Self::create_submit_throttler(&config, clock.clone(), cache.clone());
113
114        let throttled_modify_order =
115            Self::create_modify_order_throttler(&config, clock.clone(), cache.clone());
116
117        Self {
118            clock,
119            cache,
120            portfolio,
121            throttled_submit,
122            throttled_modify_order,
123            max_notional_per_order: config.max_notional_per_order.clone(),
124            trading_state: TradingState::Active,
125            config,
126            command_count: 0,
127            event_count: 0,
128        }
129    }
130
131    /// Registers all message bus handlers for the risk engine.
132    pub fn register_msgbus_handlers(engine: &Rc<RefCell<Self>>) {
133        let weak = WeakCell::from(Rc::downgrade(engine));
134
135        let weak_execute = weak.clone();
136        msgbus::register_trading_command_endpoint(
137            MessagingSwitchboard::risk_engine_execute(),
138            TypedIntoHandler::from(move |cmd: TradingCommand| {
139                if let Some(rc) = weak_execute.upgrade() {
140                    rc.borrow_mut().execute(cmd);
141                }
142            }),
143        );
144
145        // Queued endpoint for deferred command execution (re-entrancy safe).
146        // When a strategy calls `submit_order()` from within an event handler
147        // (e.g., `on_order_filled`), the command is routed through this endpoint.
148        // In live mode the `TradingCommandSender` queues the command for the next
149        // event-loop iteration, preventing a synchronous `deny_order()` from
150        // dispatching an `OrderDenied` back into a strategy that still holds a
151        // mutable borrow — which would otherwise panic on `RefCell` re-entrancy.
152        // In backtest/test mode (no sender), falls back to the direct endpoint.
153        msgbus::register_trading_command_endpoint(
154            MessagingSwitchboard::risk_engine_queue_execute(),
155            TypedIntoHandler::from(move |cmd: TradingCommand| {
156                if let Some(sender) = try_get_trading_cmd_sender() {
157                    sender.execute(cmd);
158                } else {
159                    let endpoint = MessagingSwitchboard::risk_engine_execute();
160                    msgbus::send_trading_command(endpoint, cmd);
161                }
162            }),
163        );
164
165        let weak_process = weak.clone();
166        msgbus::register_order_event_endpoint(
167            MessagingSwitchboard::risk_engine_process(),
168            TypedIntoHandler::from(move |event: OrderEventAny| {
169                if let Some(rc) = weak_process.upgrade() {
170                    rc.borrow_mut().process(event);
171                }
172            }),
173        );
174
175        let weak_order_events = weak.clone();
176        msgbus::subscribe_order_events(
177            "events.order.*".into(),
178            TypedHandler::from(move |event: &OrderEventAny| {
179                if let Some(rc) = weak_order_events.upgrade() {
180                    rc.borrow_mut().process(event.clone());
181                }
182            }),
183            Some(10),
184        );
185
186        let weak_position_events = weak;
187        msgbus::subscribe_position_events(
188            "events.position.*".into(),
189            TypedHandler::from(move |event: &PositionEvent| {
190                if let Some(rc) = weak_position_events.upgrade() {
191                    rc.borrow_mut().process_position_event(event);
192                }
193            }),
194            Some(10),
195        );
196    }
197
198    fn create_submit_throttler(
199        config: &RiskEngineConfig,
200        clock: Rc<RefCell<dyn Clock>>,
201        cache: Rc<RefCell<Cache>>,
202    ) -> Throttler<TradingCommand, SubmitCommandFn> {
203        let success_handler = {
204            Box::new(move |command: TradingCommand| {
205                let endpoint = MessagingSwitchboard::exec_engine_queue_execute();
206                msgbus::send_trading_command(endpoint, command);
207            }) as Box<dyn Fn(TradingCommand)>
208        };
209
210        let failure_handler = {
211            let cache = cache;
212            let clock = clock.clone();
213            Box::new(move |command: TradingCommand| {
214                let reason = OrderDeniedReason::RateLimitExceeded.to_string();
215
216                match command {
217                    TradingCommand::SubmitOrder(submit_order) => {
218                        log::warn!(
219                            "SubmitOrder for {} DENIED: {reason}",
220                            submit_order.client_order_id,
221                        );
222
223                        Self::handle_submit_order_cache(&cache, &submit_order);
224
225                        let denied = Self::create_order_denied(&submit_order, &reason, &clock);
226
227                        let endpoint = MessagingSwitchboard::exec_engine_process();
228                        msgbus::send_order_event(endpoint, denied);
229                    }
230                    TradingCommand::SubmitOrderList(submit_order_list) => {
231                        log::warn!(
232                            "SubmitOrderList for {} DENIED: {reason}",
233                            submit_order_list.order_list.id,
234                        );
235
236                        let orders: Vec<OrderAny> = cache.borrow().orders_for_ids(
237                            &submit_order_list.order_list.client_order_ids,
238                            &submit_order_list,
239                        );
240
241                        let timestamp = clock.borrow().timestamp_ns();
242
243                        for order in &orders {
244                            if order.status() == OrderStatus::Initialized {
245                                let denied = OrderEventAny::Denied(OrderDenied::new(
246                                    order.trader_id(),
247                                    order.strategy_id(),
248                                    order.instrument_id(),
249                                    order.client_order_id(),
250                                    reason.as_str().into(),
251                                    UUID4::new(),
252                                    timestamp,
253                                    timestamp,
254                                ));
255                                let endpoint = MessagingSwitchboard::exec_engine_process();
256                                msgbus::send_order_event(endpoint, denied);
257                            }
258                        }
259                    }
260                    _ => {
261                        log::error!("Unexpected command type in submit throttler: {command}");
262                    }
263                }
264            }) as Box<dyn Fn(TradingCommand)>
265        };
266
267        Throttler::new(
268            config.max_order_submit,
269            clock,
270            "ORDER_SUBMIT_THROTTLER",
271            success_handler,
272            Some(failure_handler),
273            Ustr::from(UUID4::new().as_str()),
274        )
275    }
276
277    fn create_modify_order_throttler(
278        config: &RiskEngineConfig,
279        clock: Rc<RefCell<dyn Clock>>,
280        cache: Rc<RefCell<Cache>>,
281    ) -> Throttler<ModifyOrder, ModifyOrderFn> {
282        let success_handler = {
283            Box::new(move |order: ModifyOrder| {
284                let endpoint = MessagingSwitchboard::exec_engine_queue_execute();
285                msgbus::send_trading_command(endpoint, TradingCommand::ModifyOrder(order));
286            }) as Box<dyn Fn(ModifyOrder)>
287        };
288
289        let failure_handler = {
290            let cache = cache;
291            let clock = clock.clone();
292            Box::new(move |order: ModifyOrder| {
293                let reason = "Exceeded MAX_ORDER_MODIFY_RATE";
294                log::warn!(
295                    "SubmitOrder for {} DENIED: {}",
296                    order.client_order_id,
297                    reason
298                );
299
300                let Some(order) = Self::get_existing_order(&cache, &order) else {
301                    return;
302                };
303
304                let rejected = Self::create_modify_rejected(&order, reason, &clock);
305
306                let endpoint = MessagingSwitchboard::exec_engine_process();
307                msgbus::send_order_event(endpoint, rejected);
308            }) as Box<dyn Fn(ModifyOrder)>
309        };
310
311        Throttler::new(
312            config.max_order_modify,
313            clock,
314            "ORDER_MODIFY_THROTTLER",
315            success_handler,
316            Some(failure_handler),
317            Ustr::from(UUID4::new().as_str()),
318        )
319    }
320
321    fn handle_submit_order_cache(cache: &Rc<RefCell<Cache>>, submit_order: &SubmitOrder) {
322        let cache = cache.borrow();
323        if !cache.order_exists(&submit_order.client_order_id) {
324            log::error!(
325                "Order not found in cache for client_order_id: {}",
326                submit_order.client_order_id
327            );
328        }
329    }
330
331    fn get_existing_order(cache: &Rc<RefCell<Cache>>, order: &ModifyOrder) -> Option<OrderAny> {
332        let cache = cache.borrow();
333        if let Some(order) = cache.order(&order.client_order_id) {
334            Some(order.clone())
335        } else {
336            log::error!(
337                "Order with command.client_order_id: {} not found",
338                order.client_order_id
339            );
340            None
341        }
342    }
343
344    fn create_order_denied(
345        submit_order: &SubmitOrder,
346        reason: &str,
347        clock: &Rc<RefCell<dyn Clock>>,
348    ) -> OrderEventAny {
349        let timestamp = clock.borrow().timestamp_ns();
350        OrderEventAny::Denied(OrderDenied::new(
351            submit_order.trader_id,
352            submit_order.strategy_id,
353            submit_order.instrument_id,
354            submit_order.client_order_id,
355            reason.into(),
356            UUID4::new(),
357            timestamp,
358            timestamp,
359        ))
360    }
361
362    fn create_modify_rejected(
363        order: &OrderAny,
364        reason: &str,
365        clock: &Rc<RefCell<dyn Clock>>,
366    ) -> OrderEventAny {
367        let timestamp = clock.borrow().timestamp_ns();
368        OrderEventAny::ModifyRejected(OrderModifyRejected::new(
369            order.trader_id(),
370            order.strategy_id(),
371            order.instrument_id(),
372            order.client_order_id(),
373            reason.into(),
374            UUID4::new(),
375            timestamp,
376            timestamp,
377            false,
378            order.venue_order_id(),
379            None,
380        ))
381    }
382
383    /// Executes a trading command through the risk management pipeline.
384    // Required by message bus dispatch
385    pub fn execute(&mut self, command: TradingCommand) {
386        self.command_count += 1;
387
388        // This will extend to other commands such as `RiskCommand`
389        self.handle_command(command);
390    }
391
392    /// Processes an order event for risk monitoring and state updates.
393    #[expect(
394        clippy::needless_pass_by_value,
395        reason = "message bus dispatch passes owned order events"
396    )]
397    pub fn process(&mut self, event: OrderEventAny) {
398        self.event_count += 1;
399
400        // This will extend to other events such as `RiskEvent`
401        self.handle_event(&event);
402    }
403
404    fn process_position_event(&mut self, event: &PositionEvent) {
405        self.event_count += 1;
406
407        self.handle_position_event(event);
408    }
409
410    /// Sets the trading state for risk control enforcement.
411    pub fn set_trading_state(&mut self, state: TradingState) {
412        if state == self.trading_state {
413            log::warn!("No change to trading state: already set to {state:?}");
414            return;
415        }
416
417        self.trading_state = state;
418
419        let ts_now = self.clock.borrow().timestamp_ns();
420        let trader_id = get_message_bus().borrow().trader_id;
421
422        let config = self.config_as_map();
423        let event =
424            TradingStateChanged::new(trader_id, state, config, UUID4::new(), ts_now, ts_now);
425
426        msgbus::publish_any("events.risk".into(), &event);
427
428        log::info!("Trading state set to {state:?}");
429    }
430
431    /// Sets the maximum notional value per order for the specified instrument.
432    pub fn set_max_notional_per_order(&mut self, instrument_id: InstrumentId, new_value: Decimal) {
433        self.max_notional_per_order.insert(instrument_id, new_value);
434
435        let new_value_str = new_value.to_string();
436        log::info!("Set MAX_NOTIONAL_PER_ORDER: {instrument_id} {new_value_str}");
437    }
438
439    /// Starts the risk engine.
440    pub fn start(&mut self) {
441        log::info!("Started");
442    }
443
444    /// Stops the risk engine.
445    pub fn stop(&mut self) {
446        log::info!("Stopped");
447    }
448
449    /// Resets the risk engine to its initial state.
450    pub fn reset(&mut self) {
451        self.throttled_submit.reset();
452        self.throttled_modify_order.reset();
453        self.max_notional_per_order = self.config.max_notional_per_order.clone();
454        self.trading_state = TradingState::Active;
455        self.command_count = 0;
456        self.event_count = 0;
457
458        log::info!("Reset");
459    }
460
461    /// Disposes of the risk engine, releasing resources.
462    pub fn dispose(&mut self) {
463        log::info!("Disposed");
464    }
465
466    /// Returns a reference to the clock.
467    #[must_use]
468    pub fn clock(&self) -> &Rc<RefCell<dyn Clock>> {
469        &self.clock
470    }
471
472    /// Returns a reference to the cache.
473    #[must_use]
474    pub fn cache(&self) -> &Rc<RefCell<Cache>> {
475        &self.cache
476    }
477
478    /// Returns a mutable reference to the portfolio.
479    pub fn portfolio_mut(&mut self) -> &mut Portfolio {
480        &mut self.portfolio
481    }
482
483    /// Returns a reference to the configuration.
484    #[must_use]
485    pub const fn config(&self) -> &RiskEngineConfig {
486        &self.config
487    }
488
489    /// Returns the total count of trading commands received by the engine.
490    #[must_use]
491    pub const fn command_count(&self) -> u64 {
492        self.command_count
493    }
494
495    /// Returns the total count of order events received by the engine.
496    #[must_use]
497    pub const fn event_count(&self) -> u64 {
498        self.event_count
499    }
500
501    /// Returns the current trading state.
502    #[must_use]
503    pub const fn trading_state(&self) -> TradingState {
504        self.trading_state
505    }
506
507    /// Returns a reference to the max notional per order settings.
508    #[must_use]
509    pub const fn max_notional_per_order(&self) -> &AHashMap<InstrumentId, Decimal> {
510        &self.max_notional_per_order
511    }
512
513    fn config_as_map(&self) -> IndexMap<String, String> {
514        let mut map = IndexMap::new();
515        map.insert("bypass".to_string(), self.config.bypass.to_string());
516        map.insert(
517            "max_order_submit_rate".to_string(),
518            format_rate_limit(&self.config.max_order_submit),
519        );
520        map.insert(
521            "max_order_modify_rate".to_string(),
522            format_rate_limit(&self.config.max_order_modify),
523        );
524
525        for (instrument_id, value) in &self.max_notional_per_order {
526            map.insert(
527                format!("max_notional_per_order.{instrument_id}"),
528                value.to_string(),
529            );
530        }
531
532        map.insert("debug".to_string(), self.config.debug.to_string());
533        map
534    }
535
536    fn handle_command(&mut self, command: TradingCommand) {
537        if self.config.debug {
538            log::debug!("{CMD}{RECV} {command:?}");
539        }
540
541        match command {
542            TradingCommand::SubmitOrder(submit_order) => self.handle_submit_order(submit_order),
543            TradingCommand::SubmitOrderList(submit_order_list) => {
544                self.handle_submit_order_list(submit_order_list);
545            }
546            TradingCommand::ModifyOrder(modify_order) => self.handle_modify_order(modify_order),
547            TradingCommand::ModifyOrders(modify_orders) => {
548                self.handle_batch_modify_orders(modify_orders);
549            }
550            TradingCommand::QueryAccount(query_account) => {
551                Self::send_to_execution(TradingCommand::QueryAccount(query_account));
552            }
553            _ => {
554                log::error!("Cannot handle command: {command}");
555            }
556        }
557    }
558
559    fn handle_submit_order(&mut self, command: SubmitOrder) {
560        if self.config.bypass {
561            Self::send_to_execution(TradingCommand::SubmitOrder(command));
562            return;
563        }
564
565        let order = {
566            let cache = self.cache.borrow();
567            let Some(order) = cache.order(&command.client_order_id) else {
568                log::error!(
569                    "Cannot handle submit order: order not found in cache for {}",
570                    command.client_order_id
571                );
572                return;
573            };
574            order.clone()
575        };
576
577        if let Some(position_id) = command.position_id
578            && order.is_reduce_only()
579        {
580            let position_exists = {
581                let cache = self.cache.borrow();
582                cache
583                    .position(&position_id)
584                    .map(|pos| (pos.side, pos.quantity))
585            };
586
587            if let Some((pos_side, pos_quantity)) = position_exists {
588                if !order.would_reduce_only(pos_side, pos_quantity) {
589                    self.deny_command(
590                        TradingCommand::SubmitOrder(command),
591                        &OrderDeniedReason::ReduceOnlyWouldIncreasePosition { position_id }
592                            .to_string(),
593                    );
594                    return; // Denied
595                }
596            } else {
597                self.deny_command(
598                    TradingCommand::SubmitOrder(command),
599                    &OrderDeniedReason::PositionNotFound { position_id }.to_string(),
600                );
601                return;
602            }
603        }
604
605        let instrument_exists = {
606            let cache = self.cache.borrow();
607            cache.instrument(&command.instrument_id).cloned()
608        };
609
610        let Some(instrument) = instrument_exists else {
611            self.deny_command(
612                TradingCommand::SubmitOrder(command.clone()),
613                &OrderDeniedReason::InstrumentNotFound {
614                    instrument_id: command.instrument_id,
615                }
616                .to_string(),
617            );
618            return; // Denied
619        };
620
621        if !self.check_order(&instrument, &order) {
622            return; // Denied
623        }
624
625        if !self.check_orders_risk(&instrument, &[order]) {
626            return; // Denied
627        }
628
629        // Route through execution gateway for TradingState checks & throttling
630        self.execution_gateway(&instrument, TradingCommand::SubmitOrder(command));
631    }
632
633    fn handle_submit_order_list(&mut self, command: SubmitOrderList) {
634        if self.config.bypass {
635            Self::send_to_execution(TradingCommand::SubmitOrderList(command));
636            return;
637        }
638
639        let orders: Vec<OrderAny> = self
640            .cache
641            .borrow()
642            .orders_for_ids(&command.order_list.client_order_ids, &command);
643
644        if orders.len() != command.order_list.client_order_ids.len() {
645            self.deny_order_list(
646                &orders,
647                &OrderDeniedReason::OrderListIncomplete {
648                    order_list_id: command.order_list.id,
649                }
650                .to_string(),
651            );
652            return; // Denied
653        }
654
655        // Per-order checks use each order's own instrument; the cumulative
656        // risk check uses the representative. See docs/concepts/orders.md
657        // (Order lists -> Caveats for mixed-instrument lists).
658        let mut instruments: AHashMap<InstrumentId, InstrumentAny> = AHashMap::new();
659
660        for order in &orders {
661            let instrument_id = order.instrument_id();
662            if instruments.contains_key(&instrument_id) {
663                continue;
664            }
665            let resolved = self.cache.borrow().instrument(&instrument_id).cloned();
666            let Some(instrument) = resolved else {
667                self.deny_command(
668                    TradingCommand::SubmitOrderList(command),
669                    &OrderDeniedReason::InstrumentNotFound { instrument_id }.to_string(),
670                );
671                return; // Denied
672            };
673            instruments.insert(instrument_id, instrument);
674        }
675
676        for order in &orders {
677            let Some(instrument) = instruments.get(&order.instrument_id()) else {
678                self.deny_order(
679                    order,
680                    &OrderDeniedReason::InstrumentNotFound {
681                        instrument_id: order.instrument_id(),
682                    }
683                    .to_string(),
684                );
685                return; // Denied
686            };
687
688            if !self.check_order(instrument, order) {
689                return; // Denied
690            }
691        }
692
693        let representative = if let Some(instrument) = instruments.get(&command.instrument_id) {
694            instrument.clone()
695        } else {
696            self.deny_order_list(
697                &orders,
698                &OrderDeniedReason::InstrumentNotFound {
699                    instrument_id: command.instrument_id,
700                }
701                .to_string(),
702            );
703            return; // Denied
704        };
705
706        if !self.check_orders_risk(&representative, &orders) {
707            self.deny_order_list(
708                &orders,
709                &OrderDeniedReason::OrderListDenied {
710                    order_list_id: command.order_list.id,
711                }
712                .to_string(),
713            );
714            return; // Denied
715        }
716
717        self.execution_gateway(&representative, TradingCommand::SubmitOrderList(command));
718    }
719
720    fn handle_modify_order(&mut self, command: ModifyOrder) {
721        if self.config.bypass {
722            Self::send_to_execution(TradingCommand::ModifyOrder(command));
723            return;
724        }
725
726        if !self.validate_modify_order(&command) {
727            return;
728        }
729
730        self.throttled_modify_order.send(command);
731    }
732
733    fn handle_batch_modify_orders(&mut self, command: BatchModifyOrders) {
734        if self.config.bypass {
735            Self::send_to_execution(TradingCommand::ModifyOrders(command));
736            return;
737        }
738
739        if command.modifies.is_empty() {
740            log::warn!("Cannot handle BatchModifyOrders: no modify commands");
741            return;
742        }
743
744        let mut rejected_client_order_ids = Vec::new();
745        let mut valid = true;
746
747        for modify in &command.modifies {
748            if modify.instrument_id != command.instrument_id {
749                if let Some(order) = self
750                    .cache
751                    .borrow()
752                    .order(&modify.client_order_id)
753                    .map(|o| o.clone())
754                {
755                    self.reject_modify_order(
756                        &order,
757                        &format!(
758                            "BatchModifyOrders instrument {} does not match child instrument {}",
759                            command.instrument_id, modify.instrument_id
760                        ),
761                    );
762                }
763                rejected_client_order_ids.push(modify.client_order_id);
764                valid = false;
765                continue;
766            }
767
768            if !self.validate_modify_order(modify) {
769                rejected_client_order_ids.push(modify.client_order_id);
770                valid = false;
771            }
772        }
773
774        if !valid {
775            let reason = "BatchModifyOrders rejected because one or more child modifications failed validation";
776
777            for modify in &command.modifies {
778                if rejected_client_order_ids.contains(&modify.client_order_id) {
779                    continue;
780                }
781
782                let Some(order) = Self::get_existing_order(&self.cache, modify) else {
783                    continue;
784                };
785
786                self.reject_modify_order(&order, reason);
787            }
788            return;
789        }
790
791        if !self
792            .throttled_modify_order
793            .try_reserve(command.modifies.len())
794        {
795            let reason = "Exceeded MAX_ORDER_MODIFY_RATE";
796
797            for modify in &command.modifies {
798                let Some(order) = Self::get_existing_order(&self.cache, modify) else {
799                    continue;
800                };
801                self.reject_modify_order(&order, reason);
802            }
803            return;
804        }
805
806        Self::send_to_execution(TradingCommand::ModifyOrders(command));
807    }
808
809    fn validate_modify_order(&self, command: &ModifyOrder) -> bool {
810        let order_exists = {
811            let cache = self.cache.borrow();
812            cache.order(&command.client_order_id).map(|o| o.clone())
813        };
814
815        let Some(order) = order_exists else {
816            log::error!(
817                "ModifyOrder DENIED: Order with command.client_order_id: {} not found",
818                command.client_order_id
819            );
820            return false;
821        };
822
823        if order.is_closed() {
824            self.reject_modify_order(
825                &order,
826                &format!(
827                    "Order with command.client_order_id: {} already closed",
828                    command.client_order_id
829                ),
830            );
831            return false;
832        } else if order.status() == OrderStatus::PendingCancel {
833            self.reject_modify_order(
834                &order,
835                &format!(
836                    "Order with command.client_order_id: {} is already pending cancel",
837                    command.client_order_id
838                ),
839            );
840            return false;
841        }
842
843        let maybe_instrument = {
844            let cache = self.cache.borrow();
845            cache.instrument(&command.instrument_id).cloned()
846        };
847
848        let Some(instrument) = maybe_instrument else {
849            self.reject_modify_order(
850                &order,
851                &format!("no instrument found for {:?}", command.instrument_id),
852            );
853            return false;
854        };
855
856        // Check Price
857        let mut risk_msg = Self::check_price(&instrument, command.price);
858        if let Some(risk_msg) = risk_msg {
859            self.reject_modify_order(&order, &risk_msg);
860            return false;
861        }
862
863        // Check Trigger
864        risk_msg = Self::check_price(&instrument, command.trigger_price);
865        if let Some(risk_msg) = risk_msg {
866            self.reject_modify_order(&order, &risk_msg);
867            return false;
868        }
869
870        // Check Quantity
871        risk_msg = Self::check_quantity(&instrument, command.quantity, order.is_quote_quantity());
872        if let Some(risk_msg) = risk_msg {
873            self.reject_modify_order(&order, &risk_msg);
874            return false;
875        }
876
877        // Check TradingState
878        match self.trading_state {
879            TradingState::Halted => {
880                self.reject_modify_order(&order, "TradingState is HALTED: Cannot modify order");
881                return false;
882            }
883            TradingState::Reducing => {
884                if let Some(quantity) = command.quantity
885                    && quantity > order.quantity()
886                    && ((order.is_buy() && self.portfolio.is_net_long(&instrument.id()))
887                        || (order.is_sell() && self.portfolio.is_net_short(&instrument.id())))
888                {
889                    self.reject_modify_order(
890                        &order,
891                        &format!(
892                            "TradingState is REDUCING and update will increase exposure {}",
893                            instrument.id()
894                        ),
895                    );
896                    return false;
897                }
898            }
899            TradingState::Active => {}
900        }
901
902        true
903    }
904
905    fn check_order(&self, instrument: &InstrumentAny, order: &OrderAny) -> bool {
906        if !self.check_order_price(instrument, order)
907            || !self.check_order_quantity(instrument, order)
908        {
909            return false; // Denied
910        }
911
912        if order.time_in_force() == TimeInForce::Gtd {
913            let Some(expire_time) = order.expire_time() else {
914                self.deny_order(order, &OrderDeniedReason::MissingExpireTime.to_string());
915                return false; // Denied
916            };
917
918            if expire_time <= self.clock.borrow().timestamp_ns() {
919                self.deny_order(
920                    order,
921                    &OrderDeniedReason::ExpireTimeInPast {
922                        expire_time: expire_time.to_rfc3339(),
923                    }
924                    .to_string(),
925                );
926                return false; // Denied
927            }
928        }
929
930        true
931    }
932
933    fn check_order_price(&self, instrument: &InstrumentAny, order: &OrderAny) -> bool {
934        if order.price().is_some() {
935            let risk_msg = Self::check_price(instrument, order.price());
936            if let Some(risk_msg) = risk_msg {
937                self.deny_order(order, &risk_msg);
938                return false; // Denied
939            }
940        }
941
942        if order.trigger_price().is_some() {
943            let risk_msg = Self::check_price(instrument, order.trigger_price());
944            if let Some(risk_msg) = risk_msg {
945                self.deny_order(order, &format!("trigger {risk_msg}"));
946                return false; // Denied
947            }
948        }
949
950        true
951    }
952
953    fn check_order_quantity(&self, instrument: &InstrumentAny, order: &OrderAny) -> bool {
954        let risk_msg = Self::check_quantity(
955            instrument,
956            Some(order.quantity()),
957            order.is_quote_quantity(),
958        );
959
960        if let Some(risk_msg) = risk_msg {
961            self.deny_order(order, &risk_msg);
962            return false; // Denied
963        }
964
965        true
966    }
967
968    fn check_orders_risk(&self, instrument: &InstrumentAny, orders: &[OrderAny]) -> bool {
969        let mut orders_by_account: AHashMap<Option<AccountId>, Vec<&OrderAny>> = AHashMap::new();
970        for order in orders {
971            orders_by_account
972                .entry(order.account_id())
973                .or_default()
974                .push(order);
975        }
976
977        for (account_id, account_orders) in &orders_by_account {
978            if !self.check_orders_risk_for_account(instrument, account_orders, *account_id) {
979                return false;
980            }
981        }
982
983        true
984    }
985
986    #[allow(
987        clippy::too_many_lines,
988        reason = "risk checks keep related denial branches together for auditability"
989    )]
990    fn check_orders_risk_for_account(
991        &self,
992        instrument: &InstrumentAny,
993        orders: &[&OrderAny],
994        account_id: Option<AccountId>,
995    ) -> bool {
996        let mut last_px: Option<Price> = None;
997        let mut max_notional: Option<Money> = None;
998
999        // Determine max notional
1000        let max_notional_setting = self.max_notional_per_order.get(&instrument.id());
1001        if let Some(max_notional_setting_val) = max_notional_setting.copied() {
1002            let Ok(max_notional_value) =
1003                Money::from_decimal(max_notional_setting_val, instrument.quote_currency())
1004            else {
1005                for order in orders {
1006                    self.deny_order(
1007                        order,
1008                        &OrderDeniedReason::InvalidMaxNotionalPerOrder {
1009                            instrument_id: instrument.id(),
1010                            value: max_notional_setting_val,
1011                        }
1012                        .to_string(),
1013                    );
1014                }
1015                return false; // Denied
1016            };
1017            max_notional = Some(max_notional_value);
1018        }
1019
1020        // Get account for risk checks: use explicit account_id if provided, otherwise venue lookup
1021        let resolved_account = {
1022            let cache = self.cache.borrow();
1023
1024            if let Some(account_id) = account_id {
1025                cache.account_owned(&account_id)
1026            } else {
1027                cache.account_for_venue_owned(&instrument.id().venue)
1028            }
1029        };
1030
1031        let Some(mut account) = resolved_account else {
1032            log::debug!(
1033                "Cannot find account for venue {} (account_id={account_id:?})",
1034                instrument.id().venue
1035            );
1036            return true;
1037        };
1038
1039        let is_margin = matches!(account, AccountAny::Margin(_));
1040        let is_betting = matches!(account, AccountAny::Betting(_));
1041        let free = match &account {
1042            AccountAny::Margin(margin) => margin.balance_free(Some(instrument.quote_currency())),
1043            AccountAny::Cash(cash) => cash.balance_free(Some(instrument.quote_currency())),
1044            AccountAny::Betting(betting) => betting.balance_free(Some(instrument.quote_currency())),
1045        };
1046        let allow_borrowing = match &account {
1047            AccountAny::Cash(cash) => cash.allow_borrowing,
1048            AccountAny::Margin(_) | AccountAny::Betting(_) => false,
1049        };
1050
1051        if self.config.debug {
1052            log::debug!("Free balance: {free:?}");
1053        }
1054
1055        // Get net LONG position quantity for this instrument (for position-reducing sell checks),
1056        // accounting for already submitted (but unfilled) SELL orders to prevent overselling.
1057        let (net_long_qty_raw, pending_sell_qty_raw) = {
1058            let cache = self.cache.borrow();
1059            let long_qty: QuantityRaw = cache
1060                .positions_open(
1061                    None,
1062                    Some(&instrument.id()),
1063                    None,
1064                    None,
1065                    Some(PositionSide::Long),
1066                )
1067                .iter()
1068                .map(|pos| pos.quantity.raw)
1069                .sum();
1070            let pending_sells: QuantityRaw = cache
1071                .orders_open(
1072                    None,
1073                    Some(&instrument.id()),
1074                    None,
1075                    None,
1076                    Some(OrderSide::Sell),
1077                )
1078                .iter()
1079                .map(|ord| ord.leaves_qty().raw)
1080                .sum();
1081            (long_qty, pending_sells)
1082        };
1083
1084        // Available quantity is long position minus pending sells
1085        let available_long_qty_raw = net_long_qty_raw.saturating_sub(pending_sell_qty_raw);
1086
1087        if self.config.debug && net_long_qty_raw > 0 {
1088            log::debug!(
1089                "Net LONG qty (raw): {net_long_qty_raw}, pending sells: {pending_sell_qty_raw}, available: {available_long_qty_raw}"
1090            );
1091        }
1092
1093        // For margin and betting accounts, also track SHORT positions for buy-side reduction
1094        let available_short_qty_raw = if is_margin || is_betting {
1095            let cache = self.cache.borrow();
1096            let short_qty: QuantityRaw = cache
1097                .positions_open(
1098                    None,
1099                    Some(&instrument.id()),
1100                    None,
1101                    None,
1102                    Some(PositionSide::Short),
1103                )
1104                .iter()
1105                .map(|pos| pos.quantity.raw)
1106                .sum();
1107            let pending_buys: QuantityRaw = cache
1108                .orders_open(
1109                    None,
1110                    Some(&instrument.id()),
1111                    None,
1112                    None,
1113                    Some(OrderSide::Buy),
1114                )
1115                .iter()
1116                .map(|ord| ord.leaves_qty().raw)
1117                .sum();
1118
1119            if self.config.debug && short_qty > 0 {
1120                log::debug!(
1121                    "Net SHORT qty (raw): {short_qty}, pending buys: {pending_buys}, available: {}",
1122                    short_qty.saturating_sub(pending_buys)
1123                );
1124            }
1125
1126            short_qty.saturating_sub(pending_buys)
1127        } else {
1128            0
1129        };
1130
1131        // Track cumulative quantities to determine position-reducing vs position-opening orders
1132        let mut cum_sell_qty_raw: QuantityRaw = 0;
1133        let mut cum_buy_qty_raw: QuantityRaw = 0;
1134
1135        let mut cum_notional_buy: Option<Money> = None;
1136        let mut cum_notional_sell: Option<Money> = None;
1137        let mut cum_margin_required: Option<Money> = None;
1138        let mut base_currency: Option<Currency> = None;
1139
1140        for order in orders {
1141            // Determine last price based on order type
1142            last_px = match order {
1143                OrderAny::Market(_) | OrderAny::MarketToLimit(_) => {
1144                    if last_px.is_none() {
1145                        let quote_price = {
1146                            let cache = self.cache.borrow();
1147                            cache.quote(&instrument.id()).map(|last_quote| {
1148                                match order.order_side() {
1149                                    OrderSide::Buy => Ok(last_quote.ask_price),
1150                                    OrderSide::Sell => Ok(last_quote.bid_price),
1151                                    OrderSide::NoOrderSide => {
1152                                        Err(OrderDeniedReason::InvalidOrderSide {
1153                                            order_side: order.order_side(),
1154                                        }
1155                                        .to_string())
1156                                    }
1157                                }
1158                            })
1159                        };
1160
1161                        if let Some(quote_price) = quote_price {
1162                            match quote_price {
1163                                Ok(price) => Some(price),
1164                                Err(reason) => {
1165                                    self.deny_order(order, &reason);
1166                                    return false; // Denied
1167                                }
1168                            }
1169                        } else {
1170                            let cache = self.cache.borrow();
1171                            let last_trade = cache.trade(&instrument.id());
1172
1173                            if let Some(last_trade) = last_trade {
1174                                Some(last_trade.price)
1175                            } else {
1176                                log::warn!(
1177                                    "Cannot check MARKET order risk: no prices for {}",
1178                                    instrument.id()
1179                                );
1180                                continue;
1181                            }
1182                        }
1183                    } else {
1184                        last_px
1185                    }
1186                }
1187                OrderAny::StopMarket(_) | OrderAny::MarketIfTouched(_) => order.trigger_price(),
1188                OrderAny::TrailingStopMarket(_) | OrderAny::TrailingStopLimit(_) => {
1189                    if let Some(trigger_price) = order.trigger_price() {
1190                        Some(trigger_price)
1191                    } else {
1192                        // Validate trailing offset type is supported
1193                        let Some(offset_type) = order.trailing_offset_type() else {
1194                            self.deny_order(
1195                                order,
1196                                &OrderDeniedReason::MissingTrailingOffsetType.to_string(),
1197                            );
1198                            return false; // Denied
1199                        };
1200
1201                        if !matches!(
1202                            offset_type,
1203                            TrailingOffsetType::Price
1204                                | TrailingOffsetType::BasisPoints
1205                                | TrailingOffsetType::Ticks
1206                        ) {
1207                            self.deny_order(
1208                                order,
1209                                &OrderDeniedReason::UnsupportedTrailingOffsetType { offset_type }
1210                                    .to_string(),
1211                            );
1212                            return false;
1213                        }
1214
1215                        let Some(trigger_type) = order.trigger_type() else {
1216                            self.deny_order(
1217                                order,
1218                                &OrderDeniedReason::MissingTriggerType.to_string(),
1219                            );
1220                            return false; // Denied
1221                        };
1222                        let Some(trailing_offset) = order.trailing_offset() else {
1223                            self.deny_order(
1224                                order,
1225                                &OrderDeniedReason::MissingTrailingOffset.to_string(),
1226                            );
1227                            return false; // Denied
1228                        };
1229
1230                        // Compute trailing stop trigger inside a scoped cache borrow
1231                        // to avoid RefCell conflict if deny_order is called below
1232                        let calc_result: Result<Option<Price>, String> = {
1233                            let cache = self.cache.borrow();
1234
1235                            if trigger_type == TriggerType::BidAsk {
1236                                if let Some(quote) = cache.quote(&instrument.id()) {
1237                                    trailing_stop_calculate_with_bid_ask(
1238                                        instrument.price_increment(),
1239                                        offset_type,
1240                                        order.order_side_specified(),
1241                                        trailing_offset,
1242                                        quote.bid_price,
1243                                        quote.ask_price,
1244                                    )
1245                                    .map(Some)
1246                                    .map_err(|e| e.to_string())
1247                                } else {
1248                                    log::warn!(
1249                                        "Cannot check {} order risk: no trigger price set and no bid/ask quotes available for {}",
1250                                        order.order_type(),
1251                                        instrument.id()
1252                                    );
1253                                    Ok(None)
1254                                }
1255                            } else if let Some(last_trade) = cache.trade(&instrument.id()) {
1256                                trailing_stop_calculate_with_last(
1257                                    instrument.price_increment(),
1258                                    offset_type,
1259                                    order.order_side_specified(),
1260                                    trailing_offset,
1261                                    last_trade.price,
1262                                )
1263                                .map(Some)
1264                                .map_err(|e| e.to_string())
1265                            } else if trigger_type == TriggerType::LastOrBidAsk {
1266                                if let Some(quote) = cache.quote(&instrument.id()) {
1267                                    trailing_stop_calculate_with_bid_ask(
1268                                        instrument.price_increment(),
1269                                        offset_type,
1270                                        order.order_side_specified(),
1271                                        trailing_offset,
1272                                        quote.bid_price,
1273                                        quote.ask_price,
1274                                    )
1275                                    .map(Some)
1276                                    .map_err(|e| e.to_string())
1277                                } else {
1278                                    log::warn!(
1279                                        "Cannot check {} order risk: no trigger price set and no market data available for {}",
1280                                        order.order_type(),
1281                                        instrument.id()
1282                                    );
1283                                    Ok(None)
1284                                }
1285                            } else {
1286                                log::warn!(
1287                                    "Cannot check {} order risk: no trigger price set and no market data available for {}",
1288                                    order.order_type(),
1289                                    instrument.id()
1290                                );
1291                                Ok(None)
1292                            }
1293                        };
1294                        // Cache borrow dropped here
1295
1296                        match calc_result {
1297                            Ok(Some(trigger)) => Some(trigger),
1298                            Ok(None) => {
1299                                continue;
1300                            }
1301                            Err(e) => {
1302                                self.deny_order(
1303                                    order,
1304                                    &OrderDeniedReason::TrailingStopCalcFailed { detail: e }
1305                                        .to_string(),
1306                                );
1307                                return false;
1308                            }
1309                        }
1310                    }
1311                }
1312                _ => order.price(),
1313            };
1314
1315            let Some(last_px) = last_px else {
1316                log::error!("Cannot check order risk: no price available");
1317                continue;
1318            };
1319
1320            // For quote quantity limit orders, use worst-case execution price
1321            let effective_price = if order.is_quote_quantity()
1322                && !instrument.is_inverse()
1323                && matches!(order, OrderAny::Limit(_) | OrderAny::StopLimit(_))
1324            {
1325                // Get current market price for worst-case execution
1326                let cache = self.cache.borrow();
1327                if let Some(quote_tick) = cache.quote(&instrument.id()) {
1328                    match order.order_side() {
1329                        // BUY: could execute at best ask if below limit (more quantity)
1330                        OrderSide::Buy => last_px.min(quote_tick.ask_price),
1331                        // SELL: could execute at best bid if above limit (but less quantity, so use limit)
1332                        OrderSide::Sell => last_px.max(quote_tick.bid_price),
1333                        OrderSide::NoOrderSide => last_px,
1334                    }
1335                } else {
1336                    last_px // No market data, use limit price
1337                }
1338            } else {
1339                last_px
1340            };
1341
1342            let effective_quantity = if order.is_quote_quantity() && !instrument.is_inverse() {
1343                instrument.calculate_base_quantity(order.quantity(), effective_price)
1344            } else {
1345                order.quantity()
1346            };
1347
1348            // Base-quantity bounds (`min_quantity`/`max_quantity`) do not apply to
1349            // quote-denominated orders: the client-side conversion uses an estimated
1350            // price and may differ from the venue fill, and some venues enforce
1351            // distinct per-order-type minimums. The venue is authoritative for
1352            // quote-denominated sizing; rely on `min_notional`/`max_notional` below.
1353            if !order.is_quote_quantity() {
1354                if let Some(max_quantity) = instrument.max_quantity()
1355                    && effective_quantity > max_quantity
1356                {
1357                    self.deny_order(
1358                        order,
1359                        &OrderDeniedReason::QuantityExceedsMaximum {
1360                            effective_quantity,
1361                            max_quantity,
1362                        }
1363                        .to_string(),
1364                    );
1365                    return false; // Denied
1366                }
1367
1368                if let Some(min_quantity) = instrument.min_quantity()
1369                    && effective_quantity < min_quantity
1370                {
1371                    self.deny_order(
1372                        order,
1373                        &OrderDeniedReason::QuantityBelowMinimum {
1374                            effective_quantity,
1375                            min_quantity,
1376                        }
1377                        .to_string(),
1378                    );
1379                    return false; // Denied
1380                }
1381            }
1382
1383            let notional =
1384                instrument.calculate_notional_value(effective_quantity, last_px, Some(true));
1385
1386            if self.config.debug {
1387                log::debug!("Notional: {notional:?}");
1388            }
1389
1390            // Check MAX notional per order limit
1391            if let Some(max_notional_value) = max_notional
1392                && notional > max_notional_value
1393            {
1394                self.deny_order(
1395                    order,
1396                    &OrderDeniedReason::NotionalExceedsMaxPerOrder {
1397                        max_notional: max_notional_value,
1398                        notional,
1399                    }
1400                    .to_string(),
1401                );
1402                return false; // Denied
1403            }
1404
1405            // Check MIN notional instrument limit
1406            if let Some(min_notional) = instrument.min_notional()
1407                && notional.currency == min_notional.currency
1408                && notional < min_notional
1409            {
1410                self.deny_order(
1411                    order,
1412                    &OrderDeniedReason::NotionalBelowMinimum {
1413                        min_notional,
1414                        notional,
1415                    }
1416                    .to_string(),
1417                );
1418                return false; // Denied
1419            }
1420
1421            // Check MAX notional instrument limit
1422            if let Some(max_notional) = instrument.max_notional()
1423                && notional.currency == max_notional.currency
1424                && notional > max_notional
1425            {
1426                self.deny_order(
1427                    order,
1428                    &OrderDeniedReason::NotionalExceedsMaximum {
1429                        max_notional,
1430                        notional,
1431                    }
1432                    .to_string(),
1433                );
1434                return false; // Denied
1435            }
1436
1437            if is_margin {
1438                // Margin account: check initial margin requirement
1439                let margin_req = match &mut account {
1440                    AccountAny::Margin(margin) => margin
1441                        .calculate_initial_margin(instrument, effective_quantity, last_px, None)
1442                        .unwrap_or_else(|e| {
1443                            log::error!("Failed to calculate initial margin: {e}");
1444                            Money::zero(instrument.quote_currency())
1445                        }),
1446                    _ => unreachable!(),
1447                };
1448
1449                if self.config.debug {
1450                    log::debug!("Initial margin required: {margin_req}");
1451                }
1452
1453                // Determine if order is position-reducing
1454                let is_reducing = order.is_reduce_only()
1455                    || (order.is_sell()
1456                        && (cum_sell_qty_raw + effective_quantity.raw) <= available_long_qty_raw)
1457                    || (order.is_buy()
1458                        && (cum_buy_qty_raw + effective_quantity.raw) <= available_short_qty_raw);
1459
1460                if order.is_sell() {
1461                    cum_sell_qty_raw += effective_quantity.raw;
1462                } else if order.is_buy() {
1463                    cum_buy_qty_raw += effective_quantity.raw;
1464                }
1465
1466                if is_reducing {
1467                    if self.config.debug {
1468                        log::debug!("Position-reducing order skips margin check");
1469                    }
1470                    continue;
1471                }
1472
1473                // Look up free balance in the margin requirement's currency
1474                // (handles inverse instruments where collateral is base currency)
1475                let margin_free = match &account {
1476                    AccountAny::Margin(margin) => margin.balance_free(Some(margin_req.currency)),
1477                    _ => unreachable!(),
1478                };
1479
1480                let Some(margin_free_val) = margin_free else {
1481                    if self.config.debug {
1482                        log::debug!(
1483                            "No balance for margin currency {}, skipping margin check",
1484                            margin_req.currency
1485                        );
1486                    }
1487                    continue;
1488                };
1489
1490                // Per-order margin check
1491                if margin_req > margin_free_val {
1492                    self.deny_order(
1493                        order,
1494                        &OrderDeniedReason::MarginExceedsFreeBalance {
1495                            free: margin_free_val,
1496                            margin_required: margin_req,
1497                        }
1498                        .to_string(),
1499                    );
1500                    return false;
1501                }
1502
1503                // Cumulative margin check
1504                match cum_margin_required.as_mut() {
1505                    Some(cum) => cum.raw += margin_req.raw,
1506                    None => cum_margin_required = Some(margin_req),
1507                }
1508
1509                if self.config.debug {
1510                    log::debug!("Cumulative margin required: {cum_margin_required:?}");
1511                }
1512
1513                if let Some(cum_margin) = cum_margin_required
1514                    && cum_margin > margin_free_val
1515                {
1516                    self.deny_order(
1517                        order,
1518                        &OrderDeniedReason::CumMarginExceedsFreeBalance {
1519                            free: margin_free_val,
1520                            cum_margin,
1521                        }
1522                        .to_string(),
1523                    );
1524                    return false;
1525                }
1526            } else {
1527                // Cash account: check full notional value
1528                let notional =
1529                    instrument.calculate_notional_value(effective_quantity, last_px, None);
1530                let order_balance_impact = if is_betting {
1531                    match &mut account {
1532                        AccountAny::Betting(betting) => Money::from_raw(
1533                            -betting
1534                                .calculate_balance_locked(
1535                                    instrument,
1536                                    order.order_side(),
1537                                    effective_quantity,
1538                                    last_px,
1539                                    None,
1540                                )
1541                                .unwrap_or_else(|e| {
1542                                    log::error!("Failed to calculate betting balance locked: {e}");
1543                                    Money::zero(instrument.quote_currency())
1544                                })
1545                                .raw,
1546                            instrument.quote_currency(),
1547                        ),
1548                        _ => unreachable!(),
1549                    }
1550                } else {
1551                    match order.order_side() {
1552                        OrderSide::Buy => Money::from_raw(-notional.raw, notional.currency),
1553                        OrderSide::Sell => Money::from_raw(notional.raw, notional.currency),
1554                        OrderSide::NoOrderSide => {
1555                            self.deny_order(
1556                                order,
1557                                &OrderDeniedReason::InvalidOrderSide {
1558                                    order_side: order.order_side(),
1559                                }
1560                                .to_string(),
1561                            );
1562                            return false; // Denied
1563                        }
1564                    }
1565                };
1566
1567                if self.config.debug {
1568                    log::debug!("Balance impact: {order_balance_impact}");
1569                }
1570
1571                // Check if order reduces an existing position
1572                let is_position_reducing = if order.is_buy() {
1573                    let reducing = order.is_reduce_only()
1574                        || (cum_buy_qty_raw + effective_quantity.raw) <= available_short_qty_raw;
1575                    cum_buy_qty_raw += effective_quantity.raw;
1576                    reducing
1577                } else if order.is_sell() {
1578                    let reducing = order.is_reduce_only()
1579                        || (cum_sell_qty_raw + effective_quantity.raw) <= available_long_qty_raw;
1580                    cum_sell_qty_raw += effective_quantity.raw;
1581                    reducing
1582                } else {
1583                    false
1584                };
1585
1586                if is_position_reducing {
1587                    if self.config.debug {
1588                        log::debug!("Position-reducing order skips balance check");
1589                    }
1590                    continue;
1591                }
1592
1593                // Deny when order exceeds free balance (unless borrowing is enabled)
1594                if !allow_borrowing
1595                    && let Some(free_val) = free
1596                    && (free_val.as_decimal() + order_balance_impact.as_decimal()) < Decimal::ZERO
1597                {
1598                    self.deny_order(
1599                        order,
1600                        &OrderDeniedReason::NotionalExceedsFreeBalance {
1601                            free: free_val,
1602                            notional,
1603                        }
1604                        .to_string(),
1605                    );
1606                    return false;
1607                }
1608
1609                if base_currency.is_none() {
1610                    base_currency = instrument.base_currency();
1611                }
1612
1613                if order.is_buy() {
1614                    match cum_notional_buy.as_mut() {
1615                        Some(cum_notional_buy_val) => {
1616                            cum_notional_buy_val.raw += -order_balance_impact.raw;
1617                        }
1618                        None => {
1619                            cum_notional_buy = Some(Money::from_raw(
1620                                -order_balance_impact.raw,
1621                                order_balance_impact.currency,
1622                            ));
1623                        }
1624                    }
1625
1626                    if self.config.debug {
1627                        log::debug!("Cumulative notional BUY: {cum_notional_buy:?}");
1628                    }
1629
1630                    if !allow_borrowing
1631                        && let (Some(free), Some(cum_notional_buy)) = (free, cum_notional_buy)
1632                        && cum_notional_buy > free
1633                    {
1634                        self.deny_order(
1635                            order,
1636                            &OrderDeniedReason::CumNotionalExceedsFreeBalance {
1637                                free,
1638                                cum_notional: cum_notional_buy,
1639                            }
1640                            .to_string(),
1641                        );
1642                        return false; // Denied
1643                    }
1644                } else if order.is_sell() {
1645                    if is_betting {
1646                        match cum_notional_sell.as_mut() {
1647                            Some(cum_notional_sell_val) => {
1648                                cum_notional_sell_val.raw += -order_balance_impact.raw;
1649                            }
1650                            None => {
1651                                cum_notional_sell = Some(Money::from_raw(
1652                                    -order_balance_impact.raw,
1653                                    order_balance_impact.currency,
1654                                ));
1655                            }
1656                        }
1657
1658                        if self.config.debug {
1659                            log::debug!("Cumulative betting SELL liability: {cum_notional_sell:?}");
1660                        }
1661
1662                        if !allow_borrowing
1663                            && let (Some(free), Some(cum_notional_sell)) = (free, cum_notional_sell)
1664                            && cum_notional_sell > free
1665                        {
1666                            self.deny_order(
1667                                order,
1668                                &OrderDeniedReason::CumNotionalExceedsFreeBalance {
1669                                    free,
1670                                    cum_notional: cum_notional_sell,
1671                                }
1672                                .to_string(),
1673                            );
1674                            return false;
1675                        }
1676
1677                        continue;
1678                    }
1679
1680                    let has_base_currency = match &account {
1681                        AccountAny::Margin(_) => false,
1682                        AccountAny::Cash(cash) => cash.base_currency.is_some(),
1683                        AccountAny::Betting(betting) => betting.base_currency.is_some(),
1684                    };
1685
1686                    if has_base_currency {
1687                        match cum_notional_sell.as_mut() {
1688                            Some(cum_notional_sell_val) => {
1689                                cum_notional_sell_val.raw += order_balance_impact.raw;
1690                            }
1691                            None => {
1692                                cum_notional_sell = Some(Money::from_raw(
1693                                    order_balance_impact.raw,
1694                                    order_balance_impact.currency,
1695                                ));
1696                            }
1697                        }
1698
1699                        if self.config.debug {
1700                            log::debug!("Cumulative notional SELL: {cum_notional_sell:?}");
1701                        }
1702
1703                        if !allow_borrowing
1704                            && let (Some(free), Some(cum_notional_sell)) = (free, cum_notional_sell)
1705                            && cum_notional_sell > free
1706                        {
1707                            self.deny_order(
1708                                order,
1709                                &OrderDeniedReason::CumNotionalExceedsFreeBalance {
1710                                    free,
1711                                    cum_notional: cum_notional_sell,
1712                                }
1713                                .to_string(),
1714                            );
1715                            return false; // Denied
1716                        }
1717                    } else if let Some(base_currency) = base_currency {
1718                        let cash_value_raw: MoneyRaw = match effective_quantity.raw.try_into() {
1719                            Ok(value) => value,
1720                            Err(e) => {
1721                                self.deny_order(
1722                                    order,
1723                                    &OrderDeniedReason::QuantityConversionFailed {
1724                                        detail: e.to_string(),
1725                                    }
1726                                    .to_string(),
1727                                );
1728                                return false; // Denied
1729                            }
1730                        };
1731                        let cash_value = Money::from_raw(cash_value_raw, base_currency);
1732
1733                        // Use base-currency free balance for sell checks
1734                        let base_free = match &account {
1735                            AccountAny::Margin(_) => None,
1736                            AccountAny::Cash(cash) => cash.balance_free(Some(base_currency)),
1737                            AccountAny::Betting(betting) => {
1738                                betting.balance_free(Some(base_currency))
1739                            }
1740                        };
1741
1742                        if self.config.debug
1743                            && let AccountAny::Cash(cash) = &account
1744                        {
1745                            log::debug!("Cash value: {cash_value:?}");
1746                            log::debug!("Total: {:?}", cash.balance_total(Some(base_currency)));
1747                            log::debug!("Locked: {:?}", cash.balance_locked(Some(base_currency)));
1748                            log::debug!("Free: {base_free:?}");
1749                        }
1750
1751                        if self.config.debug
1752                            && let AccountAny::Betting(betting) = &account
1753                        {
1754                            log::debug!("Cash value: {cash_value:?}");
1755                            log::debug!("Total: {:?}", betting.balance_total(Some(base_currency)));
1756                            log::debug!(
1757                                "Locked: {:?}",
1758                                betting.balance_locked(Some(base_currency))
1759                            );
1760                            log::debug!("Free: {base_free:?}");
1761                        }
1762
1763                        match cum_notional_sell {
1764                            Some(mut value) => {
1765                                value.raw += cash_value.raw;
1766                                cum_notional_sell = Some(value);
1767                            }
1768                            None => cum_notional_sell = Some(cash_value),
1769                        }
1770
1771                        if self.config.debug {
1772                            log::debug!("Cumulative notional SELL: {cum_notional_sell:?}");
1773                        }
1774
1775                        if !allow_borrowing
1776                            && let (Some(base_free), Some(cum_notional_sell)) =
1777                                (base_free, cum_notional_sell)
1778                            && cum_notional_sell.raw > base_free.raw
1779                        {
1780                            self.deny_order(
1781                                order,
1782                                &OrderDeniedReason::CumNotionalExceedsFreeBalance {
1783                                    free: base_free,
1784                                    cum_notional: cum_notional_sell,
1785                                }
1786                                .to_string(),
1787                            );
1788                            return false; // Denied
1789                        }
1790                    }
1791                }
1792            }
1793        }
1794
1795        // Finally
1796        true // Passed
1797    }
1798
1799    fn check_price(instrument: &InstrumentAny, price: Option<Price>) -> Option<String> {
1800        let price_val = price?;
1801
1802        if price_val.precision > instrument.price_precision() {
1803            return Some(format!(
1804                "price {} invalid (precision {} > {})",
1805                price_val,
1806                price_val.precision,
1807                instrument.price_precision()
1808            ));
1809        }
1810
1811        if !instrument.allows_negative_price() && price_val.raw <= 0 {
1812            return Some(format!("price {price_val} invalid (<= 0)"));
1813        }
1814
1815        None
1816    }
1817
1818    fn check_quantity(
1819        instrument: &InstrumentAny,
1820        quantity: Option<Quantity>,
1821        is_quote_quantity: bool,
1822    ) -> Option<String> {
1823        let quantity_val = quantity?;
1824
1825        // Check precision
1826        if quantity_val.precision > instrument.size_precision() {
1827            return Some(format!(
1828                "quantity {} invalid (precision {} > {})",
1829                quantity_val,
1830                quantity_val.precision,
1831                instrument.size_precision()
1832            ));
1833        }
1834
1835        // Skip min/max checks for quote quantities (they will be checked in check_orders_risk using effective_quantity)
1836        if is_quote_quantity {
1837            return None;
1838        }
1839
1840        // Check maximum quantity
1841        if let Some(max_quantity) = instrument.max_quantity()
1842            && quantity_val > max_quantity
1843        {
1844            return Some(format!(
1845                "quantity {quantity_val} invalid (> maximum trade size of {max_quantity})"
1846            ));
1847        }
1848
1849        // Check minimum quantity
1850        if let Some(min_quantity) = instrument.min_quantity()
1851            && quantity_val < min_quantity
1852        {
1853            return Some(format!(
1854                "quantity {quantity_val} invalid (< minimum trade size of {min_quantity})"
1855            ));
1856        }
1857
1858        None
1859    }
1860
1861    fn deny_command(&self, command: TradingCommand, reason: &str) {
1862        match command {
1863            TradingCommand::SubmitOrder(command) => {
1864                let order = {
1865                    let cache = self.cache.borrow();
1866                    cache.order(&command.client_order_id).map(|o| o.clone())
1867                };
1868
1869                if let Some(ref order) = order {
1870                    self.deny_order(order, reason);
1871                } else {
1872                    log::error!(
1873                        "Cannot deny order: not found in cache for {}",
1874                        command.client_order_id
1875                    );
1876                }
1877            }
1878            TradingCommand::SubmitOrderList(command) => {
1879                let orders: Vec<OrderAny> = self
1880                    .cache
1881                    .borrow()
1882                    .orders_for_ids(&command.order_list.client_order_ids, &command);
1883                self.deny_order_list(&orders, reason);
1884            }
1885            _ => {
1886                log::error!("Cannot deny command {command}");
1887            }
1888        }
1889    }
1890
1891    fn deny_order(&self, order: &OrderAny, reason: &str) {
1892        log::warn!(
1893            "SubmitOrder for {} DENIED: {}",
1894            order.client_order_id(),
1895            reason
1896        );
1897
1898        if order.status() != OrderStatus::Initialized {
1899            return;
1900        }
1901
1902        // Scope the cache borrow to avoid RefCell conflict when sending to ExecEngine
1903        {
1904            let mut cache = self.cache.borrow_mut();
1905            if !cache.order_exists(&order.client_order_id())
1906                && let Err(e) = cache.add_order(order.clone(), None, None, false)
1907            {
1908                log::error!("Cannot add order to cache: {e}");
1909                return;
1910            }
1911        }
1912
1913        let denied = OrderEventAny::Denied(OrderDenied::new(
1914            order.trader_id(),
1915            order.strategy_id(),
1916            order.instrument_id(),
1917            order.client_order_id(),
1918            reason.into(),
1919            UUID4::new(),
1920            self.clock.borrow().timestamp_ns(),
1921            self.clock.borrow().timestamp_ns(),
1922        ));
1923
1924        let endpoint = MessagingSwitchboard::exec_engine_process();
1925        msgbus::send_order_event(endpoint, denied);
1926    }
1927
1928    fn deny_order_list(&self, orders: &[OrderAny], reason: &str) {
1929        for order in orders {
1930            if !order.is_closed() {
1931                self.deny_order(order, reason);
1932            }
1933        }
1934    }
1935
1936    fn reject_modify_order(&self, order: &OrderAny, reason: &str) {
1937        let ts_event = self.clock.borrow().timestamp_ns();
1938        let denied = OrderEventAny::ModifyRejected(OrderModifyRejected::new(
1939            order.trader_id(),
1940            order.strategy_id(),
1941            order.instrument_id(),
1942            order.client_order_id(),
1943            reason.into(),
1944            UUID4::new(),
1945            ts_event,
1946            ts_event,
1947            false,
1948            order.venue_order_id(),
1949            order.account_id(),
1950        ));
1951
1952        let endpoint = MessagingSwitchboard::exec_engine_process();
1953        msgbus::send_order_event(endpoint, denied);
1954    }
1955
1956    fn execution_gateway(&mut self, instrument: &InstrumentAny, command: TradingCommand) {
1957        match self.trading_state {
1958            TradingState::Halted => match command {
1959                TradingCommand::SubmitOrder(submit_order) => {
1960                    let order = {
1961                        let cache = self.cache.borrow();
1962                        cache
1963                            .order(&submit_order.client_order_id)
1964                            .map(|o| o.clone())
1965                    };
1966
1967                    if let Some(ref order) = order {
1968                        self.deny_order(order, &OrderDeniedReason::TradingHalted.to_string());
1969                    }
1970                }
1971                TradingCommand::SubmitOrderList(submit_order_list) => {
1972                    let orders: Vec<OrderAny> = self.cache.borrow().orders_for_ids(
1973                        &submit_order_list.order_list.client_order_ids,
1974                        &submit_order_list,
1975                    );
1976                    self.deny_order_list(&orders, &OrderDeniedReason::TradingHalted.to_string());
1977                }
1978                _ => {}
1979            },
1980            TradingState::Reducing => {
1981                match &command {
1982                    TradingCommand::SubmitOrder(submit_order) => {
1983                        let order = {
1984                            let cache = self.cache.borrow();
1985                            cache
1986                                .order(&submit_order.client_order_id)
1987                                .map(|o| o.clone())
1988                        };
1989
1990                        if let Some(ref order) = order
1991                            && ((order.is_buy() && self.portfolio.is_net_long(&instrument.id()))
1992                                || (order.is_sell()
1993                                    && self.portfolio.is_net_short(&instrument.id())))
1994                        {
1995                            self.deny_order(
1996                                order,
1997                                &OrderDeniedReason::TradingStateReducing {
1998                                    order_side: order.order_side(),
1999                                    instrument_id: instrument.id(),
2000                                }
2001                                .to_string(),
2002                            );
2003                            return;
2004                        }
2005                    }
2006                    TradingCommand::SubmitOrderList(submit_order_list) => {
2007                        let orders: Vec<OrderAny> = self.cache.borrow().orders_for_ids(
2008                            &submit_order_list.order_list.client_order_ids,
2009                            &submit_order_list,
2010                        );
2011
2012                        for order in &orders {
2013                            let order_instrument_id = order.instrument_id();
2014                            if (order.is_buy() && self.portfolio.is_net_long(&order_instrument_id))
2015                                || (order.is_sell()
2016                                    && self.portfolio.is_net_short(&order_instrument_id))
2017                            {
2018                                self.deny_order_list(
2019                                    &orders,
2020                                    &OrderDeniedReason::TradingStateReducing {
2021                                        order_side: order.order_side(),
2022                                        instrument_id: order_instrument_id,
2023                                    }
2024                                    .to_string(),
2025                                );
2026                                return;
2027                            }
2028                        }
2029                    }
2030                    _ => {}
2031                }
2032                // Not denied: forward to throttler
2033                self.throttled_submit.send(command);
2034            }
2035            TradingState::Active => match command {
2036                TradingCommand::SubmitOrder(_) | TradingCommand::SubmitOrderList(_) => {
2037                    self.throttled_submit.send(command);
2038                }
2039                _ => {}
2040            },
2041        }
2042    }
2043
2044    fn send_to_execution(command: TradingCommand) {
2045        let endpoint = MessagingSwitchboard::exec_engine_queue_execute();
2046        msgbus::send_trading_command(endpoint, command);
2047    }
2048
2049    fn handle_event(&self, event: &OrderEventAny) {
2050        // We intend to extend the risk engine to be able to handle additional events.
2051        // For now we just log.
2052        if self.config.debug {
2053            log::debug!("{RECV}{EVT} {event:?}");
2054        }
2055    }
2056
2057    fn handle_position_event(&self, event: &PositionEvent) {
2058        if self.config.debug {
2059            log::debug!("{RECV}{EVT} {event:?}");
2060        }
2061    }
2062}