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