hypercall_engine/mmp.rs
1//! Engine-internal MMP (Market Maker Protection) state.
2//!
3//! Market Maker Protection is a risk control mechanism that automatically freezes
4//! a market maker's trading when cumulative fill exposure exceeds configured
5//! thresholds within a rolling time window. This prevents runaway fills during
6//! adverse market conditions or quoting errors.
7//!
8//! Each `(wallet, currency)` pair has its own [`EngineMmpState`]. The state tracks
9//! fills in a rolling window of `interval_ms` duration and monitors three independent
10//! limits: quantity, delta, and vega. Breaching any enabled limit freezes the
11//! market maker for `frozen_time_ms`, during which all fills are rejected.
12//!
13//! All operations are synchronous. No timers or background tasks are involved.
14//! Time advances only when the caller provides `current_time_ms` to
15//! [`EngineMmpState::process_fill`] or [`EngineMmpState::is_frozen`].
16
17use serde::{Deserialize, Serialize};
18use std::collections::VecDeque;
19
20/// A single fill record tracked by MMP.
21///
22/// Stored in the rolling window deque. Each record captures the timestamp and
23/// the greek exposure of the fill so it can be subtracted from cumulative
24/// totals when it leaves the window.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct MmpFillRecord {
27 /// Timestamp of the fill in milliseconds since epoch.
28 pub timestamp_ms: u64,
29 /// Unsigned fill quantity in contract units.
30 pub quantity: u64,
31 /// Delta contribution of this fill.
32 pub delta: f64,
33 /// Vega contribution of this fill.
34 pub vega: f64,
35}
36
37/// Engine-internal MMP state for a single `(wallet, currency)` pair.
38///
39/// Maintains a rolling window of recent fills, cumulative greek accumulators,
40/// and a timed freeze mechanism. When cumulative exposure within the window
41/// exceeds any configured limit, the state freezes and rejects all subsequent
42/// fills until `frozen_until` has passed.
43///
44/// # Fields
45///
46/// Configuration fields are set by [`EngineCommand::MmpConfigUpdate`](crate::EngineCommand::MmpConfigUpdate):
47///
48/// - `enabled` -- Whether MMP is active. When disabled, [`process_fill`](Self::process_fill) is a no-op.
49/// - `interval_ms` -- Rolling window duration in milliseconds.
50/// - `frozen_time_ms` -- Duration of freeze after a limit breach, in milliseconds.
51/// - `qty_limit` -- Maximum cumulative quantity within the window. `None` means unlimited.
52/// - `delta_limit` -- Maximum absolute cumulative delta. `None` means unlimited.
53/// - `vega_limit` -- Maximum absolute cumulative vega. `None` means unlimited.
54///
55/// Runtime state (managed internally):
56///
57/// - `fills` -- Rolling window of [`MmpFillRecord`] entries, ordered by timestamp.
58/// - `cumulative_qty` -- Sum of `quantity` across all fills in the window.
59/// - `cumulative_delta` -- Sum of `delta` across all fills in the window.
60/// - `cumulative_vega` -- Sum of `vega` across all fills in the window.
61/// - `frozen_until` -- If `Some(t)`, MMP is frozen until timestamp `t` (exclusive).
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct EngineMmpState {
64 /// Whether MMP is active for this wallet/currency pair.
65 pub enabled: bool,
66 /// Rolling window duration in milliseconds.
67 pub interval_ms: i64,
68 /// Freeze duration in milliseconds after a limit breach.
69 pub frozen_time_ms: i64,
70 /// Maximum cumulative quantity within the window. `None` disables this limit.
71 pub qty_limit: Option<f64>,
72 /// Maximum absolute cumulative delta within the window. `None` disables this limit.
73 pub delta_limit: Option<f64>,
74 /// Maximum absolute cumulative vega within the window. `None` disables this limit.
75 pub vega_limit: Option<f64>,
76 /// Rolling window of fill records, ordered by ascending timestamp.
77 pub fills: VecDeque<MmpFillRecord>,
78 /// Sum of fill quantities currently in the window.
79 pub cumulative_qty: u64,
80 /// Sum of fill deltas currently in the window.
81 pub cumulative_delta: f64,
82 /// Sum of fill vegas currently in the window.
83 pub cumulative_vega: f64,
84 /// If `Some(t)`, all fills are rejected until `current_time_ms >= t`.
85 pub frozen_until: Option<u64>,
86}
87
88impl EngineMmpState {
89 /// Create a new MMP state with the given configuration.
90 ///
91 /// The state starts unfrozen with an empty fill window and zeroed accumulators.
92 ///
93 /// # Parameters
94 ///
95 /// - `enabled` -- Whether MMP is active. Pass `false` to create a disabled state.
96 /// - `interval_ms` -- Rolling window duration in milliseconds.
97 /// - `frozen_time_ms` -- How long to freeze after a breach, in milliseconds.
98 /// - `qty_limit` -- Max cumulative quantity, or `None` for unlimited.
99 /// - `delta_limit` -- Max absolute cumulative delta, or `None` for unlimited.
100 /// - `vega_limit` -- Max absolute cumulative vega, or `None` for unlimited.
101 pub fn new(
102 enabled: bool,
103 interval_ms: i64,
104 frozen_time_ms: i64,
105 qty_limit: Option<f64>,
106 delta_limit: Option<f64>,
107 vega_limit: Option<f64>,
108 ) -> Self {
109 Self {
110 enabled,
111 interval_ms,
112 frozen_time_ms,
113 qty_limit,
114 delta_limit,
115 vega_limit,
116 fills: VecDeque::new(),
117 cumulative_qty: 0,
118 cumulative_delta: 0.0,
119 cumulative_vega: 0.0,
120 frozen_until: None,
121 }
122 }
123
124 /// Check whether MMP is currently frozen.
125 ///
126 /// Returns `true` if `frozen_until` is set and `current_time_ms` has not yet
127 /// reached it. The freeze expires at exactly `frozen_until` (i.e., the
128 /// comparison is strict less-than).
129 ///
130 /// This method is pure. It does not modify state or advance time.
131 pub fn is_frozen(&self, current_time_ms: u64) -> bool {
132 self.frozen_until
133 .is_some_and(|until| current_time_ms < until)
134 }
135
136 /// Evict fills that have fallen outside the rolling window.
137 ///
138 /// Removes all fills from the front of the deque whose `timestamp_ms` is
139 /// older than `current_time_ms - interval_ms`. Each evicted fill's greek
140 /// contributions are subtracted from the cumulative accumulators using
141 /// saturating arithmetic for quantity.
142 ///
143 /// This is called automatically at the start of [`process_fill`](Self::process_fill),
144 /// but can also be called independently if you need to inspect the window
145 /// state at a point in time.
146 pub fn evict_old_fills(&mut self, current_time_ms: u64) {
147 let cutoff = current_time_ms.saturating_sub(self.interval_ms as u64);
148 while let Some(front) = self.fills.front() {
149 if front.timestamp_ms < cutoff {
150 self.cumulative_qty = self.cumulative_qty.saturating_sub(front.quantity);
151 self.cumulative_delta -= front.delta;
152 self.cumulative_vega -= front.vega;
153 self.fills.pop_front();
154 } else {
155 break;
156 }
157 }
158 }
159
160 /// Process a fill through MMP, returning `Ok(())` if accepted or `Err` if rejected.
161 ///
162 /// # Flow
163 ///
164 /// 1. If MMP is disabled (`enabled == false`), return `Ok(())` immediately.
165 /// 2. If currently frozen, return `Err` without modifying state.
166 /// 3. Evict fills outside the rolling window via [`evict_old_fills`](Self::evict_old_fills).
167 /// 4. Add the fill's contributions to cumulative accumulators.
168 /// 5. Push the fill record into the window deque.
169 /// 6. Check each enabled limit (quantity, delta, vega). If any limit is
170 /// breached, set `frozen_until = current_time_ms + frozen_time_ms` and
171 /// return `Err`.
172 /// 7. If all limits pass, return `Ok(())`.
173 ///
174 /// # Errors
175 ///
176 /// Returns an error string describing which limit was breached, or that MMP
177 /// is currently frozen. The fill's contributions are already added to the
178 /// accumulators before the limit check, so a breaching fill is counted in
179 /// the window even though it triggers the freeze.
180 pub fn process_fill(
181 &mut self,
182 record: MmpFillRecord,
183 current_time_ms: u64,
184 ) -> Result<(), String> {
185 if !self.enabled {
186 return Ok(());
187 }
188
189 if self.is_frozen(current_time_ms) {
190 return Err("MMP triggered - currently frozen".to_string());
191 }
192
193 self.evict_old_fills(current_time_ms);
194
195 self.cumulative_qty += record.quantity;
196 self.cumulative_delta += record.delta;
197 self.cumulative_vega += record.vega;
198 self.fills.push_back(record);
199
200 if let Some(limit) = self.qty_limit {
201 if self.cumulative_qty > limit as u64 {
202 self.frozen_until = Some(current_time_ms + self.frozen_time_ms as u64);
203 return Err("MMP qty limit exceeded".to_string());
204 }
205 }
206
207 if let Some(limit) = self.delta_limit {
208 if self.cumulative_delta.abs() > limit {
209 self.frozen_until = Some(current_time_ms + self.frozen_time_ms as u64);
210 return Err("MMP delta limit exceeded".to_string());
211 }
212 }
213
214 if let Some(limit) = self.vega_limit {
215 if self.cumulative_vega.abs() > limit {
216 self.frozen_until = Some(current_time_ms + self.frozen_time_ms as u64);
217 return Err("MMP vega limit exceeded".to_string());
218 }
219 }
220
221 Ok(())
222 }
223
224 /// Reset MMP state, clearing all fills and unfreezing.
225 ///
226 /// Empties the fill window, zeroes all cumulative accumulators, and clears
227 /// the `frozen_until` timestamp. The configuration fields (`enabled`,
228 /// `interval_ms`, `frozen_time_ms`, limits) are not changed.
229 ///
230 /// Typically called when a market maker explicitly requests an MMP reset
231 /// via the API after reviewing their risk.
232 pub fn reset(&mut self) {
233 self.fills.clear();
234 self.cumulative_qty = 0;
235 self.cumulative_delta = 0.0;
236 self.cumulative_vega = 0.0;
237 self.frozen_until = None;
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn test_mmp_process_fill_within_limits() {
247 let mut state = EngineMmpState::new(true, 60_000, 30_000, Some(100.0), None, None);
248 let record = MmpFillRecord {
249 timestamp_ms: 1000,
250 quantity: 50,
251 delta: 0.0,
252 vega: 0.0,
253 };
254 assert!(state.process_fill(record, 1000).is_ok());
255 assert_eq!(state.cumulative_qty, 50);
256 assert!(!state.is_frozen(1000));
257 }
258
259 #[test]
260 fn test_mmp_process_fill_exceeds_qty_limit() {
261 let mut state = EngineMmpState::new(true, 60_000, 30_000, Some(100.0), None, None);
262 let r1 = MmpFillRecord {
263 timestamp_ms: 1000,
264 quantity: 60,
265 delta: 0.0,
266 vega: 0.0,
267 };
268 let r2 = MmpFillRecord {
269 timestamp_ms: 2000,
270 quantity: 50,
271 delta: 0.0,
272 vega: 0.0,
273 };
274 assert!(state.process_fill(r1, 1000).is_ok());
275 assert!(state.process_fill(r2, 2000).is_err());
276 assert!(state.is_frozen(2000));
277 assert!(!state.is_frozen(32_001)); // frozen_time_ms = 30_000
278 }
279
280 #[test]
281 fn test_mmp_eviction() {
282 let mut state = EngineMmpState::new(true, 10_000, 30_000, Some(100.0), None, None);
283 let r1 = MmpFillRecord {
284 timestamp_ms: 1000,
285 quantity: 60,
286 delta: 0.0,
287 vega: 0.0,
288 };
289 assert!(state.process_fill(r1, 1000).is_ok());
290 assert_eq!(state.cumulative_qty, 60);
291
292 // After window expires, old fill evicted
293 let r2 = MmpFillRecord {
294 timestamp_ms: 12_000,
295 quantity: 30,
296 delta: 0.0,
297 vega: 0.0,
298 };
299 assert!(state.process_fill(r2, 12_000).is_ok());
300 assert_eq!(state.cumulative_qty, 30); // 60 was evicted
301 }
302
303 #[test]
304 fn test_mmp_disabled_always_ok() {
305 let mut state = EngineMmpState::new(false, 60_000, 30_000, Some(1.0), None, None);
306 let record = MmpFillRecord {
307 timestamp_ms: 1000,
308 quantity: 999,
309 delta: 0.0,
310 vega: 0.0,
311 };
312 assert!(state.process_fill(record, 1000).is_ok());
313 }
314
315 #[test]
316 fn test_mmp_frozen_rejects() {
317 let mut state = EngineMmpState::new(true, 60_000, 30_000, Some(10.0), None, None);
318 // Force freeze
319 state.frozen_until = Some(50_000);
320
321 let record = MmpFillRecord {
322 timestamp_ms: 1000,
323 quantity: 1,
324 delta: 0.0,
325 vega: 0.0,
326 };
327 assert!(state.process_fill(record, 1000).is_err());
328 }
329
330 #[test]
331 fn test_mmp_delta_limit() {
332 let mut state = EngineMmpState::new(true, 60_000, 30_000, None, Some(5.0), None);
333 let r1 = MmpFillRecord {
334 timestamp_ms: 1000,
335 quantity: 1,
336 delta: 3.0,
337 vega: 0.0,
338 };
339 assert!(state.process_fill(r1, 1000).is_ok());
340
341 let r2 = MmpFillRecord {
342 timestamp_ms: 2000,
343 quantity: 1,
344 delta: 3.0,
345 vega: 0.0,
346 };
347 assert!(state.process_fill(r2, 2000).is_err());
348 assert!(state.is_frozen(2000));
349 }
350}