Skip to main content

nautilus_risk/engine/
mod.rs

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