Skip to main content

engine/matcher/
stochastic.rs

1use crate::matcher::Matcher;
2use crate::types::{FillEvent, MarketState, Order, OrderState, Side};
3use rand::prelude::*;
4use rand::rng;
5use rand::rngs::StdRng;
6
7pub struct StochasticMatcher {
8    orders: Vec<Order>,
9    dt: f64,
10    k: f64,
11    a: f64,
12    rng: StdRng,
13    // Hawkes self-excitation of fill arrivals (optional; alpha=0 disables).
14    // a_eff(t) = a + excitation(t), where excitation decays at rate beta
15    // and jumps by alpha on each fill.
16    hawkes_alpha: f64,
17    hawkes_beta: f64,
18    hawkes_excitation: f64,
19    // Bilateral (2D) Hawkes: separate buy/sell intensities.
20    // lambda_buy(t) = a + buy_excitation(t)
21    // lambda_sell(t) = a + sell_excitation(t)
22    // Each side only self-excites on fills of its own side.
23    bilateral_hawkes: bool,
24    hawkes_buy_excitation: f64,
25    hawkes_sell_excitation: f64,
26}
27
28impl StochasticMatcher {
29    pub fn new(dt: f64, k: f64, a: f64) -> Self {
30        Self {
31            orders: Vec::new(),
32            dt,
33            k,
34            a,
35            rng: StdRng::from_rng(&mut rng()),
36            hawkes_alpha: 0.0,
37            hawkes_beta: 0.0,
38            hawkes_excitation: 0.0,
39            bilateral_hawkes: false,
40            hawkes_buy_excitation: 0.0,
41            hawkes_sell_excitation: 0.0,
42        }
43    }
44
45    /// Enable Hawkes self-excitation for fill arrivals.
46    ///
47    /// Each fill boosts the effective arrival rate by `alpha`.
48    /// The boost decays exponentially at rate `beta` (per second).
49    /// The branching ratio alpha/beta must be < 1 for stationarity.
50    pub fn with_hawkes(mut self, alpha: f64, beta: f64) -> Self {
51        self.hawkes_alpha = alpha;
52        self.hawkes_beta = beta;
53        self
54    }
55
56    /// Enable bilateral (2D) Hawkes: separate intensities for buy and sell arrivals.
57    ///
58    /// dλ+ = β(μ − λ+)dt + α dN+   (buy side, excited only by buy fills)
59    /// dλ- = β(μ − λ-)dt + α dN-   (sell side, excited only by sell fills)
60    ///
61    /// where μ = `a` (the baseline rate), α = `alpha`, β = `beta`.
62    /// Branching ratio alpha/beta must be < 1 for stationarity.
63    pub fn with_bilateral_hawkes(mut self, alpha: f64, beta: f64) -> Self {
64        self.hawkes_alpha = alpha;
65        self.hawkes_beta = beta;
66        self.bilateral_hawkes = true;
67        self
68    }
69
70    /// Initialize unilateral Hawkes so effective intensity starts at `lambda0`.
71    ///
72    /// Keeps baseline `a` unchanged and adjusts only the excitation state.
73    pub fn with_initial_hawkes_intensity(mut self, lambda0: f64) -> Self {
74        self.hawkes_excitation = (lambda0 - self.a).max(0.0);
75        self
76    }
77
78    /// Initialize bilateral Hawkes so buy/sell effective intensities start at
79    /// (`lambda_buy0`, `lambda_sell0`).
80    ///
81    /// Keeps baseline `a` unchanged and adjusts only side-specific excitations.
82    pub fn with_initial_bilateral_hawkes_intensities(
83        mut self,
84        lambda_buy0: f64,
85        lambda_sell0: f64,
86    ) -> Self {
87        self.hawkes_buy_excitation = (lambda_buy0 - self.a).max(0.0);
88        self.hawkes_sell_excitation = (lambda_sell0 - self.a).max(0.0);
89        self
90    }
91
92    /// Seed this matcher with an explicit RNG (for reproducible, Send-safe copies).
93    pub fn with_rng(mut self, rng: StdRng) -> Self {
94        self.rng = rng;
95        self
96    }
97
98    /// Current effective arrival rate (base + Hawkes excitation).
99    #[inline]
100    pub fn effective_a(&self) -> f64 {
101        self.a + self.hawkes_excitation
102    }
103
104    /// Current Hawkes excitation level (0 when disabled or quiescent).
105    #[inline]
106    pub fn hawkes_excitation(&self) -> f64 {
107        self.hawkes_excitation
108    }
109
110    /// Current effective buy-side arrival rate in bilateral mode.
111    #[inline]
112    pub fn effective_a_buy(&self) -> f64 {
113        self.a + self.hawkes_buy_excitation
114    }
115
116    /// Current effective sell-side arrival rate in bilateral mode.
117    #[inline]
118    pub fn effective_a_sell(&self) -> f64 {
119        self.a + self.hawkes_sell_excitation
120    }
121}
122
123impl Matcher for StochasticMatcher {
124    fn add_order(&mut self, order: Order) {
125        self.orders.push(order);
126    }
127
128    fn cancel_order(&mut self, order_id: u64) {
129        if let Some(order) = self.orders.iter_mut().find(|o| o.id == order_id) {
130            order.state = OrderState::Cancelled;
131        }
132        self.orders.retain(|o| o.state == OrderState::Open);
133    }
134
135    fn cancel_all(&mut self) {
136        self.orders.clear();
137    }
138
139    fn process_quote(&mut self, state: &MarketState) -> Vec<FillEvent> {
140        // Hawkes: decay excitation over dt, compute effective arrival rate
141        if self.bilateral_hawkes {
142            let decay = (-self.hawkes_beta * self.dt).exp();
143            self.hawkes_buy_excitation *= decay;
144            self.hawkes_sell_excitation *= decay;
145        } else if self.hawkes_alpha > 0.0 {
146            self.hawkes_excitation *= (-self.hawkes_beta * self.dt).exp();
147        }
148        let a_eff = self.a + self.hawkes_excitation;
149
150        let mut fills = Vec::new();
151        // `state` is S_{t+1}: the market state AFTER the price process has stepped.
152        // Orders were submitted by strategies that already observed this state,
153        // so `mid_price` here is the post-move mid (the t+1 price).
154        let mid_price = (state.best_bid + state.best_ask) / 2.0;
155        let timestamp = state.timestamp;
156        let best_ask = state.best_ask;
157        let best_bid = state.best_bid;
158
159        for order in self.orders.iter_mut() {
160            if order.state != OrderState::Open {
161                continue;
162            }
163
164            let mut filled = false;
165            let mut fill_price = 0.0;
166
167            // ----------------------------------------------------------------
168            // Two-pronged evaluation
169            // ----------------------------------------------------------------
170            // A. AGGRESSIVE CROSSING
171            //    The limit price is on the wrong side of the current BBO.
172            //    A new buy order priced >= best_ask would immediately consume
173            //    the resting offer; a new sell order priced <= best_bid would
174            //    immediately consume the resting bid.
175            //    Fill at the BBO price (price improvement over limit price).
176            //
177            // B. DETERMINISTIC SWEEP
178            //    The limit was placed passively but the mid-price has since
179            //    moved THROUGH it.  Buy @ L: mid <= L (market fell to the bid).
180            //    Sell @ L: mid >= L (market rose to the ask).
181            //    Because the price path crossed L, every market order that
182            //    occurred during the interval would have filled this order.
183            //    Result: guaranteed fill at the limit price.
184            //
185            // C. PROBABILISTIC POISSON ARRIVAL
186            //    The mid has NOT yet crossed the limit.  Randomly arriving
187            //    market orders may still hit the resting order during [t, t+1].
188            //    We use the distance from the post-move mid (S_{t+1}) so the
189            //    intensity reflects the current book depth, not a stale pre-move
190            //    value.
191            //        delta  = distance(limit, mid_{t+1})   [always >= 0 here]
192            //        lambda = a * exp(-k * delta)           [arrivals / sec]
193            //        p_fill = 1 - exp(-lambda * dt)
194            // ----------------------------------------------------------------
195
196            // A. Aggressive crossing -- fill at BBO with price improvement
197            let aggressive = match order.side {
198                Side::Buy => order.limit_price >= best_ask,
199                Side::Sell => order.limit_price <= best_bid,
200            };
201
202            if aggressive {
203                filled = true;
204                fill_price = match order.side {
205                    Side::Buy => best_ask,
206                    Side::Sell => best_bid,
207                };
208            } else {
209                // B. Price sweep -- mid moved through the limit
210                let swept = match order.side {
211                    Side::Buy => mid_price <= order.limit_price,
212                    Side::Sell => mid_price >= order.limit_price,
213                };
214
215                if swept {
216                    filled = true;
217                    fill_price = order.limit_price;
218                } else {
219                    // C. Poisson arrival -- passive order not yet swept.
220                    // Delta is positive: order is away from the post-move mid.
221                    let delta = match order.side {
222                        Side::Buy => mid_price - order.limit_price, // mid > limit (passive)
223                        Side::Sell => order.limit_price - mid_price, // limit > mid (passive)
224                    };
225
226                    let side_a_eff = if self.bilateral_hawkes {
227                        match order.side {
228                            Side::Buy => self.a + self.hawkes_buy_excitation,
229                            Side::Sell => self.a + self.hawkes_sell_excitation,
230                        }
231                    } else {
232                        a_eff
233                    };
234
235                    let lambda = side_a_eff * (-self.k * delta).exp();
236                    let prob = 1.0 - (-lambda * self.dt).exp();
237
238                    if self.rng.random_bool(prob.clamp(0.0, 1.0)) {
239                        filled = true;
240                        fill_price = order.limit_price;
241                    }
242                }
243            }
244
245            if filled {
246                order.state = OrderState::Filled;
247                fills.push(FillEvent {
248                    timestamp,
249                    order_id: order.id,
250                    side: order.side,
251                    price: fill_price,
252                    quantity: order.quantity,
253                });
254            }
255        }
256
257        // Hawkes: each fill excites future arrivals
258        if self.bilateral_hawkes && !fills.is_empty() {
259            for fill in &fills {
260                match fill.side {
261                    Side::Buy => self.hawkes_buy_excitation += self.hawkes_alpha,
262                    Side::Sell => self.hawkes_sell_excitation += self.hawkes_alpha,
263                }
264            }
265        } else if self.hawkes_alpha > 0.0 && !fills.is_empty() {
266            self.hawkes_excitation += self.hawkes_alpha * fills.len() as f64;
267        }
268
269        // Clean up filled orders
270        self.orders.retain(|o| o.state == OrderState::Open);
271
272        fills
273    }
274
275    fn get_orders(&self) -> &Vec<Order> {
276        &self.orders
277    }
278
279    fn augment_parameters(&self) -> std::collections::HashMap<String, f64> {
280        let mut params = std::collections::HashMap::with_capacity(2);
281        if self.bilateral_hawkes {
282            // Convention (shared by all strategies and tests):
283            //   hawkes_buy_intensity  = lambda+ = buy MO arrival rate
284            //                        = rate of fills on MM's sell (ask) orders
285            //   hawkes_sell_intensity = lambda- = sell MO arrival rate
286            //                        = rate of fills on MM's buy (bid) orders
287            //
288            // hawkes_sell_excitation is excited by Side::Sell fills (ask fills = buy MOs hitting ask).
289            // hawkes_buy_excitation  is excited by Side::Buy  fills (bid fills = sell MOs hitting bid).
290            // Therefore: buy intensity = effective_a_sell(), sell intensity = effective_a_buy().
291            params.insert("hawkes_buy_intensity".to_string(), self.effective_a_sell());
292            params.insert("hawkes_sell_intensity".to_string(), self.effective_a_buy());
293        } else if self.hawkes_alpha > 0.0 {
294            params.insert("hawkes_intensity".to_string(), self.effective_a());
295        }
296        params
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::types::Side;
304
305    fn state(bid: f64, ask: f64) -> MarketState {
306        MarketState {
307            timestamp: 0.0,
308            best_bid: bid,
309            best_ask: ask,
310            last_price: None,
311            true_volatility: None,
312            true_drift: None,
313            parameters: None,
314        }
315    }
316
317    // --- Prong A: aggressive crossing ---
318
319    #[test]
320    fn test_aggressive_buy_fills_at_ask_not_at_limit() {
321        // Buy limit above best_ask crosses the book (prong A).
322        // Fill price must be best_ask, not the (higher) limit price.
323        let mut m = StochasticMatcher::new(1.0, 1.5, 140.0);
324        m.add_order(Order::new(1, Side::Buy, 101.0, 1.0));
325        let fills = m.process_quote(&state(99.0, 100.0));
326        assert_eq!(fills.len(), 1);
327        assert_eq!(fills[0].price, 100.0); // best_ask, not 101.0
328    }
329
330    #[test]
331    fn test_aggressive_sell_fills_at_bid_not_at_limit() {
332        // Sell limit below best_bid crosses the book (prong A).
333        // Fill price must be best_bid, not the (lower) limit price.
334        let mut m = StochasticMatcher::new(1.0, 1.5, 140.0);
335        m.add_order(Order::new(1, Side::Sell, 98.0, 1.0));
336        let fills = m.process_quote(&state(99.0, 100.0));
337        assert_eq!(fills.len(), 1);
338        assert_eq!(fills[0].price, 99.0); // best_bid, not 98.0
339    }
340
341    // --- Prong B: deterministic sweep ---
342
343    #[test]
344    fn test_sweep_buy_fills_when_mid_drops_through_limit() {
345        // Passive buy at 99.8; mid (99.0) < limit -> the market swept
346        // through the order (prong B). Fill is guaranteed at limit price.
347        let mut m = StochasticMatcher::new(1.0, 1.5, 140.0);
348        m.add_order(Order::new(1, Side::Buy, 99.8, 1.0));
349        // bid=98.5, ask=100.0 -> mid=99.25 < 99.8 -> swept
350        let fills = m.process_quote(&state(98.5, 100.0));
351        assert_eq!(fills.len(), 1);
352        assert_eq!(fills[0].price, 99.8);
353    }
354
355    #[test]
356    fn test_sweep_sell_fills_when_mid_rises_through_limit() {
357        // Passive sell at 99.5; mid (100.0) >= limit -> swept (prong B).
358        // Fill is guaranteed at the limit price.
359        let mut m = StochasticMatcher::new(1.0, 1.5, 140.0);
360        m.add_order(Order::new(1, Side::Sell, 99.5, 1.0));
361        // bid=99.0, ask=101.0 -> mid=100.0 >= 99.5 -> swept
362        let fills = m.process_quote(&state(99.0, 101.0));
363        assert_eq!(fills.len(), 1);
364        assert_eq!(fills[0].price, 99.5);
365    }
366
367    // --- Prong C: Poisson arrival ---
368
369    #[test]
370    fn test_passive_far_from_mid_has_near_zero_fill_probability() {
371        // delta=10, a=1 -> lambda=1*exp(-15)~3e-7 -> p_fill~3e-7 per tick.
372        // Over 1000 independent draws the order should fill at most a handful of times.
373        let a = 1.0;
374        let k = 1.5;
375        let mut fill_count = 0u32;
376        let trials = 1000;
377        for i in 0..trials {
378            let mut m = StochasticMatcher::new(1.0, k, a);
379            m.add_order(Order::new(i as u64 + 1, Side::Buy, 80.0, 1.0));
380            // mid=90.0, limit=80.0, delta=10
381            if !m.process_quote(&state(89.5, 90.5)).is_empty() {
382                fill_count += 1;
383            }
384        }
385        assert!(
386            fill_count <= 10,
387            "Expected near-zero fills, got {fill_count}/{trials}"
388        );
389    }
390
391    #[test]
392    fn test_passive_at_zero_delta_has_near_certain_fill() {
393        // delta=0 -> lambda=a=500 -> p = 1 - exp(-500) ~= 1.0.
394        // Place the buy limit exactly at mid (passive: limit < ask).
395        let a = 500.0;
396        let k = 1.5;
397        let mut miss_count = 0u32;
398        let trials = 100;
399        for i in 0..trials {
400            let mut m = StochasticMatcher::new(1.0, k, a);
401            // bid=99.99, ask=100.01, mid=100.0, limit=100.0
402            m.add_order(Order::new(i as u64 + 1, Side::Buy, 100.0, 1.0));
403            if m.process_quote(&state(99.99, 100.01)).is_empty() {
404                miss_count += 1;
405            }
406        }
407        assert!(
408            miss_count == 0,
409            "Expected all fills, missed {miss_count}/{trials}"
410        );
411    }
412
413    // --- Multiple orders from a single agent ---
414
415    #[test]
416    fn test_multiple_passive_bids_only_swept_one_fills() {
417        // Three bids at different depths; only the one the mid sweeps through
418        // (prong B) fills deterministically. The others are so far away (with
419        // a=0.001) that Poisson fills are negligible.
420        //
421        // bid=98.9, ask=99.1 -> mid=99.0
422        // order 1: limit=99.05 -> mid(99.0) <= 99.05 -> SWEPT -> guaranteed fill
423        // order 2: limit=98.0  -> delta=1.0, lambda=0.001*exp(-1.5)~0.00022 -> p~0
424        // order 3: limit=95.0  -> delta=4.0, lambda~3e-10 -> p~0
425        let mut m = StochasticMatcher::new(1.0, 1.5, 0.001);
426        m.add_order(Order::new(1, Side::Buy, 99.05, 1.0));
427        m.add_order(Order::new(2, Side::Buy, 98.0, 1.0));
428        m.add_order(Order::new(3, Side::Buy, 95.0, 1.0));
429        let fills = m.process_quote(&state(98.9, 99.1));
430        let ids: Vec<u64> = fills.iter().map(|f| f.order_id).collect();
431        assert!(ids.contains(&1), "Swept order must fill");
432        assert!(
433            !ids.contains(&2),
434            "Passive order at delta=1 must not fill with a=0.001"
435        );
436        assert!(
437            !ids.contains(&3),
438            "Passive order at delta=4 must not fill with a=0.001"
439        );
440    }
441
442    #[test]
443    fn test_two_aggressive_bids_both_fill_at_bbo() {
444        // Two bids both above best_ask must fill independently, both at best_ask.
445        let mut m = StochasticMatcher::new(1.0, 1.5, 140.0);
446        m.add_order(Order::new(1, Side::Buy, 101.0, 1.0));
447        m.add_order(Order::new(2, Side::Buy, 102.0, 2.0));
448        let fills = m.process_quote(&state(99.0, 100.0));
449        assert_eq!(fills.len(), 2);
450        for f in &fills {
451            assert_eq!(f.price, 100.0);
452        }
453    }
454
455    // --- Hawkes self-excitation ---
456
457    #[test]
458    fn test_hawkes_disabled_by_default() {
459        let m = StochasticMatcher::new(1.0, 1.5, 10.0);
460        assert_eq!(m.hawkes_excitation(), 0.0);
461        assert_eq!(m.effective_a(), 10.0);
462    }
463
464    #[test]
465    fn test_hawkes_builder_sets_params() {
466        let m = StochasticMatcher::new(1.0, 1.5, 10.0).with_hawkes(0.5, 2.0);
467        assert_eq!(m.hawkes_excitation(), 0.0);
468        assert_eq!(m.effective_a(), 10.0); // no fills yet
469    }
470
471    #[test]
472    fn test_hawkes_excitation_after_fills() {
473        // After a fill, excitation should increase by alpha.
474        let mut m = StochasticMatcher::new(1.0, 1.5, 500.0).with_hawkes(3.0, 2.0);
475
476        // Place a bid at the ask to guarantee fill (aggressive crossing).
477        m.add_order(Order::new(1, Side::Buy, 101.0, 1.0));
478        let fills = m.process_quote(&state(99.0, 100.0));
479        assert_eq!(fills.len(), 1);
480        // Excitation should be alpha * 1 fill = 3.0
481        assert!((m.hawkes_excitation() - 3.0).abs() < 1e-10);
482        assert!((m.effective_a() - 503.0).abs() < 1e-10);
483    }
484
485    #[test]
486    fn test_hawkes_excitation_decays() {
487        let mut m = StochasticMatcher::new(1.0, 1.5, 500.0).with_hawkes(3.0, 2.0);
488
489        // Trigger a fill to build excitation.
490        m.add_order(Order::new(1, Side::Buy, 101.0, 1.0));
491        m.process_quote(&state(99.0, 100.0));
492        assert!((m.hawkes_excitation() - 3.0).abs() < 1e-10);
493
494        // Next tick (no new fills): excitation should decay by exp(-beta*dt).
495        m.process_quote(&state(99.0, 100.0)); // no orders -> no fills
496        let expected = 3.0 * (-2.0_f64).exp(); // exp(-2.0 * 1.0)
497        assert!((m.hawkes_excitation() - expected).abs() < 1e-10);
498    }
499
500    #[test]
501    fn test_hawkes_increases_fill_rate_statistically() {
502        // Compare fill counts: Hawkes (with excitation primed) should fill
503        // more often than base matcher at the same spread distance.
504        //
505        // We use a moderate base rate where fills are not saturated.
506        // alpha=10, beta=5 gives meaningful excitation after each fill.
507        //
508        // Retry up to 3 times — the test is inherently stochastic and
509        // 2000 trials may occasionally not show a clear signal.
510        const MAX_ATTEMPTS: usize = 3;
511        let mut best_margin: i32 = i32::MIN;
512        let mut best_base = 0u32;
513        let mut best_hawkes = 0u32;
514
515        for attempt in 0..MAX_ATTEMPTS {
516            let trials = 2000;
517            let mut base_fills = 0u32;
518            let mut hawkes_fills = 0u32;
519
520            // First run base paths
521            for i in 0..trials {
522                let mut m = StochasticMatcher::new(1.0, 1.5, 1.0);
523                m.add_order(Order::new(i as u64 + 1, Side::Buy, 99.5, 1.0));
524                if !m.process_quote(&state(99.9, 100.1)).is_empty() {
525                    base_fills += 1;
526                }
527            }
528
529            // Then run Hawkes paths: prime excitation to simulate post-burst state
530            for i in 0..trials {
531                let mut m = StochasticMatcher::new(1.0, 1.5, 1.0).with_hawkes(10.0, 5.0);
532                // Prime: inject excitation as if 2 fills just happened
533                m.add_order(Order::new(1000000 + i as u64, Side::Buy, 101.0, 1.0));
534                m.process_quote(&state(99.0, 100.0)); // aggressive fill -> excitation += 10
535                m.add_order(Order::new(2000000 + i as u64, Side::Buy, 101.0, 1.0));
536                m.process_quote(&state(99.0, 100.0)); // another fill -> excitation += 10
537
538                // Now place a passive order: higher effective_a should give more fills
539                m.add_order(Order::new(3000000 + i as u64, Side::Buy, 99.5, 1.0));
540                if !m.process_quote(&state(99.9, 100.1)).is_empty() {
541                    hawkes_fills += 1;
542                }
543            }
544
545            let margin = hawkes_fills as i32 - base_fills as i32;
546            if margin > best_margin {
547                best_margin = margin;
548                best_base = base_fills;
549                best_hawkes = hawkes_fills;
550            }
551
552            if hawkes_fills > base_fills {
553                return; // success
554            }
555
556            eprintln!(
557                "attempt {} failed: base={}, hawkes={}; retrying…",
558                attempt + 1,
559                base_fills,
560                hawkes_fills,
561            );
562        }
563
564        panic!(
565            "Hawkes should produce more fills after {} attempts. \
566             Best result: base={}, hawkes={} (margin={})",
567            MAX_ATTEMPTS, best_base, best_hawkes, best_margin,
568        );
569    }
570}