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 (index, order) in orders.iter().enumerate() {
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                let reason = OrderDeniedReason::OrderListDenied {
834                    order_list_id: command.order_list.id,
835                }
836                .to_string();
837
838                self.deny_order_list(&orders[..index], &reason);
839                self.deny_order_list(&orders[index + 1..], &reason);
840                return; // Denied
841            }
842        }
843
844        let representative = if let Some(instrument) = instruments.get(&command.instrument_id) {
845            instrument.clone()
846        } else {
847            self.deny_order_list(
848                &orders,
849                &OrderDeniedReason::InstrumentNotFound {
850                    instrument_id: command.instrument_id,
851                }
852                .to_string(),
853            );
854            return; // Denied
855        };
856
857        let Some(reservations) = self.check_orders_risk(
858            &representative,
859            &orders,
860            false,
861            RiskCheck::Submit,
862            command.client_id,
863        ) else {
864            self.deny_order_list(
865                &orders,
866                &OrderDeniedReason::OrderListDenied {
867                    order_list_id: command.order_list.id,
868                }
869                .to_string(),
870            );
871            return; // Denied
872        };
873
874        self.execution_gateway(TradingCommand::SubmitOrderList(command), reservations);
875    }
876
877    fn handle_modify_order(&mut self, command: ModifyOrder) {
878        if self.config.bypass {
879            Self::send_to_execution(TradingCommand::ModifyOrder(command));
880            return;
881        }
882
883        if !self.validate_modify_order(&command)
884            || !self.check_modify_orders_risk(std::slice::from_ref(&command), command.client_id)
885        {
886            return;
887        }
888
889        self.throttler_modify.send(command);
890    }
891
892    fn handle_batch_modify_orders(&mut self, command: BatchModifyOrders) {
893        if self.config.bypass {
894            Self::send_to_execution(TradingCommand::ModifyOrders(command));
895            return;
896        }
897
898        if command.modifies.is_empty() {
899            log::warn!("Cannot handle BatchModifyOrders: no modify commands");
900            return;
901        }
902
903        if !self.validate_batch_modify_orders(&command) {
904            return;
905        }
906
907        if !self.check_modify_orders_risk(&command.modifies, command.client_id) {
908            return;
909        }
910
911        if !self.throttler_modify.try_reserve(command.modifies.len()) {
912            let reason = "Exceeded MAX_ORDER_MODIFY_RATE";
913
914            for modify in &command.modifies {
915                let Some(order) = Self::get_existing_order(&self.cache, modify) else {
916                    continue;
917                };
918
919                self.reject_modify_order(&order, reason);
920            }
921
922            return;
923        }
924
925        Self::send_to_execution(TradingCommand::ModifyOrders(command));
926    }
927
928    fn validate_batch_modify_orders(&self, command: &BatchModifyOrders) -> bool {
929        let mut rejected_client_order_ids = Vec::new();
930        let mut valid = true;
931
932        for modify in &command.modifies {
933            if !self.validate_batch_modify_order(command, modify) {
934                rejected_client_order_ids.push(modify.client_order_id);
935                valid = false;
936            }
937        }
938
939        if !valid {
940            let reason = "BatchModifyOrders rejected because one or more child modifications failed validation";
941
942            for modify in &command.modifies {
943                if rejected_client_order_ids.contains(&modify.client_order_id) {
944                    continue;
945                }
946
947                let Some(order) = Self::get_existing_order(&self.cache, modify) else {
948                    continue;
949                };
950
951                self.reject_modify_order(&order, reason);
952            }
953
954            return false;
955        }
956
957        true
958    }
959
960    fn validate_batch_modify_order(
961        &self,
962        command: &BatchModifyOrders,
963        modify: &ModifyOrder,
964    ) -> bool {
965        if modify.instrument_id != command.instrument_id {
966            let order = self
967                .cache
968                .borrow()
969                .order(&modify.client_order_id)
970                .map(|order| order.clone());
971
972            if let Some(order) = order {
973                self.reject_modify_order(
974                    &order,
975                    &format!(
976                        "BatchModifyOrders instrument {} does not match child instrument {}",
977                        command.instrument_id, modify.instrument_id
978                    ),
979                );
980            }
981
982            return false;
983        }
984
985        self.validate_modify_order(modify)
986    }
987
988    fn validate_modify_order(&self, command: &ModifyOrder) -> bool {
989        let order_exists = {
990            let cache = self.cache.borrow();
991            cache.order(&command.client_order_id).map(|o| o.clone())
992        };
993
994        let Some(order) = order_exists else {
995            log::error!(
996                "ModifyOrder DENIED: Order with command.client_order_id: {} not found",
997                command.client_order_id
998            );
999            return false;
1000        };
1001
1002        if order.is_closed() {
1003            self.reject_modify_order(
1004                &order,
1005                &format!(
1006                    "Order with command.client_order_id: {} already closed",
1007                    command.client_order_id
1008                ),
1009            );
1010            return false;
1011        } else if order.status() == OrderStatus::PendingCancel {
1012            self.reject_modify_order(
1013                &order,
1014                &format!(
1015                    "Order with command.client_order_id: {} is already pending cancel",
1016                    command.client_order_id
1017                ),
1018            );
1019            return false;
1020        }
1021
1022        let maybe_instrument = {
1023            let cache = self.cache.borrow();
1024            cache.instrument(&command.instrument_id).cloned()
1025        };
1026
1027        let Some(instrument) = maybe_instrument else {
1028            self.reject_modify_order(
1029                &order,
1030                &format!("no instrument found for {:?}", command.instrument_id),
1031            );
1032            return false;
1033        };
1034
1035        // Check Price
1036        let mut reason = Self::check_price(&instrument, command.price, OrderPriceField::Price);
1037        if let Some(reason) = reason {
1038            self.reject_modify_order(&order, &reason.to_string());
1039            return false;
1040        }
1041
1042        // Check Trigger
1043        reason = Self::check_price(
1044            &instrument,
1045            command.trigger_price,
1046            OrderPriceField::TriggerPrice,
1047        );
1048
1049        if let Some(reason) = reason {
1050            self.reject_modify_order(&order, &reason.to_string());
1051            return false;
1052        }
1053
1054        // Check Quantity
1055        reason = Self::check_quantity(
1056            &instrument,
1057            command.quantity,
1058            order.is_quote_quantity(),
1059            false,
1060        );
1061
1062        if let Some(reason) = reason {
1063            self.reject_modify_order(&order, &reason.to_string());
1064            return false;
1065        }
1066
1067        let state_reason = match self.trading_state {
1068            TradingState::Halted => Some(OrderDeniedReason::TradingHalted.to_string()),
1069            TradingState::Reducing => Some(
1070                OrderDeniedReason::TradingStateReducing {
1071                    order_side: order.order_side(),
1072                    instrument_id: instrument.id(),
1073                }
1074                .to_string(),
1075            ),
1076            TradingState::Active => None,
1077        };
1078
1079        if let Some(reason) = state_reason {
1080            self.reject_modify_order(&order, &reason);
1081            return false;
1082        }
1083
1084        true
1085    }
1086
1087    fn check_modify_orders_risk(
1088        &self,
1089        commands: &[ModifyOrder],
1090        client_id: Option<ClientId>,
1091    ) -> bool {
1092        let mut originals = Vec::with_capacity(commands.len());
1093        let mut orders = Vec::with_capacity(commands.len());
1094        let cache = self.cache.borrow();
1095        for command in commands {
1096            let Some(order) = cache.order(&command.client_order_id) else {
1097                return false;
1098            };
1099
1100            originals.push(order.clone());
1101            let mut projected = order.clone();
1102
1103            // Project values without applying a venue event or changing the cached order
1104            projected.update(&OrderUpdated::new(
1105                order.trader_id(),
1106                order.strategy_id(),
1107                order.instrument_id(),
1108                order.client_order_id(),
1109                command.quantity.unwrap_or(order.quantity()),
1110                command.command_id,
1111                command.ts_init,
1112                command.ts_init,
1113                false,
1114                order.venue_order_id(),
1115                order.account_id(),
1116                command.price.filter(|_| {
1117                    LIMIT_ORDER_TYPES.contains(&order.order_type())
1118                        || order.order_type() == OrderType::MarketToLimit
1119                }),
1120                command.trigger_price.filter(|_| {
1121                    STOP_ORDER_TYPES.contains(&order.order_type())
1122                        || matches!(
1123                            order.order_type(),
1124                            OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
1125                        )
1126                }),
1127                None,
1128                order.is_quote_quantity(),
1129            ));
1130
1131            orders.push(projected);
1132        }
1133
1134        let instrument = cache.instrument(&commands[0].instrument_id).cloned();
1135        drop(cache);
1136        let check = RiskCheck::Modify(&originals);
1137
1138        let Some(instrument) = instrument else {
1139            return false;
1140        };
1141
1142        self.check_orders_risk(&instrument, &orders, false, check, client_id)
1143            .is_some()
1144    }
1145
1146    fn check_order(
1147        &self,
1148        instrument: &InstrumentAny,
1149        order: &OrderAny,
1150        full_position_exit: bool,
1151    ) -> bool {
1152        if !self.check_order_price(instrument, order)
1153            || !self.check_order_quantity(instrument, order, full_position_exit)
1154        {
1155            return false; // Denied
1156        }
1157
1158        if order.time_in_force() == TimeInForce::Gtd {
1159            let Some(expire_time) = order.expire_time() else {
1160                self.deny_order(order, &OrderDeniedReason::MissingExpireTime.to_string());
1161                return false; // Denied
1162            };
1163
1164            if expire_time <= self.clock.borrow().timestamp_ns() {
1165                self.deny_order(
1166                    order,
1167                    &OrderDeniedReason::ExpireTimeInPast {
1168                        expire_time: expire_time.to_rfc3339(),
1169                    }
1170                    .to_string(),
1171                );
1172                return false; // Denied
1173            }
1174        }
1175
1176        true
1177    }
1178
1179    fn check_order_price(&self, instrument: &InstrumentAny, order: &OrderAny) -> bool {
1180        if order.price().is_some() {
1181            let reason = Self::check_price(instrument, order.price(), OrderPriceField::Price);
1182            if let Some(reason) = reason {
1183                self.deny_order(order, &reason.to_string());
1184                return false; // Denied
1185            }
1186        }
1187
1188        if order.trigger_price().is_some() {
1189            let reason = Self::check_price(
1190                instrument,
1191                order.trigger_price(),
1192                OrderPriceField::TriggerPrice,
1193            );
1194
1195            if let Some(reason) = reason {
1196                self.deny_order(order, &reason.to_string());
1197                return false; // Denied
1198            }
1199        }
1200
1201        true
1202    }
1203
1204    fn check_order_quantity(
1205        &self,
1206        instrument: &InstrumentAny,
1207        order: &OrderAny,
1208        full_position_exit: bool,
1209    ) -> bool {
1210        let reason = Self::check_quantity(
1211            instrument,
1212            Some(order.quantity()),
1213            order.is_quote_quantity(),
1214            full_position_exit,
1215        );
1216
1217        if let Some(reason) = reason {
1218            self.deny_order(order, &reason.to_string());
1219            return false; // Denied
1220        }
1221
1222        true
1223    }
1224
1225    fn check_orders_risk(
1226        &self,
1227        instrument: &InstrumentAny,
1228        orders: &[OrderAny],
1229        full_position_exit: bool,
1230        check: RiskCheck<'_>,
1231        client_id: Option<ClientId>,
1232    ) -> Option<Vec<Reservation>> {
1233        let venue = instrument.id().venue;
1234        let mut orders_by_account: AHashMap<Option<AccountId>, Vec<&OrderAny>> = AHashMap::new();
1235        for order in orders {
1236            orders_by_account
1237                .entry(self.order_account_id(order, client_id, venue))
1238                .or_default()
1239                .push(order);
1240        }
1241
1242        let mut reservations = Vec::new();
1243
1244        for (account_id, account_orders) in &orders_by_account {
1245            reservations.extend(self.check_orders_risk_for_account(
1246                instrument,
1247                account_orders,
1248                *account_id,
1249                client_id,
1250                full_position_exit,
1251                check,
1252            )?);
1253        }
1254
1255        Some(reservations)
1256    }
1257
1258    // Holds the free balance of orders sent for execution until their account reflects them,
1259    // replacing any earlier reservation of a re-checked order
1260    fn record_reservations(&mut self, reservations: Vec<Reservation>) {
1261        self.reservations.retain(|held| {
1262            reservations
1263                .iter()
1264                .all(|reservation| reservation.client_order_id != held.client_order_id)
1265        });
1266        self.reservations.extend(reservations);
1267    }
1268
1269    fn release_reservations(&mut self) {
1270        if self.reservations.is_empty() {
1271            return;
1272        }
1273
1274        let cache = self.cache.borrow();
1275        self.reservations.retain(|reservation| {
1276            cache
1277                .order(&reservation.client_order_id)
1278                .is_some_and(|order| !reservation.is_released(&order))
1279        });
1280    }
1281
1282    // Orders under check are valued again, so their own reservations are excluded
1283    fn unreflected_reservations(&self, account_id: AccountId, orders: &[&OrderAny]) -> Vec<Money> {
1284        self.reservations
1285            .iter()
1286            .filter(|reservation| {
1287                reservation.account_id == account_id
1288                    && orders
1289                        .iter()
1290                        .all(|order| order.client_order_id() != reservation.client_order_id)
1291            })
1292            .map(|reservation| reservation.amount)
1293            .collect()
1294    }
1295
1296    // An order without an assigned account uses the account of the client that command routing
1297    // selects: a registered command client, else the venue route or default client. An external
1298    // client, or a command no registered client handles, uses the single account issued under the
1299    // venue.
1300    fn order_account_id(
1301        &self,
1302        order: &OrderAny,
1303        client_id: Option<ClientId>,
1304        venue: Venue,
1305    ) -> Option<AccountId> {
1306        if let Some(account_id) = order.account_id() {
1307            return Some(account_id);
1308        }
1309
1310        let cache = self.cache.borrow();
1311
1312        let routed_account_id = || {
1313            cache
1314                .client_id_for_venue(&venue)
1315                .and_then(|client_id| cache.account_id_for_client(client_id))
1316        };
1317
1318        let account_id = match client_id {
1319            Some(client_id) if cache.is_external_exec_client(&client_id) => None,
1320            Some(client_id) => cache
1321                .account_id_for_client(&client_id)
1322                .or_else(routed_account_id),
1323            None => routed_account_id(),
1324        };
1325
1326        account_id.or_else(|| cache.account_id(&venue)).copied()
1327    }
1328
1329    #[allow(
1330        clippy::too_many_lines,
1331        reason = "risk checks keep related denial branches together for auditability"
1332    )]
1333    fn check_orders_risk_for_account(
1334        &self,
1335        instrument: &InstrumentAny,
1336        orders: &[&OrderAny],
1337        account_id: Option<AccountId>,
1338        client_id: Option<ClientId>,
1339        full_position_exit: bool,
1340        check: RiskCheck<'_>,
1341    ) -> Option<Vec<Reservation>> {
1342        let max_notional = match self.order_notional_limit(instrument) {
1343            Ok(limit) => limit,
1344            Err(reason) => {
1345                check.reject_orders(self, orders, &reason.to_string());
1346                return None;
1347            }
1348        };
1349
1350        let mut market_prices = Vec::with_capacity(orders.len());
1351
1352        for order in orders {
1353            let price = match order {
1354                OrderAny::Market(_) | OrderAny::MarketToLimit(_) => {
1355                    self.market_order_price(instrument.id(), order.order_side())
1356                }
1357                _ => None,
1358            };
1359
1360            market_prices.push(price);
1361        }
1362
1363        let resolved_account = account_id.and_then(|account_id| {
1364            self.cache
1365                .borrow()
1366                .account(&account_id)
1367                .map(|account| account.clone_without_events())
1368        });
1369
1370        let Some(account) = resolved_account else {
1371            check.reject_orders(
1372                self,
1373                orders,
1374                &OrderDeniedReason::ValidationFailed {
1375                    detail: format!(
1376                        "No account available for risk checks: instrument_id={}, client_id={client_id:?}, account_id={account_id:?}",
1377                        instrument.id()
1378                    ),
1379                }
1380                .to_string(),
1381            );
1382
1383            return None;
1384        };
1385
1386        let allow_borrowing = match &account {
1387            AccountAny::Cash(cash) => cash.allow_borrowing,
1388            AccountAny::Margin(_) | AccountAny::Betting(_) | AccountAny::Wallet(_) => false,
1389        };
1390
1391        let available_long_qty_raw = self.available_position_quantity(
1392            instrument.id(),
1393            account.id(),
1394            PositionSide::Long,
1395            OrderSide::Sell,
1396            check,
1397        );
1398
1399        let available_short_qty_raw =
1400            if matches!(account, AccountAny::Margin(_) | AccountAny::Betting(_)) {
1401                self.available_position_quantity(
1402                    instrument.id(),
1403                    account.id(),
1404                    PositionSide::Short,
1405                    OrderSide::Buy,
1406                    check,
1407                )
1408            } else {
1409                0
1410            };
1411
1412        // Amendments are checked against the account alone and stay venue-enforced
1413        let unreflected = match check {
1414            RiskCheck::Submit => self.unreflected_reservations(account.id(), orders),
1415            RiskCheck::Modify(_) => Vec::new(),
1416        };
1417
1418        let mut risk = AccountRisk {
1419            engine: self,
1420            instrument,
1421            account,
1422            check,
1423            full_position_exit,
1424            max_notional,
1425            allow_borrowing,
1426            available_long_qty_raw,
1427            available_short_qty_raw,
1428            cum_sell_qty_raw: 0,
1429            cum_buy_qty_raw: 0,
1430            cum_original_sell_qty_raw: 0,
1431            cum_original_buy_qty_raw: 0,
1432            cum_notional_buy: None,
1433            cum_notional_sell: None,
1434            cum_margin_required: None,
1435            unreflected,
1436            reservations: Vec::new(),
1437        };
1438
1439        for (&order, market_price) in orders.iter().zip(market_prices) {
1440            if !risk.check_order(order, market_price) {
1441                return None;
1442            }
1443        }
1444
1445        Some(risk.reservations)
1446    }
1447
1448    fn order_notional_limit(
1449        &self,
1450        instrument: &InstrumentAny,
1451    ) -> Result<Option<Money>, OrderDeniedReason> {
1452        let Some(value) = self.max_notional_per_order.get(&instrument.id()).copied() else {
1453            return Ok(None);
1454        };
1455
1456        Money::from_decimal(value, instrument.quote_currency())
1457            .map(Some)
1458            .map_err(|_| OrderDeniedReason::InvalidMaxNotionalPerOrder {
1459                instrument_id: instrument.id(),
1460                value,
1461            })
1462    }
1463
1464    fn available_position_quantity(
1465        &self,
1466        instrument_id: InstrumentId,
1467        account_id: AccountId,
1468        position_side: PositionSide,
1469        order_side: OrderSide,
1470        check: RiskCheck<'_>,
1471    ) -> QuantityRaw {
1472        let cache = self.cache.borrow();
1473        let position_quantity: QuantityRaw = cache
1474            .positions_open(
1475                None,
1476                Some(&instrument_id),
1477                None,
1478                Some(&account_id),
1479                Some(position_side),
1480            )
1481            .iter()
1482            .map(|position| position.quantity.raw())
1483            .sum();
1484        let pending_quantity: QuantityRaw = cache
1485            .orders_open(
1486                None,
1487                Some(&instrument_id),
1488                None,
1489                Some(&account_id),
1490                Some(order_side),
1491            )
1492            .iter()
1493            .filter(|order| check.original(order).is_none())
1494            .map(|order| order.leaves_qty().raw())
1495            .sum();
1496        let available = position_quantity.saturating_sub(pending_quantity);
1497
1498        if self.config.debug && position_quantity > 0 {
1499            log::debug!(
1500                "Net {position_side} qty (raw): {position_quantity}, pending {order_side}: {pending_quantity}, available: {available}"
1501            );
1502        }
1503
1504        available
1505    }
1506
1507    fn order_risk_quantity(
1508        &self,
1509        check: RiskCheck<'_>,
1510        instrument: &InstrumentAny,
1511        order: &OrderAny,
1512        quantity: Quantity,
1513        price: Price,
1514    ) -> Result<Quantity, ()> {
1515        if !order.is_quote_quantity() || instrument.is_inverse() {
1516            return Ok(quantity);
1517        }
1518
1519        let effective_price = if matches!(order, OrderAny::Limit(_) | OrderAny::StopLimit(_)) {
1520            self.cache
1521                .borrow()
1522                .quote(&instrument.id())
1523                .map_or(price, |quote| match order.order_side() {
1524                    OrderSide::Buy => price.min(quote.ask_price),
1525                    OrderSide::Sell => price.max(quote.bid_price),
1526                })
1527        } else {
1528            price
1529        };
1530
1531        instrument
1532            .try_calculate_base_quantity(quantity, effective_price)
1533            .map_err(|e| {
1534                check.reject(
1535                    self,
1536                    order,
1537                    &OrderDeniedReason::QuantityConversionFailed {
1538                        detail: e.to_string(),
1539                    }
1540                    .to_string(),
1541                );
1542            })
1543    }
1544
1545    fn check_risk_increase(
1546        &self,
1547        check: RiskCheck<'_>,
1548        order: &OrderAny,
1549        current: Money,
1550        previous: Money,
1551    ) -> Option<Money> {
1552        // A reduction cannot fund another amendment before the venue acknowledges it
1553        let increase = if current.currency == previous.currency {
1554            Money::from_decimal(
1555                (current.as_decimal().max(Decimal::ZERO)
1556                    - previous.as_decimal().max(Decimal::ZERO))
1557                .max(Decimal::ZERO),
1558                current.currency,
1559            )
1560            .ok()
1561        } else {
1562            None
1563        };
1564
1565        if increase.is_none() {
1566            check.reject(
1567                self,
1568                order,
1569                &OrderDeniedReason::NotionalCalculationFailed {
1570                    detail:
1571                        "amendment risk increase exceeds Money bounds or has incompatible currency"
1572                            .to_string(),
1573                }
1574                .to_string(),
1575            );
1576        }
1577
1578        increase
1579    }
1580
1581    fn market_order_price(
1582        &self,
1583        instrument_id: InstrumentId,
1584        order_side: OrderSide,
1585    ) -> Option<Price> {
1586        let price_type = match order_side {
1587            OrderSide::Buy => PriceType::Ask,
1588            OrderSide::Sell => PriceType::Bid,
1589        };
1590
1591        let cache = self.cache.borrow();
1592
1593        if let Some(price) = cache.price(&instrument_id, price_type) {
1594            return Some(price);
1595        }
1596
1597        if let Some(price) = cache.price(&instrument_id, PriceType::Last) {
1598            return Some(price);
1599        }
1600
1601        let bar_price = |price_type| {
1602            cache
1603                .bar_types(
1604                    Some(&instrument_id),
1605                    Some(&price_type),
1606                    AggregationSource::External,
1607                )
1608                .into_iter()
1609                .filter_map(|bar_type| {
1610                    cache
1611                        .bar(bar_type)
1612                        .map(|bar| (bar.ts_init, *bar_type, bar.close))
1613                })
1614                .max_by_key(|(ts_init, bar_type, _)| (*ts_init, *bar_type))
1615                .map(|(_, _, price)| price)
1616        };
1617
1618        bar_price(price_type).or_else(|| bar_price(PriceType::Last))
1619    }
1620
1621    #[allow(
1622        clippy::too_many_arguments,
1623        reason = "cash sell validation shares the account, cumulative exposure, and rejection context"
1624    )]
1625    fn check_cash_sell_balance(
1626        &self,
1627        check: RiskCheck<'_>,
1628        account: &dyn Account,
1629        allow_borrowing: bool,
1630        order: &OrderAny,
1631        quantity: Quantity,
1632        base_free: Money,
1633        cum_notional_sell: &mut Option<Money>,
1634    ) -> Result<Money, ()> {
1635        let base_currency = base_free.currency;
1636        let cash_value = match Money::from_quantity(quantity, base_currency) {
1637            Ok(value) => value,
1638            Err(e) => {
1639                check.reject(
1640                    self,
1641                    order,
1642                    &OrderDeniedReason::QuantityConversionFailed {
1643                        detail: e.to_string(),
1644                    }
1645                    .to_string(),
1646                );
1647
1648                return Err(());
1649            }
1650        };
1651
1652        if self.config.debug {
1653            log::debug!("Cash value: {cash_value:?}");
1654            log::debug!("Total: {:?}", account.balance_total(Some(base_currency)));
1655            log::debug!("Locked: {:?}", account.balance_locked(Some(base_currency)));
1656            log::debug!("Free: {base_free:?}");
1657        }
1658
1659        if !self.accumulate_notional(check, order, cum_notional_sell, cash_value) {
1660            return Err(());
1661        }
1662
1663        if self.config.debug {
1664            log::debug!("Cumulative notional SELL: {cum_notional_sell:?}");
1665        }
1666
1667        if !allow_borrowing
1668            && let Some(cum_notional_sell) = *cum_notional_sell
1669            && cum_notional_sell > base_free
1670        {
1671            check.reject(
1672                self,
1673                order,
1674                &OrderDeniedReason::CumulativeNotionalExceedsFreeBalance {
1675                    free_balance: base_free,
1676                    cumulative_notional: cum_notional_sell,
1677                }
1678                .to_string(),
1679            );
1680            return Err(());
1681        }
1682
1683        Ok(cash_value)
1684    }
1685
1686    fn accumulate_notional(
1687        &self,
1688        check: RiskCheck<'_>,
1689        order: &OrderAny,
1690        total: &mut Option<Money>,
1691        value: Money,
1692    ) -> bool {
1693        let next = match *total {
1694            Some(current) if current.currency == value.currency => current.checked_add(value),
1695            Some(_) => None,
1696            None => Some(value),
1697        };
1698
1699        let Some(next) = next else {
1700            check.reject(self,
1701                order,
1702                &OrderDeniedReason::NotionalCalculationFailed {
1703                    detail: "cumulative notional exceeds Money bounds or has incompatible currency or scale".to_string(),
1704                }
1705                .to_string(),
1706            );
1707
1708            return false;
1709        };
1710
1711        *total = Some(next);
1712        true
1713    }
1714
1715    fn deny_no_market_price(
1716        &self,
1717        instrument_id: InstrumentId,
1718        order: &OrderAny,
1719        check: RiskCheck<'_>,
1720    ) {
1721        check.reject(
1722            self,
1723            order,
1724            &OrderDeniedReason::MarketPriceUnavailable {
1725                order_type: order.order_type(),
1726                instrument_id,
1727            }
1728            .to_string(),
1729        );
1730    }
1731
1732    fn check_price(
1733        instrument: &InstrumentAny,
1734        price: Option<Price>,
1735        field: OrderPriceField,
1736    ) -> Option<OrderDeniedReason> {
1737        let price_val = price?;
1738
1739        if price_val.precision > instrument.price_precision() {
1740            return Some(OrderDeniedReason::PricePrecisionExceedsMaximum {
1741                field,
1742                price: price_val,
1743                price_precision: price_val.precision,
1744                max_precision: instrument.price_precision(),
1745            });
1746        }
1747
1748        if !instrument.allows_negative_price() && (price_val.is_zero() || price_val.is_negative()) {
1749            return Some(OrderDeniedReason::PriceNotPositive {
1750                field,
1751                price: price_val,
1752            });
1753        }
1754
1755        None
1756    }
1757
1758    fn check_quantity(
1759        instrument: &InstrumentAny,
1760        quantity: Option<Quantity>,
1761        is_quote_quantity: bool,
1762        full_position_exit: bool,
1763    ) -> Option<OrderDeniedReason> {
1764        let quantity_val = quantity?;
1765
1766        // Check precision
1767        if quantity_val.precision > instrument.size_precision() {
1768            return Some(OrderDeniedReason::QuantityPrecisionExceedsMaximum {
1769                quantity: quantity_val,
1770                quantity_precision: quantity_val.precision,
1771                max_precision: instrument.size_precision(),
1772            });
1773        }
1774
1775        // Base-quantity bounds do not apply to quote-denominated or validated whole-position
1776        // exits. Applicable quote-quantity notional limits are checked during account risk.
1777        if is_quote_quantity || full_position_exit {
1778            return None;
1779        }
1780
1781        // Check maximum quantity
1782        if let Some(max_quantity) = instrument.max_quantity()
1783            && quantity_val > max_quantity
1784        {
1785            return Some(OrderDeniedReason::QuantityExceedsMaximum {
1786                effective_quantity: quantity_val,
1787                max_quantity,
1788            });
1789        }
1790
1791        // Check minimum quantity
1792        if let Some(min_quantity) = instrument.min_quantity()
1793            && quantity_val < min_quantity
1794        {
1795            return Some(OrderDeniedReason::QuantityBelowMinimum {
1796                effective_quantity: quantity_val,
1797                min_quantity,
1798            });
1799        }
1800
1801        None
1802    }
1803
1804    fn deny_command(&self, command: TradingCommand, reason: &str) {
1805        match command {
1806            TradingCommand::SubmitOrder(command) => {
1807                let order = {
1808                    let cache = self.cache.borrow();
1809                    cache.order(&command.client_order_id).map(|o| o.clone())
1810                };
1811
1812                if let Some(ref order) = order {
1813                    self.deny_order(order, reason);
1814                } else {
1815                    log::error!(
1816                        "Cannot deny order: not found in cache for {}",
1817                        command.client_order_id
1818                    );
1819                }
1820            }
1821            TradingCommand::SubmitOrderList(command) => {
1822                let orders: Vec<OrderAny> = self
1823                    .cache
1824                    .borrow()
1825                    .orders_for_ids(&command.order_list.client_order_ids, &command);
1826                self.deny_order_list(&orders, reason);
1827            }
1828            _ => {
1829                log::error!("Cannot deny command {command}");
1830            }
1831        }
1832    }
1833
1834    fn deny_order(&self, order: &OrderAny, reason: &str) {
1835        log::warn!(
1836            "SubmitOrder for {} DENIED: {}",
1837            order.client_order_id(),
1838            reason
1839        );
1840
1841        if !matches!(
1842            order.status(),
1843            OrderStatus::Initialized | OrderStatus::Released
1844        ) {
1845            return;
1846        }
1847
1848        // Scope the cache borrow to avoid RefCell conflict when sending to ExecEngine
1849        {
1850            let mut cache = self.cache.borrow_mut();
1851            if cache.is_order_dispatched(&order.client_order_id()) {
1852                return;
1853            }
1854
1855            if !cache.order_exists(&order.client_order_id())
1856                && let Err(e) = cache.add_order(order.clone(), None, None, false)
1857            {
1858                log::error!("Cannot add order to cache: {e}");
1859                return;
1860            }
1861        }
1862
1863        let denied = OrderEventAny::Denied(OrderDenied::new(
1864            order.trader_id(),
1865            order.strategy_id(),
1866            order.instrument_id(),
1867            order.client_order_id(),
1868            reason.into(),
1869            UUID4::new(),
1870            self.clock.borrow().timestamp_ns(),
1871            self.clock.borrow().timestamp_ns(),
1872        ));
1873
1874        let endpoint = MessagingSwitchboard::exec_engine_process();
1875        msgbus::send_order_event(endpoint, denied);
1876    }
1877
1878    fn deny_order_list(&self, orders: &[OrderAny], reason: &str) {
1879        for order in orders {
1880            if !order.is_closed() {
1881                self.deny_order(order, reason);
1882            }
1883        }
1884    }
1885
1886    fn reject_modify_order(&self, order: &OrderAny, reason: &str) {
1887        let ts_event = self.clock.borrow().timestamp_ns();
1888        let denied = OrderEventAny::ModifyRejected(OrderModifyRejected::new(
1889            order.trader_id(),
1890            order.strategy_id(),
1891            order.instrument_id(),
1892            order.client_order_id(),
1893            reason.into(),
1894            UUID4::new(),
1895            ts_event,
1896            ts_event,
1897            false,
1898            order.venue_order_id(),
1899            order.account_id(),
1900        ));
1901
1902        let endpoint = MessagingSwitchboard::exec_engine_process();
1903        msgbus::send_order_event(endpoint, denied);
1904    }
1905
1906    fn execution_gateway(&mut self, command: TradingCommand, reservations: Vec<Reservation>) {
1907        match self.trading_state {
1908            TradingState::Halted => match command {
1909                TradingCommand::SubmitOrder(submit_order) => {
1910                    let order = {
1911                        let cache = self.cache.borrow();
1912                        cache
1913                            .order(&submit_order.client_order_id)
1914                            .map(|order| order.clone())
1915                    };
1916
1917                    if let Some(order) = order {
1918                        self.deny_order(&order, &OrderDeniedReason::TradingHalted.to_string());
1919                    }
1920                }
1921                TradingCommand::SubmitOrderList(submit_order_list) => {
1922                    let orders: Vec<OrderAny> = self.cache.borrow().orders_for_ids(
1923                        &submit_order_list.order_list.client_order_ids,
1924                        &submit_order_list,
1925                    );
1926                    self.deny_order_list(&orders, &OrderDeniedReason::TradingHalted.to_string());
1927                }
1928                _ => {}
1929            },
1930            TradingState::Reducing => match command {
1931                TradingCommand::SubmitOrder(submit_order) => {
1932                    let order = {
1933                        let cache = self.cache.borrow();
1934                        cache
1935                            .order(&submit_order.client_order_id)
1936                            .map(|order| order.clone())
1937                    };
1938                    let Some(order) = order else {
1939                        return;
1940                    };
1941
1942                    if self.is_reducing_submission(&submit_order, &order) {
1943                        self.record_reservations(reservations);
1944                        self.throttler_submit
1945                            .send(TradingCommand::SubmitOrder(submit_order));
1946                    } else {
1947                        self.deny_order(
1948                            &order,
1949                            &OrderDeniedReason::TradingStateReducing {
1950                                order_side: order.order_side(),
1951                                instrument_id: order.instrument_id(),
1952                            }
1953                            .to_string(),
1954                        );
1955                    }
1956                }
1957                TradingCommand::SubmitOrderList(submit_order_list) => {
1958                    let orders: Vec<OrderAny> = self.cache.borrow().orders_for_ids(
1959                        &submit_order_list.order_list.client_order_ids,
1960                        &submit_order_list,
1961                    );
1962
1963                    for order in &orders {
1964                        self.deny_order(
1965                            order,
1966                            &OrderDeniedReason::TradingStateReducing {
1967                                order_side: order.order_side(),
1968                                instrument_id: order.instrument_id(),
1969                            }
1970                            .to_string(),
1971                        );
1972                    }
1973                }
1974                _ => {}
1975            },
1976            TradingState::Active => match command {
1977                TradingCommand::SubmitOrder(_) | TradingCommand::SubmitOrderList(_) => {
1978                    self.record_reservations(reservations);
1979                    self.throttler_submit.send(command);
1980                }
1981                _ => {}
1982            },
1983        }
1984    }
1985
1986    fn send_to_execution(command: TradingCommand) {
1987        let endpoint = MessagingSwitchboard::exec_engine_queue_execute();
1988        msgbus::send_trading_command(endpoint, command);
1989    }
1990
1991    fn handle_event(&self, event: &OrderEventAny) {
1992        // We intend to extend the risk engine to be able to handle additional events.
1993        // For now we just log.
1994        if self.config.debug {
1995            log::debug!("{RECV}{EVT} {event}");
1996        }
1997    }
1998
1999    fn handle_position_event(&self, event: &PositionEvent) {
2000        if self.config.debug {
2001            log::debug!("{RECV}{EVT} {event:?}");
2002        }
2003    }
2004}
2005
2006// Free balance an approved order uses until its account reflects the order
2007struct Reservation {
2008    client_order_id: ClientOrderId,
2009    account_id: AccountId,
2010    amount: Money,
2011    wallet: bool,
2012}
2013
2014impl Reservation {
2015    // Accounts reflect open orders, and wallet accounts also in-flight orders, in their locked
2016    // balances. A closed order no longer uses free balance.
2017    fn is_released(&self, order: &OrderAny) -> bool {
2018        order.is_open() || order.is_closed() || (self.wallet && order.is_inflight())
2019    }
2020}
2021
2022#[derive(Clone, Copy)]
2023enum RiskCheck<'a> {
2024    Submit,
2025    Modify(&'a [OrderAny]),
2026}
2027
2028impl<'a> RiskCheck<'a> {
2029    fn reject_orders(self, engine: &RiskEngine, orders: &[&OrderAny], reason: &str) {
2030        for order in orders {
2031            self.reject(engine, order, reason);
2032
2033            if matches!(self, Self::Modify(_)) {
2034                break;
2035            }
2036        }
2037    }
2038
2039    fn reject(self, engine: &RiskEngine, order: &OrderAny, reason: &str) {
2040        match self {
2041            Self::Submit => engine.deny_order(order, reason),
2042            Self::Modify(originals) => {
2043                for (index, original) in originals.iter().enumerate() {
2044                    if originals[..index]
2045                        .iter()
2046                        .any(|previous| previous.client_order_id() == original.client_order_id())
2047                    {
2048                        continue;
2049                    }
2050
2051                    engine.reject_modify_order(original, reason);
2052                }
2053            }
2054        }
2055    }
2056
2057    fn original(self, order: &OrderAny) -> Option<&'a OrderAny> {
2058        match self {
2059            Self::Submit => None,
2060            Self::Modify(originals) => originals
2061                .iter()
2062                .find(|original| original.client_order_id() == order.client_order_id()),
2063        }
2064    }
2065}
2066
2067struct AccountRisk<'a> {
2068    engine: &'a RiskEngine,
2069    instrument: &'a InstrumentAny,
2070    account: AccountAny,
2071    check: RiskCheck<'a>,
2072    full_position_exit: bool,
2073    max_notional: Option<Money>,
2074    allow_borrowing: bool,
2075    available_long_qty_raw: QuantityRaw,
2076    available_short_qty_raw: QuantityRaw,
2077    cum_sell_qty_raw: QuantityRaw,
2078    cum_buy_qty_raw: QuantityRaw,
2079    cum_original_sell_qty_raw: QuantityRaw,
2080    cum_original_buy_qty_raw: QuantityRaw,
2081    cum_notional_buy: Option<Money>,
2082    cum_notional_sell: Option<Money>,
2083    cum_margin_required: Option<Money>,
2084    unreflected: Vec<Money>,
2085    reservations: Vec<Reservation>,
2086}
2087
2088impl AccountRisk<'_> {
2089    // Orders approved by earlier commands still use free balance until the account reflects them
2090    fn balance_free(&self, order: &OrderAny, currency: Currency) -> Result<Money, ()> {
2091        let free = self
2092            .account
2093            .balance_free(Some(currency))
2094            .unwrap_or_else(|| Money::zero(currency));
2095
2096        self.unreflected
2097            .iter()
2098            .filter(|amount| amount.currency == currency)
2099            .try_fold(free, |free, amount| self.deduct_reservation(free, *amount))
2100            .map_err(|detail| {
2101                self.check.reject(
2102                    self.engine,
2103                    order,
2104                    &OrderDeniedReason::NotionalCalculationFailed { detail }.to_string(),
2105                );
2106            })
2107    }
2108
2109    // Wallet reservations are held at the observed balance precision, and like the wallet's own
2110    // locks they cannot be deducted once the observed precision has changed
2111    fn deduct_reservation(&self, free: Money, amount: Money) -> Result<Money, String> {
2112        if matches!(self.account, AccountAny::Wallet(_))
2113            && amount.currency.precision != free.currency.precision
2114        {
2115            return Err(format!(
2116                "reserved balance precision {} differed from balance precision {} for {}",
2117                amount.currency.precision, free.currency.precision, free.currency
2118            ));
2119        }
2120
2121        free.checked_sub(amount).ok_or_else(|| {
2122            "reserved balance exceeds Money bounds or has incompatible scale".to_string()
2123        })
2124    }
2125
2126    fn reserve_balance(&mut self, order: &OrderAny, amount: Money) {
2127        if amount.is_positive() {
2128            self.reservations.push(Reservation {
2129                client_order_id: order.client_order_id(),
2130                account_id: self.account.id(),
2131                amount,
2132                wallet: matches!(self.account, AccountAny::Wallet(_)),
2133            });
2134        }
2135    }
2136
2137    fn check_order(&mut self, order: &OrderAny, market_price: Option<Price>) -> bool {
2138        let Ok(last_px) = self.order_price(order, market_price) else {
2139            return false;
2140        };
2141
2142        let Some(last_px) = last_px else {
2143            self.engine
2144                .deny_no_market_price(self.instrument.id(), order, self.check);
2145            return false;
2146        };
2147
2148        let Ok(effective_quantity) = self.engine.order_risk_quantity(
2149            self.check,
2150            self.instrument,
2151            order,
2152            order.quantity(),
2153            last_px,
2154        ) else {
2155            return false;
2156        };
2157
2158        if !self.check_order_limits(order, effective_quantity, last_px) {
2159            return false;
2160        }
2161
2162        // Caps apply to total size, but only unfilled exposure needs funds on amendment
2163        let effective_quantity = if matches!(self.check, RiskCheck::Modify(_)) {
2164            let Ok(quantity) = self.engine.order_risk_quantity(
2165                self.check,
2166                self.instrument,
2167                order,
2168                order.leaves_qty(),
2169                last_px,
2170            ) else {
2171                return false;
2172            };
2173
2174            quantity
2175        } else {
2176            effective_quantity
2177        };
2178
2179        let Ok(original) = self.original_exposure(order, market_price) else {
2180            return false;
2181        };
2182
2183        // Pending reductions cannot release closing capacity for other amendments
2184        let reserved_quantity = original.map_or(effective_quantity.raw(), |(_, quantity, _)| {
2185            quantity.raw().max(effective_quantity.raw())
2186        });
2187
2188        if matches!(self.account, AccountAny::Margin(_)) {
2189            return self.check_margin(
2190                order,
2191                effective_quantity,
2192                last_px,
2193                original,
2194                reserved_quantity,
2195            );
2196        }
2197
2198        self.check_balance(
2199            order,
2200            effective_quantity,
2201            last_px,
2202            original,
2203            reserved_quantity,
2204        )
2205    }
2206
2207    fn original_exposure(
2208        &mut self,
2209        order: &OrderAny,
2210        market_price: Option<Price>,
2211    ) -> Result<Option<(Price, Quantity, bool)>, ()> {
2212        let Some(original) = self.check.original(order).filter(|original| {
2213            original.is_open()
2214                || (matches!(self.account, AccountAny::Wallet(_)) && original.is_inflight())
2215        }) else {
2216            return Ok(None);
2217        };
2218
2219        let original_price = match self.order_price(original, market_price) {
2220            Ok(Some(price)) => price,
2221            Ok(None) => {
2222                self.engine
2223                    .deny_no_market_price(self.instrument.id(), order, self.check);
2224                return Err(());
2225            }
2226            Err(()) => return Err(()),
2227        };
2228
2229        let quantity = self.engine.order_risk_quantity(
2230            self.check,
2231            self.instrument,
2232            original,
2233            original.leaves_qty(),
2234            original_price,
2235        )?;
2236        let is_reducing = !matches!(self.account, AccountAny::Wallet(_))
2237            && ((original.is_reduce_only()
2238                && (matches!(self.account, AccountAny::Margin(_)) || original.is_sell()))
2239                || (original.is_sell()
2240                    && self.cum_original_sell_qty_raw + quantity.raw()
2241                        <= self.available_long_qty_raw)
2242                || (original.is_buy()
2243                    && self.cum_original_buy_qty_raw + quantity.raw()
2244                        <= self.available_short_qty_raw));
2245
2246        if original.is_sell() {
2247            self.cum_original_sell_qty_raw += quantity.raw();
2248        } else {
2249            self.cum_original_buy_qty_raw += quantity.raw();
2250        }
2251
2252        Ok(Some((original_price, quantity, is_reducing)))
2253    }
2254
2255    fn order_price(
2256        &mut self,
2257        order: &OrderAny,
2258        market_price: Option<Price>,
2259    ) -> Result<Option<Price>, ()> {
2260        match order {
2261            OrderAny::MarketToLimit(_) if order.price().is_some() => Ok(order.price()),
2262            OrderAny::Market(_) | OrderAny::MarketToLimit(_) => {
2263                let Some(price) = market_price else {
2264                    let is_reducing = !matches!(self.account, AccountAny::Wallet(_))
2265                        && (order.is_reduce_only()
2266                            || (order.is_sell()
2267                                && (self.cum_sell_qty_raw + order.quantity().raw())
2268                                    <= self.available_long_qty_raw));
2269
2270                    if !order.is_quote_quantity()
2271                        && order.is_sell()
2272                        && !is_reducing
2273                        && let Some(unleveraged) = cash_or_wallet_account(&self.account)
2274                        && unleveraged.base_currency().is_none()
2275                        && let Some(base_currency) = self.instrument.base_currency()
2276                    {
2277                        let base_free = self.balance_free(order, base_currency)?;
2278                        self.engine.check_cash_sell_balance(
2279                            self.check,
2280                            unleveraged,
2281                            self.allow_borrowing,
2282                            order,
2283                            order.quantity(),
2284                            base_free,
2285                            &mut self.cum_notional_sell,
2286                        )?;
2287                    }
2288
2289                    self.engine
2290                        .deny_no_market_price(self.instrument.id(), order, self.check);
2291                    return Err(());
2292                };
2293
2294                Ok(Some(price))
2295            }
2296            OrderAny::StopMarket(_) | OrderAny::MarketIfTouched(_) => Ok(order.trigger_price()),
2297            OrderAny::TrailingStopMarket(_) | OrderAny::TrailingStopLimit(_) => {
2298                self.trailing_order_price(order)
2299            }
2300            _ => Ok(order.price()),
2301        }
2302    }
2303
2304    fn trailing_order_price(&self, order: &OrderAny) -> Result<Option<Price>, ()> {
2305        if let Some(price) = order.trigger_price() {
2306            return Ok(order.price().or(Some(price)));
2307        }
2308
2309        // Validate trailing offset type is supported
2310        let Some(offset_type) = order.trailing_offset_type() else {
2311            self.check.reject(
2312                self.engine,
2313                order,
2314                &OrderDeniedReason::MissingTrailingOffsetType.to_string(),
2315            );
2316            return Err(()); // Denied
2317        };
2318
2319        if !matches!(
2320            offset_type,
2321            TrailingOffsetType::Price | TrailingOffsetType::BasisPoints | TrailingOffsetType::Ticks
2322        ) {
2323            self.check.reject(
2324                self.engine,
2325                order,
2326                &OrderDeniedReason::UnsupportedTrailingOffsetType { offset_type }.to_string(),
2327            );
2328            return Err(());
2329        }
2330
2331        let Some(trigger_type) = order.trigger_type() else {
2332            self.check.reject(
2333                self.engine,
2334                order,
2335                &OrderDeniedReason::MissingTriggerType.to_string(),
2336            );
2337            return Err(()); // Denied
2338        };
2339
2340        let Some(trailing_offset) = order.trailing_offset() else {
2341            self.check.reject(
2342                self.engine,
2343                order,
2344                &OrderDeniedReason::MissingTrailingOffset.to_string(),
2345            );
2346            return Err(()); // Denied
2347        };
2348
2349        if let Some(price) = order.price() {
2350            return Ok(Some(price));
2351        }
2352
2353        // Release the cache borrow before publishing a rejection
2354        self.calculate_trailing_price(order, offset_type, trigger_type, trailing_offset)
2355            .map_err(|detail| {
2356                self.check.reject(
2357                    self.engine,
2358                    order,
2359                    &OrderDeniedReason::TrailingStopCalculationFailed { detail }.to_string(),
2360                );
2361            })
2362    }
2363
2364    fn calculate_trailing_price(
2365        &self,
2366        order: &OrderAny,
2367        offset_type: TrailingOffsetType,
2368        trigger_type: TriggerType,
2369        trailing_offset: Decimal,
2370    ) -> Result<Option<Price>, String> {
2371        let cache = self.engine.cache.borrow();
2372        if trigger_type != TriggerType::BidAsk
2373            && let Some(trade) = cache.trade(&self.instrument.id())
2374        {
2375            return trailing_stop_calculate_with_last(
2376                self.instrument.price_increment(),
2377                offset_type,
2378                order.order_side(),
2379                trailing_offset,
2380                trade.price,
2381            )
2382            .map(Some)
2383            .map_err(|e| e.to_string());
2384        }
2385
2386        if matches!(
2387            trigger_type,
2388            TriggerType::BidAsk | TriggerType::LastOrBidAsk
2389        ) && let Some(quote) = cache.quote(&self.instrument.id())
2390        {
2391            return trailing_stop_calculate_with_bid_ask(
2392                self.instrument.price_increment(),
2393                offset_type,
2394                order.order_side(),
2395                trailing_offset,
2396                quote.bid_price,
2397                quote.ask_price,
2398            )
2399            .map(Some)
2400            .map_err(|e| e.to_string());
2401        }
2402
2403        if trigger_type == TriggerType::BidAsk {
2404            log::warn!(
2405                "Cannot check {} order risk: no trigger price set and no bid/ask quotes available for {}",
2406                order.order_type(),
2407                self.instrument.id()
2408            );
2409        } else {
2410            log::warn!(
2411                "Cannot check {} order risk: no trigger price set and no market data available for {}",
2412                order.order_type(),
2413                self.instrument.id()
2414            );
2415        }
2416
2417        Ok(None)
2418    }
2419
2420    fn check_order_limits(
2421        &self,
2422        order: &OrderAny,
2423        effective_quantity: Quantity,
2424        last_px: Price,
2425    ) -> bool {
2426        // Base-quantity bounds (`min_quantity`/`max_quantity`) do not apply to
2427        // quote-denominated orders: the client-side conversion uses an estimated
2428        // price and may differ from the venue fill, and some venues enforce
2429        // distinct per-order-type minimums. The venue is authoritative for
2430        // quote-denominated sizing; rely on `min_notional`/`max_notional` below.
2431        if !order.is_quote_quantity() && !self.full_position_exit {
2432            if let Some(max_quantity) = self.instrument.max_quantity()
2433                && effective_quantity > max_quantity
2434            {
2435                self.check.reject(
2436                    self.engine,
2437                    order,
2438                    &OrderDeniedReason::QuantityExceedsMaximum {
2439                        effective_quantity,
2440                        max_quantity,
2441                    }
2442                    .to_string(),
2443                );
2444
2445                return false; // Denied
2446            }
2447
2448            if let Some(min_quantity) = self.instrument.min_quantity()
2449                && effective_quantity < min_quantity
2450            {
2451                self.check.reject(
2452                    self.engine,
2453                    order,
2454                    &OrderDeniedReason::QuantityBelowMinimum {
2455                        effective_quantity,
2456                        min_quantity,
2457                    }
2458                    .to_string(),
2459                );
2460
2461                return false; // Denied
2462            }
2463        }
2464
2465        // Limits apply to exposure, so a negative price must not reduce the notional
2466        let notional = match self.instrument.try_calculate_notional_value(
2467            effective_quantity,
2468            last_px,
2469            Some(true),
2470        ) {
2471            Ok(notional) => notional.abs(),
2472            Err(e) => {
2473                self.check.reject(
2474                    self.engine,
2475                    order,
2476                    &OrderDeniedReason::NotionalCalculationFailed {
2477                        detail: e.to_string(),
2478                    }
2479                    .to_string(),
2480                );
2481
2482                return false;
2483            }
2484        };
2485
2486        if self.engine.config.debug {
2487            log::debug!("Notional: {notional:?}");
2488        }
2489
2490        // Check MAX notional per order limit
2491        if !self.full_position_exit
2492            && let Some(max_notional_value) = self.max_notional
2493            && notional > max_notional_value
2494        {
2495            self.check.reject(
2496                self.engine,
2497                order,
2498                &OrderDeniedReason::NotionalExceedsMaxPerOrder {
2499                    max_notional: max_notional_value,
2500                    notional,
2501                }
2502                .to_string(),
2503            );
2504
2505            return false; // Denied
2506        }
2507
2508        // Whole-position and reduce-only orders may close residual positions below the
2509        // venue minimum
2510        if !order.is_reduce_only()
2511            && !self.full_position_exit
2512            && let Some(min_notional) = self.instrument.min_notional()
2513            && notional.currency == min_notional.currency
2514            && notional < min_notional
2515        {
2516            self.check.reject(
2517                self.engine,
2518                order,
2519                &OrderDeniedReason::NotionalBelowMinimum {
2520                    min_notional,
2521                    notional,
2522                }
2523                .to_string(),
2524            );
2525
2526            return false; // Denied
2527        }
2528
2529        // Check MAX notional instrument limit
2530        if !self.full_position_exit
2531            && let Some(max_notional) = self.instrument.max_notional()
2532            && notional.currency == max_notional.currency
2533            && notional > max_notional
2534        {
2535            self.check.reject(
2536                self.engine,
2537                order,
2538                &OrderDeniedReason::NotionalExceedsMaximum {
2539                    max_notional,
2540                    notional,
2541                }
2542                .to_string(),
2543            );
2544
2545            return false; // Denied
2546        }
2547
2548        true
2549    }
2550
2551    fn check_margin(
2552        &mut self,
2553        order: &OrderAny,
2554        quantity: Quantity,
2555        price: Price,
2556        original: Option<(Price, Quantity, bool)>,
2557        reserved_quantity: QuantityRaw,
2558    ) -> bool {
2559        let Ok(required) = self.initial_margin(order, quantity, price) else {
2560            return false;
2561        };
2562
2563        if self.engine.config.debug {
2564            log::debug!("Initial margin required: {required}");
2565        }
2566
2567        if self.reserve_position(order, quantity, reserved_quantity) {
2568            if self.engine.config.debug {
2569                log::debug!("Position-reducing order skips margin check");
2570            }
2571
2572            return true;
2573        }
2574
2575        let Ok(required) = self.margin_increase(order, required, original) else {
2576            return false;
2577        };
2578
2579        self.check_margin_balance(order, required)
2580    }
2581
2582    fn margin_increase(
2583        &mut self,
2584        order: &OrderAny,
2585        required: Money,
2586        original: Option<(Price, Quantity, bool)>,
2587    ) -> Result<Money, ()> {
2588        let Some((price, quantity, was_reducing)) = original else {
2589            return Ok(required);
2590        };
2591
2592        let previous = if was_reducing {
2593            Money::zero(required.currency)
2594        } else {
2595            self.initial_margin(order, quantity, price)?
2596        };
2597
2598        self.engine
2599            .check_risk_increase(self.check, order, required, previous)
2600            .ok_or(())
2601    }
2602
2603    fn initial_margin(
2604        &mut self,
2605        order: &OrderAny,
2606        quantity: Quantity,
2607        price: Price,
2608    ) -> Result<Money, ()> {
2609        let AccountAny::Margin(margin) = &mut self.account else {
2610            unreachable!()
2611        };
2612
2613        margin
2614            .calculate_initial_margin(self.instrument, quantity, price, None)
2615            .map_err(|e| {
2616                self.check.reject(
2617                    self.engine,
2618                    order,
2619                    &OrderDeniedReason::InitialMarginCalculationFailed {
2620                        detail: e.to_string(),
2621                    }
2622                    .to_string(),
2623                );
2624            })
2625    }
2626
2627    fn check_margin_balance(&mut self, order: &OrderAny, required: Money) -> bool {
2628        if matches!(self.check, RiskCheck::Modify(_)) && required.is_zero() {
2629            return true;
2630        }
2631
2632        let Ok(required) = self.account_currency_amount(order, required) else {
2633            return false;
2634        };
2635
2636        // Inverse instruments can require collateral in the base currency
2637        let Ok(free) = self.balance_free(order, required.currency) else {
2638            return false;
2639        };
2640
2641        if required > free {
2642            self.check.reject(
2643                self.engine,
2644                order,
2645                &OrderDeniedReason::InitialMarginExceedsFreeBalance {
2646                    free_balance: free,
2647                    initial_margin: required,
2648                }
2649                .to_string(),
2650            );
2651
2652            return false;
2653        }
2654
2655        let total = match self.cum_margin_required {
2656            Some(total) => total.checked_add(required),
2657            None => Some(required),
2658        };
2659
2660        let Some(total) = total else {
2661            self.check.reject(
2662                self.engine,
2663                order,
2664                &OrderDeniedReason::CumulativeInitialMarginCalculationFailed {
2665                    detail: "total exceeds Money bounds".to_string(),
2666                }
2667                .to_string(),
2668            );
2669
2670            return false;
2671        };
2672
2673        self.cum_margin_required = Some(total);
2674
2675        if self.engine.config.debug {
2676            log::debug!("Cumulative margin required: {:?}", self.cum_margin_required);
2677        }
2678
2679        if total > free {
2680            self.check.reject(
2681                self.engine,
2682                order,
2683                &OrderDeniedReason::CumulativeInitialMarginExceedsFreeBalance {
2684                    free_balance: free,
2685                    cumulative_initial_margin: total,
2686                }
2687                .to_string(),
2688            );
2689
2690            return false;
2691        }
2692
2693        self.reserve_balance(order, required);
2694        true
2695    }
2696
2697    fn check_balance(
2698        &mut self,
2699        order: &OrderAny,
2700        quantity: Quantity,
2701        price: Price,
2702        original: Option<(Price, Quantity, bool)>,
2703        reserved_quantity: QuantityRaw,
2704    ) -> bool {
2705        let Ok((notional, impact)) = self.balance_impact(order, quantity, price) else {
2706            return false;
2707        };
2708
2709        if self.engine.config.debug {
2710            log::debug!("Balance impact: {impact}");
2711        }
2712
2713        if self.reserve_position(order, quantity, reserved_quantity) {
2714            if self.engine.config.debug {
2715                log::debug!("Position-reducing order skips balance check");
2716            }
2717
2718            return true;
2719        }
2720
2721        let Ok(impact) = self.balance_increase(order, impact, original) else {
2722            return false;
2723        };
2724
2725        let is_debit = order.is_buy() || matches!(self.account, AccountAny::Betting(_));
2726        if matches!(self.check, RiskCheck::Modify(_))
2727            && impact.is_zero()
2728            && (is_debit || self.account.base_currency().is_some())
2729        {
2730            return true;
2731        }
2732
2733        let Ok(impact) = self.account_currency_amount(order, impact) else {
2734            return false;
2735        };
2736
2737        let Ok(free) = self.balance_free(order, impact.currency) else {
2738            return false;
2739        };
2740
2741        if !self.allow_borrowing && free.as_decimal() + impact.as_decimal() < Decimal::ZERO {
2742            self.check.reject(
2743                self.engine,
2744                order,
2745                &OrderDeniedReason::NotionalExceedsFreeBalance {
2746                    free_balance: free,
2747                    notional,
2748                }
2749                .to_string(),
2750            );
2751
2752            return false;
2753        }
2754
2755        if is_debit {
2756            return self.check_cumulative_balance(order, -impact);
2757        }
2758
2759        // Without an instrument base currency there is no asset to check, so fall through to
2760        // `check_asset_balance` rather than comparing sale proceeds against free collateral.
2761        if self.instrument.base_currency().is_some() && self.account.base_currency().is_some() {
2762            return self.check_cumulative_balance(order, impact);
2763        }
2764
2765        self.check_asset_balance(order, quantity, original)
2766    }
2767
2768    fn reserve_position(
2769        &mut self,
2770        order: &OrderAny,
2771        quantity: Quantity,
2772        reserved: QuantityRaw,
2773    ) -> bool {
2774        let (cumulative, available) = match order.order_side() {
2775            OrderSide::Buy => (&mut self.cum_buy_qty_raw, self.available_short_qty_raw),
2776            OrderSide::Sell => (&mut self.cum_sell_qty_raw, self.available_long_qty_raw),
2777        };
2778
2779        let reducing = self.full_position_exit
2780            || (order.is_reduce_only()
2781                && (matches!(self.account, AccountAny::Margin(_)) || order.is_sell()))
2782            || *cumulative + quantity.raw() <= available;
2783        *cumulative += reserved;
2784        reducing && !matches!(self.account, AccountAny::Wallet(_))
2785    }
2786
2787    fn balance_impact(
2788        &mut self,
2789        order: &OrderAny,
2790        quantity: Quantity,
2791        price: Price,
2792    ) -> Result<(Money, Money), ()> {
2793        let notional = self
2794            .instrument
2795            .try_calculate_notional_value(quantity, price, None)
2796            .map_err(|e| {
2797                self.check.reject(
2798                    self.engine,
2799                    order,
2800                    &OrderDeniedReason::NotionalCalculationFailed {
2801                        detail: e.to_string(),
2802                    }
2803                    .to_string(),
2804                );
2805            })?;
2806
2807        let impact = if let AccountAny::Betting(betting) = &mut self.account {
2808            -betting
2809                .calculate_balance_locked(
2810                    self.instrument,
2811                    order.order_side(),
2812                    quantity,
2813                    price,
2814                    None,
2815                )
2816                .map_err(|e| {
2817                    self.check.reject(
2818                        self.engine,
2819                        order,
2820                        &OrderDeniedReason::BettingBalanceLockedCalculationFailed {
2821                            detail: e.to_string(),
2822                        }
2823                        .to_string(),
2824                    );
2825                })?
2826        } else if let Some(funding) =
2827            self.wallet_buy_funding(order, quantity, price, notional.currency)
2828        {
2829            let funding = funding.map_err(|detail| {
2830                self.check.reject(
2831                    self.engine,
2832                    order,
2833                    &OrderDeniedReason::NotionalCalculationFailed { detail }.to_string(),
2834                );
2835            })?;
2836
2837            // Denials report the amount checked against free balance
2838            return Ok((funding, -funding));
2839        } else {
2840            match order.order_side() {
2841                OrderSide::Buy => -notional,
2842                OrderSide::Sell => notional,
2843            }
2844        };
2845
2846        Ok((notional, impact))
2847    }
2848
2849    // A wallet locks the quote amount of a quote-quantity buy, and the notional of any other buy
2850    // rounded up at the observed balance precision, so that amount is checked and reserved.
2851    // Without an observed balance in the funding currency, the buy is checked against zero free
2852    // balance
2853    fn wallet_buy_funding(
2854        &self,
2855        order: &OrderAny,
2856        quantity: Quantity,
2857        price: Price,
2858        notional_currency: Currency,
2859    ) -> Option<Result<Money, String>> {
2860        let AccountAny::Wallet(wallet) = &self.account else {
2861            return None;
2862        };
2863
2864        if !order.is_buy() {
2865            return None;
2866        }
2867
2868        if order.is_quote_quantity() {
2869            let quote_currency = self.instrument.quote_currency();
2870            let currency = wallet
2871                .balance_total(Some(quote_currency))
2872                .map_or(quote_currency, |observed| observed.currency);
2873            return Some(
2874                Money::from_quantity(order.leaves_qty(), currency).map_err(|e| e.to_string()),
2875            );
2876        }
2877
2878        wallet.balance_total(Some(notional_currency))?;
2879        Some(
2880            wallet
2881                .calculate_balance_locked(self.instrument, OrderSide::Buy, quantity, price, None)
2882                .map_err(|e| e.to_string()),
2883        )
2884    }
2885
2886    fn balance_increase(
2887        &mut self,
2888        order: &OrderAny,
2889        impact: Money,
2890        original: Option<(Price, Quantity, bool)>,
2891    ) -> Result<Money, ()> {
2892        let Some((price, quantity, was_reducing)) = original else {
2893            return Ok(impact);
2894        };
2895
2896        let previous = if was_reducing {
2897            Ok(Money::zero(impact.currency))
2898        } else if let AccountAny::Betting(betting) = &mut self.account {
2899            betting
2900                .calculate_balance_locked(
2901                    self.instrument,
2902                    order.order_side(),
2903                    quantity,
2904                    price,
2905                    None,
2906                )
2907                .map_err(|e| e.to_string())
2908        } else if let Some(funding) = self.check.original(order).and_then(|original| {
2909            self.wallet_buy_funding(original, quantity, price, impact.currency)
2910        }) {
2911            funding
2912        } else {
2913            self.instrument
2914                .try_calculate_notional_value(quantity, price, None)
2915                .map_err(|e| e.to_string())
2916        }
2917        .map_err(|detail| {
2918            self.check.reject(
2919                self.engine,
2920                order,
2921                &OrderDeniedReason::NotionalCalculationFailed { detail }.to_string(),
2922            );
2923        })?;
2924
2925        let is_debit = order.is_buy() || matches!(self.account, AccountAny::Betting(_));
2926        let current = if is_debit { -impact } else { impact };
2927        let increase = self
2928            .engine
2929            .check_risk_increase(self.check, order, current, previous)
2930            .ok_or(())?;
2931        Ok(if is_debit { -increase } else { increase })
2932    }
2933
2934    fn check_cumulative_balance(&mut self, order: &OrderAny, required: Money) -> bool {
2935        let cumulative = match order.order_side() {
2936            OrderSide::Buy => &mut self.cum_notional_buy,
2937            OrderSide::Sell => &mut self.cum_notional_sell,
2938        };
2939
2940        if !self
2941            .engine
2942            .accumulate_notional(self.check, order, cumulative, required)
2943        {
2944            return false;
2945        }
2946
2947        if self.engine.config.debug {
2948            log::debug!(
2949                "Cumulative balance required for {}: {cumulative:?}",
2950                order.order_side()
2951            );
2952        }
2953
2954        let cumulative = *cumulative;
2955        let Ok(free) = self.balance_free(order, required.currency) else {
2956            return false;
2957        };
2958
2959        if !self.allow_borrowing
2960            && let Some(total) = cumulative
2961            && total > free
2962        {
2963            self.check.reject(
2964                self.engine,
2965                order,
2966                &OrderDeniedReason::CumulativeNotionalExceedsFreeBalance {
2967                    free_balance: free,
2968                    cumulative_notional: total,
2969                }
2970                .to_string(),
2971            );
2972
2973            return false;
2974        }
2975
2976        self.reserve_balance(order, required);
2977        true
2978    }
2979
2980    fn account_currency_amount(&self, order: &OrderAny, amount: Money) -> Result<Money, ()> {
2981        let Some(currency) = self.account.base_currency() else {
2982            return Ok(amount);
2983        };
2984
2985        if amount.currency == currency {
2986            return Ok(amount);
2987        }
2988
2989        if amount.is_zero() {
2990            return Ok(Money::zero(currency));
2991        }
2992
2993        // Match the portfolio's order-funding conversion convention
2994        let price_type = match order.order_side() {
2995            OrderSide::Buy => PriceType::Bid,
2996            OrderSide::Sell => PriceType::Ask,
2997        };
2998
2999        let xrate = self.engine.cache.borrow().try_get_xrate(
3000            self.instrument.id().venue,
3001            amount.currency,
3002            currency,
3003            price_type,
3004        );
3005        xrate
3006            .map_err(|e| e.to_string())
3007            .and_then(|xrate| {
3008                let xrate = xrate.ok_or_else(|| {
3009                    format!("No exchange rate from {} to {currency}", amount.currency)
3010                })?;
3011
3012                let value = amount.as_decimal().checked_mul(xrate).ok_or_else(|| {
3013                    "Account currency conversion exceeds Decimal bounds".to_string()
3014                })?;
3015
3016                Money::from_decimal(value, currency).map_err(|e| e.to_string())
3017            })
3018            .map_err(|e| {
3019                self.check.reject(
3020                    self.engine,
3021                    order,
3022                    &OrderDeniedReason::ValidationFailed {
3023                        detail: format!("Account currency conversion failed: {e}"),
3024                    }
3025                    .to_string(),
3026                );
3027            })
3028    }
3029
3030    fn check_asset_balance(
3031        &mut self,
3032        order: &OrderAny,
3033        quantity: Quantity,
3034        original: Option<(Price, Quantity, bool)>,
3035    ) -> bool {
3036        let Some(base_currency) = self.instrument.base_currency() else {
3037            return true;
3038        };
3039
3040        let Ok(base_free) = self.balance_free(order, base_currency) else {
3041            return false;
3042        };
3043
3044        let Some(account) = cash_or_wallet_account(&self.account) else {
3045            unreachable!()
3046        };
3047
3048        let quantity = match original {
3049            Some((_, previous, false)) => quantity.saturating_sub(previous),
3050            _ => quantity,
3051        };
3052
3053        let Ok(cash_value) = self.engine.check_cash_sell_balance(
3054            self.check,
3055            account,
3056            self.allow_borrowing,
3057            order,
3058            quantity,
3059            base_free,
3060            &mut self.cum_notional_sell,
3061        ) else {
3062            return false;
3063        };
3064
3065        self.reserve_balance(order, cash_value);
3066        true
3067    }
3068}
3069
3070// Returns cash and wallet accounts for sell-balance checks; margin and betting accounts
3071// follow their own sell paths.
3072fn cash_or_wallet_account(account: &AccountAny) -> Option<&dyn Account> {
3073    match account {
3074        AccountAny::Cash(cash) => Some(cash),
3075        AccountAny::Wallet(wallet) => Some(wallet),
3076        AccountAny::Margin(_) | AccountAny::Betting(_) => None,
3077    }
3078}
3079
3080#[cfg(test)]
3081mod tests;