Skip to main content

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}