1pub 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#[derive(Debug, Clone)]
18pub struct RiskEngine {
19 config: RiskConfig,
20}
21
22impl RiskEngine {
23 #[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 pub fn config(&self) -> &RiskConfig {
39 &self.config
40 }
41
42 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 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 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 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 pub fn check_batch(
160 &self,
161 orders: &[(Symbol, BrokerSide, u64, i64)], account: &Account,
163 current_positions: &[(Symbol, i64)], 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}