Skip to main content

nanobook_risk/
lib.rs

1//! Pre-trade risk engine for nanobook.
2//!
3//! Validates orders against configurable risk limits before execution.
4//! Uses generic broker types so it can be called from Python or any broker adapter.
5
6pub mod checks;
7pub mod config;
8pub mod report;
9
10pub use config::RiskConfig;
11pub use report::{RiskCheck, RiskReport, RiskStatus};
12
13use nanobook::Symbol;
14use nanobook_broker::{Account, BrokerSide};
15
16/// Pre-trade risk engine.
17#[derive(Debug, Clone)]
18pub struct RiskEngine {
19    config: RiskConfig,
20}
21
22impl RiskEngine {
23    /// Create a new risk engine with the given config.
24    ///
25    /// # Panics
26    ///
27    /// Panics if `config` fails validation (e.g., NaN fields, out-of-range values).
28    /// This is intentional — fail-fast at construction, not at check time.
29    #[track_caller]
30    pub fn new(config: RiskConfig) -> Self {
31        if let Err(msg) = config.validate() {
32            panic!("invalid RiskConfig: {msg}");
33        }
34        Self { config }
35    }
36
37    /// Access the current config.
38    pub fn config(&self) -> &RiskConfig {
39        &self.config
40    }
41
42    /// Check a single order against risk limits.
43    ///
44    /// A lightweight check for one order — validates position concentration
45    /// and order size.
46    pub fn check_order(
47        &self,
48        symbol: &Symbol,
49        side: BrokerSide,
50        quantity: u64,
51        price_cents: i64,
52        account: &Account,
53        current_positions: &[(Symbol, i64)],
54    ) -> RiskReport {
55        let equity = account.equity_cents;
56        let notional = quantity as i64 * price_cents;
57
58        let mut checks = Vec::new();
59
60        let max_order = self.config.max_order_value_cents;
61        let order_status = if max_order > 0 && notional > max_order {
62            RiskStatus::Fail
63        } else {
64            RiskStatus::Pass
65        };
66        checks.push(RiskCheck {
67            name: "Max order value",
68            status: order_status,
69            detail: format!(
70                "${:.0} {} ${:.0} max_order_value_cents",
71                notional as f64 / 100.0,
72                if order_status == RiskStatus::Pass {
73                    "<="
74                } else {
75                    ">"
76                },
77                max_order as f64 / 100.0,
78            ),
79        });
80
81        // Position concentration after this order
82        let current_qty = current_positions
83            .iter()
84            .find(|(s, _)| s == symbol)
85            .map(|(_, q)| *q)
86            .unwrap_or(0);
87
88        let delta = match side {
89            BrokerSide::Buy => quantity as i64,
90            BrokerSide::Sell => -(quantity as i64),
91        };
92        let post_qty = current_qty + delta;
93        let post_value = post_qty.abs() * price_cents;
94        let post_pct = if equity > 0 {
95            post_value as f64 / equity as f64
96        } else {
97            0.0
98        };
99
100        let pos_status = if post_pct > self.config.max_position_pct {
101            RiskStatus::Fail
102        } else {
103            RiskStatus::Pass
104        };
105        checks.push(RiskCheck {
106            name: "Max position",
107            status: pos_status,
108            detail: format!(
109                "{:.1}% ({}) {} {:.1}% limit",
110                post_pct * 100.0,
111                symbol.as_str(),
112                if pos_status == RiskStatus::Pass {
113                    "<="
114                } else {
115                    ">"
116                },
117                self.config.max_position_pct * 100.0,
118            ),
119        });
120
121        // Order size check
122        let max_cents = (self.config.max_trade_usd * 100.0) as i64;
123        let order_size_status = if notional > max_cents {
124            RiskStatus::Warn
125        } else {
126            RiskStatus::Pass
127        };
128        checks.push(RiskCheck {
129            name: "Order size",
130            status: order_size_status,
131            detail: format!(
132                "${:.2} {} ${:.2} max",
133                notional as f64 / 100.0,
134                if order_size_status == RiskStatus::Pass {
135                    "<="
136                } else {
137                    ">"
138                },
139                self.config.max_trade_usd,
140            ),
141        });
142
143        // Short check
144        if side == BrokerSide::Sell && post_qty < 0 && !self.config.allow_short {
145            checks.push(RiskCheck {
146                name: "Short selling",
147                status: RiskStatus::Fail,
148                detail: "short selling not allowed".into(),
149            });
150        }
151
152        RiskReport { checks }
153    }
154
155    /// Check a batch of orders (e.g., a full rebalance).
156    ///
157    /// Validates all risk limits including leverage, short exposure, and
158    /// aggregate position limits.
159    pub fn check_batch(
160        &self,
161        orders: &[(Symbol, BrokerSide, u64, i64)], // (symbol, side, qty, price_cents)
162        account: &Account,
163        current_positions: &[(Symbol, i64)], // (symbol, current_qty)
164        target_weights: &[(Symbol, f64)],
165    ) -> RiskReport {
166        checks::check_batch(
167            &self.config,
168            orders,
169            account,
170            current_positions,
171            target_weights,
172        )
173    }
174}