Skip to main content

market_model/process/
hawkes.rs

1//! Hawkes self-exciting point process.
2//!
3//! A stochastic intensity process where each arrival increases the
4//! probability of further arrivals, capturing clustering behavior
5//! observed in market orders, cancellations, and volatility spikes.
6
7use crate::process::StochasticProcess;
8use crate::simulatable::Simulatable;
9use rand::Rng;
10
11/// Trait for Hawkes kernel functions.
12pub trait Kernel: Clone + Send + Sync {
13    fn call(&self, t: f64) -> f64;
14
15    /// Whether this kernel admits an O(1) recursive intensity update.
16    /// Only exponential kernels (phi(t) = alpha * exp(-beta * t)) support this.
17    fn supports_recursive_intensity(&self) -> bool {
18        false
19    }
20
21    /// Decay rate beta such that intensity decays as exp(-beta * dt) toward mu.
22    /// Only meaningful when `supports_recursive_intensity()` is true.
23    fn decay_rate(&self) -> f64 {
24        0.0
25    }
26
27    /// Excitation jump size added to intensity on each arrival.
28    /// Only meaningful when `supports_recursive_intensity()` is true.
29    fn excitation_size(&self) -> f64 {
30        0.0
31    }
32}
33
34/// Exponential kernel: phi(t) = alpha * exp(-beta * t).
35#[derive(Clone)]
36pub struct ExponentialKernel {
37    pub alpha: f64,
38    pub beta: f64,
39}
40
41impl ExponentialKernel {
42    pub fn new(alpha: f64, beta: f64) -> Self {
43        Self { alpha, beta }
44    }
45}
46
47impl Kernel for ExponentialKernel {
48    fn call(&self, t: f64) -> f64 {
49        if t < 0.0 {
50            0.0
51        } else {
52            self.alpha * (-self.beta * t).exp()
53        }
54    }
55
56    fn supports_recursive_intensity(&self) -> bool {
57        true
58    }
59
60    fn decay_rate(&self) -> f64 {
61        self.beta
62    }
63
64    fn excitation_size(&self) -> f64 {
65        self.alpha
66    }
67}
68
69/// Power-law kernel: phi(t) = alpha / (c + t)^p.
70#[derive(Clone)]
71pub struct PowerLawKernel {
72    pub alpha: f64,
73    pub c: f64,
74    pub p: f64,
75}
76
77impl PowerLawKernel {
78    pub fn new(alpha: f64, c: f64, p: f64) -> Self {
79        Self { alpha, c, p }
80    }
81}
82
83impl Kernel for PowerLawKernel {
84    fn call(&self, t: f64) -> f64 {
85        if t < 0.0 {
86            0.0
87        } else {
88            self.alpha / (self.c + t).powf(self.p)
89        }
90    }
91}
92
93/// Hawkes process with arbitrary kernel.
94///
95/// Simulated via Ogata's thinning algorithm. For exponential kernels,
96/// intensity is maintained O(1) via the recursion
97/// I(t+dt) = mu + (I(t) - mu) * exp(-beta*dt) + alpha * new_jumps.
98/// For non-exponential kernels, intensity is computed by summing over all
99/// historical jumps (O(n)).
100#[derive(Clone)]
101pub struct HawkesProcess<K: Kernel> {
102    pub mu: f64,
103    pub kernel: K,
104    jumps: Vec<f64>,
105    current_time: f64,
106    state: f64,
107    /// Pre-computed decay factor per unit time; used only by recursive path.
108    /// Re-computed when `current_time` advances.
109    current_intensity: f64,
110    /// Time of the oldest jump retained (for pruning).
111    prune_threshold: f64,
112}
113
114impl<K: Kernel> HawkesProcess<K> {
115    pub fn new(mu: f64, kernel: K) -> Self {
116        Self {
117            mu,
118            kernel,
119            jumps: Vec::new(),
120            current_time: 0.0,
121            state: mu,
122            current_intensity: mu,
123            prune_threshold: f64::NEG_INFINITY,
124        }
125    }
126
127    pub fn value(&self) -> f64 {
128        self.state
129    }
130
131    fn compute_intensity(&self, t: f64) -> f64 {
132        if self.kernel.supports_recursive_intensity() {
133            // O(1) for exponential kernel: compute directly from jump times.
134            // The contribution of each jump at tj is alpha * exp(-beta * (t - tj)).
135            // Since we only ever need the O(n) scan once per step() call (at the
136            // very end), this is simpler and numerically identical to the
137            // recursive O(1) path inside the Ogata loop.
138            let alpha = self.kernel.excitation_size();
139            let beta = self.kernel.decay_rate();
140            let mut intensity = self.mu;
141            for &tj in &self.jumps {
142                if tj < t {
143                    intensity += alpha * (-beta * (t - tj)).exp();
144                } else {
145                    break;
146                }
147            }
148            intensity
149        } else {
150            // O(n): sum over all historical jumps using the kernel call.
151            let mut sum = 0.0;
152            for &tj in &self.jumps {
153                if tj < t {
154                    sum += self.kernel.call(t - tj);
155                } else {
156                    break;
157                }
158            }
159            self.mu + sum
160        }
161    }
162
163    /// Drop jumps older than ~10 decay half-lives.
164    ///
165    /// A jump at time t_j contributes alpha * exp(-beta * (t - t_j)) to
166    /// the intensity at time t. After 10 half-lives, exp(-10) ~ 4.5e-5.
167    /// Removing such jumps has negligible impact on the intensity but bounds
168    /// the Vec growth from O(steps) to O(1).
169    fn prune_jumps(&mut self) {
170        let cutoff = self.current_time - 10.0 / self.kernel.decay_rate();
171        if cutoff <= self.prune_threshold {
172            return;
173        }
174        self.prune_threshold = cutoff;
175        let first = self.jumps.partition_point(|tj| *tj < cutoff);
176        if first > 0 {
177            self.jumps.drain(..first);
178        }
179    }
180}
181
182impl<K: Kernel + 'static> Simulatable for HawkesProcess<K> {
183    type Output = f64;
184
185    fn dim(&self) -> usize {
186        0 // No Brownian driver; all randomness from Ogata thinning via rng.
187    }
188
189    fn step(&mut self, dt: f64, _dw: &[f64], rng: &mut impl Rng) -> f64 {
190        let end_time = self.current_time + dt;
191
192        if self.kernel.supports_recursive_intensity() {
193            // O(1) path for exponential kernel.
194            //
195            // Two intensity values are maintained:
196            //   lambda_t: true intensity I(t), including all accepted jumps.
197            //   lambda_p: proposal upper bound for Ogata, which after a jump
198            //     at t does NOT include the jump (matches compute_intensity(t)
199            //     strict < semantics in the O(n) path).
200            //
201            // After a jump: lambda_t += alpha, lambda_p stays at pre-jump.
202            // Both decay at rate beta toward mu between events.
203            let beta = self.kernel.decay_rate();
204            let alpha = self.kernel.excitation_size();
205            let mut lambda_t = self.current_intensity;
206            let mut lambda_p = self.current_intensity;
207            let mut t_now = self.current_time;
208
209            while t_now < end_time {
210                if lambda_p <= 1e-12 {
211                    break;
212                }
213
214                let u: f64 = rng.random();
215                let tau = -u.ln() / lambda_p;
216                let candidate = t_now + tau;
217
218                if candidate > end_time {
219                    // Decay both to end_time.
220                    let dtau = end_time - t_now;
221                    let decay = (-beta * dtau).exp();
222                    lambda_t = self.mu + (lambda_t - self.mu) * decay;
223                    t_now = end_time;
224                    break;
225                }
226
227                let dtau = candidate - t_now;
228                let decay = (-beta * dtau).exp();
229                let lambda_t_candidate = self.mu + (lambda_t - self.mu) * decay;
230                let lambda_p_candidate = self.mu + (lambda_p - self.mu) * decay;
231
232                let u_accept: f64 = rng.random();
233                // Ogata acceptance uses lambda_p (the proposal upper bound)
234                // against lambda_t_candidate (the true intensity at candidate).
235                if u_accept * lambda_p <= lambda_t_candidate {
236                    self.jumps.push(candidate);
237                    lambda_t = lambda_t_candidate + alpha;
238                    // lambda_p for next interval: intensity at candidate
239                    // WITHOUT the just-accepted jump (matches O(n)).
240                    lambda_p = lambda_t_candidate;
241                } else {
242                    lambda_t = lambda_t_candidate;
243                    lambda_p = lambda_p_candidate;
244                }
245                t_now = candidate;
246            }
247
248            self.current_time = t_now;
249            self.state = lambda_t;
250            self.current_intensity = lambda_t;
251            self.prune_jumps();
252        } else {
253            // O(n) path: Ogata thinning with full intensity scan.
254            while self.current_time < end_time {
255                let lambda_star = self.compute_intensity(self.current_time);
256
257                if lambda_star <= 1e-12 {
258                    self.current_time = end_time;
259                    break;
260                }
261
262                let u: f64 = rng.random();
263                let tau = -u.ln() / lambda_star;
264                let candidate = self.current_time + tau;
265
266                if candidate > end_time {
267                    self.current_time = end_time;
268                    break;
269                }
270
271                self.current_time = candidate;
272                let lambda_s = self.compute_intensity(self.current_time);
273                let u_accept: f64 = rng.random();
274
275                if u_accept * lambda_star <= lambda_s {
276                    self.jumps.push(self.current_time);
277                }
278            }
279
280            self.state = self.compute_intensity(self.current_time);
281            self.current_intensity = self.state;
282        }
283
284        self.state
285    }
286
287    fn current(&self) -> f64 {
288        self.state
289    }
290}
291
292/// Simulate the Hawkes process and return the intensity path on a grid.
293impl<K: Kernel + 'static> HawkesProcess<K> {
294    pub fn simulate_intensity(&self, total_time: f64, n_steps: usize) -> Vec<f64> {
295        let mut sim = self.clone();
296        let dt = total_time / n_steps as f64;
297        let mut path = Vec::with_capacity(n_steps + 1);
298        path.push(sim.current());
299        for _ in 0..n_steps {
300            let v = Simulatable::step(&mut sim, dt, &[], &mut rand::rng());
301            path.push(v);
302        }
303        path
304    }
305}
306
307impl<K: Kernel + 'static> StochasticProcess for HawkesProcess<K> {
308    type State = f64;
309    type Diffusion = f64;
310    type BrownianIncrement = f64;
311
312    fn drift(&self, x: &f64, _t: f64) -> f64 {
313        self.mu - x
314    }
315
316    fn diffusion(&self, _x: &f64, _t: f64) -> f64 {
317        0.0
318    }
319
320    fn jump_intensity(&self, _x: &f64, _t: f64) -> f64 {
321        self.mu
322    }
323
324    fn jump_size<R: Rng + ?Sized>(&self, _x: &f64, t: f64, _rng: &mut R) -> f64 {
325        self.kernel.call(t)
326    }
327
328    fn expected_value(&self, x0: &f64, _t: f64) -> f64 {
329        *x0
330    }
331
332    fn variance(&self, _x0: &f64, _t: f64) -> f64 {
333        self.mu
334    }
335
336    fn step<R: Rng + ?Sized>(&self, x: &f64, _t: f64, dt: f64, _dw: &f64, rng: &mut R) -> f64 {
337        let lambda = self.mu;
338        if lambda <= 1e-12 {
339            return *x;
340        }
341        let u: f64 = rng.random();
342        let tau = -u.ln() / lambda;
343        if tau < dt { *x + 1.0 } else { *x }
344    }
345
346    fn generate_increment<R: Rng + ?Sized>(&self, _rng: &mut R, _dt: f64) -> f64 {
347        0.0
348    }
349
350    fn simulate<R: Rng + ?Sized>(
351        &self,
352        x0: f64,
353        t0: f64,
354        t_end: f64,
355        dt: f64,
356        rng: &mut R,
357    ) -> Vec<(f64, f64)> {
358        let n_steps = ((t_end - t0) / dt).ceil() as usize;
359        let mut path = Vec::with_capacity(n_steps + 1);
360        let mut t = t0;
361        let mut x = x0;
362        path.push((t, x));
363
364        for _ in 0..n_steps {
365            x = self.step(&x, t, dt, &0.0, rng);
366            t += dt;
367            path.push((t, x));
368        }
369        path
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn test_hawkes_no_brownian_dimension() {
379        let kernel = ExponentialKernel::new(0.5, 2.0);
380        let h = HawkesProcess::new(0.1, kernel);
381        assert_eq!(h.dim(), 0);
382    }
383
384    #[test]
385    fn test_hawkes_intensity_positive() {
386        let kernel = ExponentialKernel::new(0.5, 2.0);
387        let h = HawkesProcess::new(0.5, kernel);
388        let path = h.simulate_intensity(1.0, 100);
389        for v in path {
390            assert!(v >= 0.0);
391        }
392    }
393
394    #[test]
395    fn test_exponential_kernel() {
396        let k = ExponentialKernel::new(1.0, 2.0);
397        assert_eq!(k.call(0.0), 1.0);
398        assert!(k.call(1.0) < 1.0);
399        assert_eq!(k.call(-0.5), 0.0);
400    }
401
402    #[test]
403    fn test_power_law_kernel() {
404        let k = PowerLawKernel::new(1.0, 1.0, 2.0);
405        assert_eq!(k.call(0.0), 1.0);
406        assert!(k.call(1.0) < 0.5);
407        assert_eq!(k.call(-0.5), 0.0);
408    }
409
410    #[test]
411    fn test_recursive_intensity_same_seed_match() {
412        // Same seed trace: the recursive path must consume RNG identically.
413        // We run a long single-path trace with StdRng.  If the two paths ever
414        // diverge, the RNGs desync and all subsequent steps differ.
415        use rand::SeedableRng;
416        use rand::rngs::StdRng;
417
418        let kernel = ExponentialKernel::new(2.0, 3.0);
419        let mu = 1.0;
420        let dt = 0.01;
421        let n_steps = 5000;
422        let seed = 42u64;
423
424        #[derive(Clone)]
425        struct NonRecursiveKernel(ExponentialKernel);
426        impl Kernel for NonRecursiveKernel {
427            fn call(&self, t: f64) -> f64 {
428                self.0.call(t)
429            }
430        }
431
432        let mut ha = HawkesProcess::new(mu, kernel.clone());
433        let mut rng_a = StdRng::seed_from_u64(seed);
434        let mut hb = HawkesProcess::new(mu, NonRecursiveKernel(kernel.clone()));
435        let mut rng_b = StdRng::seed_from_u64(seed);
436
437        let mut first_diverge = None;
438        for i in 0..n_steps {
439            let a = Simulatable::step(&mut ha, dt, &[], &mut rng_a);
440            let b = Simulatable::step(&mut hb, dt, &[], &mut rng_b);
441            if first_diverge.is_none() && (a - b).abs() > 1e-12 {
442                first_diverge = Some((i, a, b));
443            }
444        }
445
446        if let Some((i, a, b)) = first_diverge {
447            panic!(
448                "recursive and exact diverged at step {}: rec={:.15e}, exact={:.15e}",
449                i, a, b
450            );
451        }
452    }
453
454    #[test]
455    fn test_recursive_intensity_o_n_self_consistency() {
456        // Two independent runs of the O(n) path: grand means should agree
457        // within Monte Carlo error.  This establishes the baseline noise floor
458        // for any cross-algorithm comparison.
459        use rand::SeedableRng;
460        use rand::rngs::StdRng;
461
462        let kernel = ExponentialKernel::new(2.0, 3.0);
463        let mu = 1.0;
464        let dt = 0.01;
465        let n_steps = 2000;
466        let n_paths = 1000;
467
468        #[derive(Clone)]
469        struct NonRecursiveKernel(ExponentialKernel);
470        impl Kernel for NonRecursiveKernel {
471            fn call(&self, t: f64) -> f64 {
472                self.0.call(t)
473            }
474        }
475
476        let mut sum_a = 0.0f64;
477        let mut sum_b = 0.0f64;
478        let mut count = 0u64;
479
480        for path_i in 0u64..n_paths {
481            let seed_a = path_i;
482            let seed_b = path_i + 1_000_000;
483
484            let mut ha = HawkesProcess::new(mu, NonRecursiveKernel(kernel.clone()));
485            let mut rng_a = StdRng::seed_from_u64(seed_a);
486            for _ in 0..n_steps {
487                sum_a += Simulatable::step(&mut ha, dt, &[], &mut rng_a);
488                count += 1;
489            }
490
491            let mut hb = HawkesProcess::new(mu, NonRecursiveKernel(kernel.clone()));
492            let mut rng_b = StdRng::seed_from_u64(seed_b);
493            for _ in 0..n_steps {
494                sum_b += Simulatable::step(&mut hb, dt, &[], &mut rng_b);
495            }
496        }
497
498        let mean_a = sum_a / count as f64;
499        let mean_b = sum_b / count as f64;
500        let rel_diff = (mean_a - mean_b).abs() / mean_b.max(1e-12);
501
502        eprintln!(
503            "O(n) self-consistency: mean_a={:.6}, mean_b={:.6}, rel_diff={:.4}%",
504            mean_a,
505            mean_b,
506            rel_diff * 100.0
507        );
508
509        // The exponential Hawkes with branching ratio 2/3 has long-range
510        // dependence (bursty).  1000 paths x 2000 steps may not be enough
511        // for the grand mean to converge tighter than ~1%.  This is a
512        // property of the process, not of the algorithm.
513        assert!(
514            rel_diff < 0.01,
515            "O(n) self-consistency: means differ by {:.4}%",
516            rel_diff * 100.0
517        );
518    }
519}