hyperliquid_backtest/
risk_manager.rs

1use std::collections::HashMap;
2
3use chrono::{DateTime, FixedOffset, Utc};
4use thiserror::Error;
5
6use crate::unified_data::{OrderRequest, OrderSide, OrderType, Position};
7
8/// Configuration values used by the [`RiskManager`].
9#[derive(Debug, Clone)]
10pub struct RiskConfig {
11    pub max_position_size_pct: f64,
12    pub stop_loss_pct: f64,
13    pub take_profit_pct: f64,
14}
15
16impl Default for RiskConfig {
17    fn default() -> Self {
18        Self {
19            max_position_size_pct: 0.1,
20            stop_loss_pct: 0.05,
21            take_profit_pct: 0.1,
22        }
23    }
24}
25
26/// Errors that can be returned by [`RiskManager`].
27#[derive(Debug, Error, Clone)]
28pub enum RiskError {
29    /// Returned when an order would exceed the configured position size.
30    #[error("position size exceeds configured limit: {message}")]
31    PositionSizeExceeded { message: String },
32    /// Returned when trading is halted by the emergency stop flag.
33    #[error("trading is halted by the emergency stop toggle")]
34    TradingHalted,
35}
36
37/// Convenience result type for risk management operations.
38pub type Result<T> = std::result::Result<T, RiskError>;
39
40/// Representation of a stop-loss or take-profit order managed by [`RiskManager`].
41#[derive(Debug, Clone)]
42pub struct RiskOrder {
43    /// Identifier of the originating order.
44    pub parent_order_id: String,
45    /// Asset symbol.
46    pub symbol: String,
47    /// Order side used to flatten the position when triggered.
48    pub side: OrderSide,
49    /// Order type used when submitting the risk order.
50    pub order_type: OrderType,
51    /// Quantity to trade when the order triggers.
52    pub quantity: f64,
53    /// Trigger price for the order.
54    pub trigger_price: f64,
55    /// Whether the order acts as a stop-loss.
56    pub is_stop_loss: bool,
57    /// Whether the order acts as a take-profit.
58    pub is_take_profit: bool,
59    /// Timestamp when the risk order was created.
60    pub created_at: DateTime<FixedOffset>,
61}
62
63impl RiskOrder {
64    fn new(
65        parent_order_id: &str,
66        symbol: &str,
67        side: OrderSide,
68        quantity: f64,
69        trigger_price: f64,
70        is_stop_loss: bool,
71        is_take_profit: bool,
72    ) -> Self {
73        Self {
74            parent_order_id: parent_order_id.to_string(),
75            symbol: symbol.to_string(),
76            side,
77            order_type: OrderType::Market,
78            quantity,
79            trigger_price,
80            is_stop_loss,
81            is_take_profit,
82            created_at: Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap()),
83        }
84    }
85}
86
87/// Minimal risk management component used by the higher level trading engines.
88#[derive(Debug, Clone)]
89pub struct RiskManager {
90    config: RiskConfig,
91    portfolio_value: f64,
92    stop_losses: Vec<RiskOrder>,
93    take_profits: Vec<RiskOrder>,
94    emergency_stop: bool,
95}
96
97impl RiskManager {
98    /// Create a new [`RiskManager`] with the provided configuration.
99    pub fn new(config: RiskConfig, portfolio_value: f64) -> Self {
100        Self {
101            config,
102            portfolio_value,
103            stop_losses: Vec::new(),
104            take_profits: Vec::new(),
105            emergency_stop: false,
106        }
107    }
108
109    /// Access the underlying risk configuration.
110    pub fn config(&self) -> &RiskConfig {
111        &self.config
112    }
113
114    /// Update the tracked portfolio value. The current implementation simply records
115    /// the latest value so that position size checks have an up-to-date notion of the
116    /// account size.
117    pub fn update_portfolio_value(
118        &mut self,
119        new_value: f64,
120        _realized_pnl_delta: f64,
121    ) -> Result<()> {
122        self.portfolio_value = new_value.max(0.0);
123        Ok(())
124    }
125
126    /// Validate an order against simple position size limits and the emergency stop flag.
127    pub fn validate_order(
128        &self,
129        order: &OrderRequest,
130        _positions: &HashMap<String, Position>,
131    ) -> Result<()> {
132        if self.emergency_stop {
133            return Err(RiskError::TradingHalted);
134        }
135
136        if let Some(price) = order.price {
137            let notional = price * order.quantity.abs();
138            let max_notional = self.config.max_position_size_pct * self.portfolio_value;
139            if max_notional > 0.0 && notional > max_notional {
140                return Err(RiskError::PositionSizeExceeded {
141                    message: format!(
142                        "order notional {:.2} exceeds {:.2} ({:.2}% of portfolio)",
143                        notional,
144                        max_notional,
145                        self.config.max_position_size_pct * 100.0,
146                    ),
147                });
148            }
149        }
150
151        Ok(())
152    }
153
154    /// Produce a stop-loss order for the supplied position.
155    pub fn generate_stop_loss(&self, position: &Position, order_id: &str) -> Option<RiskOrder> {
156        if position.size == 0.0 || self.config.stop_loss_pct <= 0.0 {
157            return None;
158        }
159
160        let trigger_price = if position.size > 0.0 {
161            position.entry_price * (1.0 - self.config.stop_loss_pct)
162        } else {
163            position.entry_price * (1.0 + self.config.stop_loss_pct)
164        };
165
166        let side = if position.size > 0.0 {
167            OrderSide::Sell
168        } else {
169            OrderSide::Buy
170        };
171
172        Some(RiskOrder::new(
173            order_id,
174            &position.symbol,
175            side,
176            position.size.abs(),
177            trigger_price,
178            true,
179            false,
180        ))
181    }
182
183    /// Produce a take-profit order for the supplied position.
184    pub fn generate_take_profit(&self, position: &Position, order_id: &str) -> Option<RiskOrder> {
185        if position.size == 0.0 || self.config.take_profit_pct <= 0.0 {
186            return None;
187        }
188
189        let trigger_price = if position.size > 0.0 {
190            position.entry_price * (1.0 + self.config.take_profit_pct)
191        } else {
192            position.entry_price * (1.0 - self.config.take_profit_pct)
193        };
194
195        let side = if position.size > 0.0 {
196            OrderSide::Sell
197        } else {
198            OrderSide::Buy
199        };
200
201        Some(RiskOrder::new(
202            order_id,
203            &position.symbol,
204            side,
205            position.size.abs(),
206            trigger_price,
207            false,
208            true,
209        ))
210    }
211
212    /// Store a generated stop-loss order.
213    pub fn register_stop_loss(&mut self, order: RiskOrder) {
214        self.stop_losses.push(order);
215    }
216
217    /// Store a generated take-profit order.
218    pub fn register_take_profit(&mut self, order: RiskOrder) {
219        self.take_profits.push(order);
220    }
221
222    /// Inspect tracked risk orders against the latest market prices.
223    pub fn check_risk_orders(&mut self, current_prices: &HashMap<String, f64>) -> Vec<RiskOrder> {
224        fn should_trigger(order: &RiskOrder, price: f64) -> bool {
225            if order.is_stop_loss {
226                match order.side {
227                    OrderSide::Sell => price <= order.trigger_price,
228                    OrderSide::Buy => price >= order.trigger_price,
229                }
230            } else if order.is_take_profit {
231                match order.side {
232                    OrderSide::Sell => price >= order.trigger_price,
233                    OrderSide::Buy => price <= order.trigger_price,
234                }
235            } else {
236                false
237            }
238        }
239
240        let mut triggered = Vec::new();
241
242        self.stop_losses.retain(|order| {
243            if let Some(price) = current_prices.get(&order.symbol) {
244                if should_trigger(order, *price) {
245                    triggered.push(order.clone());
246                    return false;
247                }
248            }
249            true
250        });
251
252        self.take_profits.retain(|order| {
253            if let Some(price) = current_prices.get(&order.symbol) {
254                if should_trigger(order, *price) {
255                    triggered.push(order.clone());
256                    return false;
257                }
258            }
259            true
260        });
261
262        triggered
263    }
264
265    /// Manually trigger the emergency stop.
266    pub fn activate_emergency_stop(&mut self) {
267        self.emergency_stop = true;
268    }
269
270    /// Clear the emergency stop condition.
271    pub fn deactivate_emergency_stop(&mut self) {
272        self.emergency_stop = false;
273    }
274
275    /// Check whether trading should be halted.
276    pub fn should_stop_trading(&self) -> bool {
277        self.emergency_stop
278    }
279}