Skip to main content

solver/models/
heston_hawkes.rs

1use super::control::{ControlProblem, StateDerivatives};
2use super::market_making::MarketMakingControl;
3use super::normal_cdf;
4use crate::numeric::finite_difference::discretization::{DimensionKind, Transport};
5use crate::numeric::finite_difference::pde::PdeProblem;
6
7/// Combined Heston-Hawkes Model for Market Making (N=3).
8///
9/// Merges stochastic volatility (Heston) with self-exciting order flow (Hawkes)
10/// into a single three-dimensional model, demonstrating seamless N=3 extensibility.
11///
12/// # State Space
13/// `[q, v, lambda]` where:
14/// - `q` (dim 0): Inventory level (discrete, jump-controlled)
15/// - `v` (dim 1): Stochastic variance following CIR dynamics
16/// - `lambda` (dim 2): Hawkes order arrival intensity (mean-reverting with self-excitation)
17///
18/// # Dynamics
19/// * Inventory: $dq = +1$ (buy fill) or $-1$ (sell fill)
20/// * Variance: $dv_t = \kappa_v (\theta - v_t) dt + \xi \sqrt{v_t} dW^v_t$
21/// * Intensity: $d\lambda_t = \beta (\mu_\lambda - \lambda_t) dt + \alpha \lambda_t dt$
22///
23/// The optimal spreads incorporate both volatility risk premia (from Heston)
24/// and state-dependent arrival rates (from Hawkes).
25#[derive(Clone, Debug)]
26pub struct HestonHawkes {
27    // -- Market-making parameters --
28    /// Risk aversion for inventory holding.
29    pub gamma: f64,
30    /// Order filling intensity decay.
31    pub kappa: f64,
32
33    // -- Heston parameters --
34    /// Mean reversion speed for variance.
35    pub v_kappa: f64,
36    /// Long-run mean variance.
37    pub v_theta: f64,
38    /// Volatility of variance.
39    pub v_xi: f64,
40    /// Correlation between price and variance Brownian motions ($\rho$).
41    /// Enters the HJB through the cross-variation drift: $-\gamma \rho \xi v q$.
42    pub rho: f64,
43    /// Grid step size for variance dimension.
44    pub dv: f64,
45
46    // -- Hawkes parameters --
47    /// Self-excitation jump size.
48    pub alpha: f64,
49    /// Mean-reversion speed of intensity.
50    pub beta: f64,
51    /// Baseline intensity level.
52    pub mu_lambda: f64,
53    /// Grid step for intensity dimension.
54    pub lambda_step: f64,
55    /// Grid step for inventory dimension.
56    pub dq: f64,
57}
58
59impl HestonHawkes {
60    pub fn new(gamma: f64, kappa: f64) -> Self {
61        Self {
62            gamma,
63            kappa,
64            v_kappa: 2.0,
65            v_theta: 0.25,
66            v_xi: 0.3,
67            rho: 0.0,
68            dv: 1.0,
69            alpha: 0.5,
70            beta: 2.0,
71            mu_lambda: 1.0,
72            lambda_step: 1.0,
73            dq: 1.0,
74        }
75    }
76
77    pub fn with_heston_params(mut self, v_kappa: f64, v_theta: f64, v_xi: f64) -> Self {
78        self.v_kappa = v_kappa;
79        self.v_theta = v_theta;
80        self.v_xi = v_xi;
81        self
82    }
83
84    pub fn with_rho(mut self, rho: f64) -> Self {
85        self.rho = rho;
86        self
87    }
88
89    pub fn with_hawkes_params(mut self, alpha: f64, beta: f64, mu_lambda: f64) -> Self {
90        self.alpha = alpha;
91        self.beta = beta;
92        self.mu_lambda = mu_lambda;
93        self
94    }
95
96    /// Sets grid step sizes from a Grid<3>.
97    /// Dimension 0 = inventory (dq), Dimension 1 = variance (dv), Dimension 2 = intensity (lambda_step).
98    pub fn with_grid_steps(mut self, dx: &[f64; 3]) -> Self {
99        self.dq = dx[0].abs().max(1e-8);
100        self.dv = dx[1].abs().max(1e-8);
101        self.lambda_step = dx[2].abs().max(1e-8);
102        self
103    }
104
105    /// Compute optimal bid/ask spreads given the current state and value gradients.
106    ///
107    /// Combines the Heston volatility risk premium with the Hawkes intensity dependence.
108    pub fn get_spreads(&self, derivs: &StateDerivatives<3>) -> (f64, f64) {
109        // Pure HJB-derived formula: all variance risk is encoded in V(q, v, lambda).
110        // Removing ad-hoc premia ensures the degenerate limit (kappa_v -> inf, alpha -> 0)
111        // collapses cleanly to the base AS model.
112        let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
113
114        // Value gradient w.r.t. inventory
115        let dv_dq_buy = derivs.fwd[0];
116        let dv_dq_sell = derivs.bwd[0];
117
118        let mut delta_bid = base_spread - dv_dq_buy;
119        let mut delta_ask = base_spread + dv_dq_sell;
120
121        delta_bid = delta_bid.clamp(-5.0, 10.0);
122        delta_ask = delta_ask.clamp(-5.0, 10.0);
123
124        (delta_bid, delta_ask)
125    }
126
127    /// The base arrival intensity is state-dependent: returns the current
128    /// intensity `state[2]` (clamped above 0) for intensity-to-spread conversion.
129    pub fn fill_rate_base(&self, state: &[f64; 3]) -> f64 {
130        state[2].max(1e-10)
131    }
132
133    /// The fill-rate decay parameter used for intensity-to-spread conversion.
134    pub fn fill_rate_decay(&self) -> f64 {
135        self.kappa
136    }
137}
138
139impl ControlProblem<3> for HestonHawkes {
140    type Control = MarketMakingControl;
141
142    fn optimize(&self, _t: f64, state: &[f64; 3], derivs: &StateDerivatives<3>) -> Self::Control {
143        let q = state[0];
144        let v = state[1];
145        let lambda = state[2];
146        let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
147        let delta_bid = (base_spread - derivs.fwd[0]).clamp(-5.0, 10.0);
148        let delta_ask = (base_spread + derivs.bwd[0]).clamp(-5.0, 10.0);
149
150        let max_fill_mult = 20.0_f64;
151        let lambda_bid = lambda * (-self.kappa * delta_bid).exp().min(max_fill_mult);
152        let lambda_ask = lambda * (-self.kappa * delta_ask).exp().min(max_fill_mult);
153
154        let _ = (q, v);
155        MarketMakingControl::new(lambda_bid, lambda_ask)
156    }
157
158    fn running_reward(&self, _t: f64, _state: &[f64; 3], control: &Self::Control) -> f64 {
159        (control.bid_intensity + control.ask_intensity) / (self.gamma + self.kappa)
160    }
161
162    fn bsde_driver(
163        &self,
164        _t: f64,
165        state: &[f64; 3],
166        control: &Self::Control,
167        _derivs: &StateDerivatives<3>,
168        dt: f64,
169    ) -> f64 {
170        // Full-value problem: the variance and Hawkes-intensity transport is
171        // already simulated forward, so the backward driver adds only the
172        // running reward plus the local inventory-risk source. The reward is
173        // bounded at `1/dt` per fill side to match the forward fill-probability
174        // clamp `lambda * dt <= 1`.
175        let q = state[0];
176        let v = state[1];
177        let local = -0.5 * self.gamma * v * q.powi(2);
178        let rate_cap = 1.0 / dt.max(1e-12);
179        let reward = (control.bid_intensity.min(rate_cap) + control.ask_intensity.min(rate_cap))
180            / (self.gamma + self.kappa);
181        reward + local
182    }
183
184    fn generator(
185        &self,
186        _t: f64,
187        state: &[f64; 3],
188        _control: &Self::Control,
189        derivs: &StateDerivatives<3>,
190    ) -> f64 {
191        let q = state[0];
192        let v = state[1];
193        let lambda = state[2];
194
195        let v_pos = v.max(1e-5);
196        let mu_v =
197            self.v_kappa * (self.v_theta - v_pos) - self.gamma * self.rho * self.v_xi * v_pos * q;
198        let sigma2_v = self.v_xi.powi(2) * v_pos;
199
200        let net_drift_lambda = self.beta * (self.mu_lambda - lambda) + self.alpha * lambda;
201
202        -0.5 * self.gamma * v * q.powi(2)
203            + mu_v * derivs.grad[1]
204            + 0.5 * sigma2_v * derivs.hessian[1]
205            + net_drift_lambda * derivs.grad[2]
206    }
207
208    fn terminal(&self, _state: &[f64; 3]) -> f64 {
209        0.0
210    }
211
212    fn discount_rate(&self, _state: &[f64; 3]) -> f64 {
213        0.0
214    }
215
216    fn constant_discount_rate(&self) -> Option<f64> {
217        Some(0.0)
218    }
219
220    fn next_step(&self, _t: f64, state: &[f64; 3], dt: f64, noise: &[f64; 3]) -> [f64; 3] {
221        let q = state[0];
222        let v = state[1];
223        let lambda = state[2];
224        let sqrt_dt = dt.sqrt();
225
226        let mut next_q = q;
227        let u = normal_cdf(noise[0]);
228        let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
229        let lambda_bid = lambda * (-self.kappa * base_spread).exp();
230        let lambda_ask = lambda * (-self.kappa * base_spread).exp();
231        let p_bid = (lambda_bid * dt).clamp(0.0, 1.0);
232        let p_ask = (lambda_ask * dt).clamp(0.0, 1.0);
233        if u < p_bid {
234            next_q += 1.0;
235        } else if u > 1.0 - p_ask {
236            next_q -= 1.0;
237        }
238
239        let v_pos = v.max(1e-5);
240        let drift_v = self.v_kappa * (self.v_theta - v_pos) * dt;
241        let diff_v = self.v_xi * v_pos.sqrt() * sqrt_dt * noise[1];
242        let mut next_v = v_pos + drift_v + diff_v;
243        if next_v < 0.0 {
244            next_v = -next_v;
245        }
246
247        let net_drift = self.beta * (self.mu_lambda - lambda) + self.alpha * lambda;
248        let next_lambda = (lambda + net_drift * dt).max(0.0);
249
250        [next_q, next_v, next_lambda]
251    }
252
253    fn next_step_controlled(
254        &self,
255        _t: f64,
256        state: &[f64; 3],
257        control: &Self::Control,
258        dt: f64,
259        noise: &[f64; 3],
260    ) -> [f64; 3] {
261        let v = state[1];
262        let lambda = state[2];
263        let sqrt_dt = dt.sqrt();
264
265        let mut next_q = state[0];
266        let u = normal_cdf(noise[0]);
267        let p_bid = (control.bid_intensity * dt).clamp(0.0, 1.0);
268        let p_ask = (control.ask_intensity * dt).clamp(0.0, 1.0);
269        if u < p_bid {
270            next_q += 1.0;
271        } else if u > 1.0 - p_ask {
272            next_q -= 1.0;
273        }
274
275        let v_pos = v.max(1e-5);
276        let drift_v = self.v_kappa * (self.v_theta - v_pos) * dt;
277        let diff_v = self.v_xi * v_pos.sqrt() * sqrt_dt * noise[1];
278        let mut next_v = v_pos + drift_v + diff_v;
279        if next_v < 0.0 {
280            next_v = -next_v;
281        }
282
283        let net_drift = self.beta * (self.mu_lambda - lambda) + self.alpha * lambda;
284        let next_lambda = (lambda + net_drift * dt).max(0.0);
285
286        [next_q, next_v, next_lambda]
287    }
288
289    fn is_diffusion_dimension(&self, dim: usize) -> bool {
290        dim == 1
291    }
292
293    fn gradient_step(&self, dim: usize) -> f64 {
294        match dim {
295            1 => self.dv.abs().max(1e-8),
296            2 => self.lambda_step.abs().max(1e-8),
297            _ => 1.0,
298        }
299    }
300}
301
302impl PdeProblem<3> for HestonHawkes {
303    fn dimension_kind(&self, dim: usize) -> DimensionKind {
304        match dim {
305            0 => DimensionKind::DiscreteJump,
306            1 => DimensionKind::Diffusion,
307            _ => DimensionKind::DeterministicDrift,
308        }
309    }
310
311    fn transport(
312        &self,
313        _t: f64,
314        state: &[f64; 3],
315        control: &Self::Control,
316        derivs: &StateDerivatives<3>,
317    ) -> Transport<3> {
318        let q = state[0];
319        let v = state[1].max(1e-5);
320        let lambda = state[2].max(0.0);
321
322        let mu_v = self.v_kappa * (self.v_theta - v) - self.gamma * self.rho * self.v_xi * v * q;
323        let sigma2_v = self.v_xi.powi(2) * v;
324        let dv = self.dv.abs().max(1e-12);
325
326        let diff_term = sigma2_v / (2.0 * dv * dv);
327        let drift_abs = mu_v.abs() / dv;
328        let (plus_v, minus_v) = if mu_v >= 0.0 {
329            (diff_term + drift_abs, diff_term)
330        } else {
331            (diff_term, diff_term + drift_abs)
332        };
333
334        let net_drift_lambda = self.beta * (self.mu_lambda - lambda) + self.alpha * lambda;
335        let rate = net_drift_lambda.abs() / self.lambda_step.abs().max(1e-12);
336        let (plus_lam, minus_lam) = if net_drift_lambda >= 0.0 {
337            (rate, 0.0)
338        } else {
339            (0.0, rate)
340        };
341
342        let hamiltonian =
343            (control.bid_intensity + control.ask_intensity) / (self.gamma + self.kappa);
344        let jump_transport =
345            control.bid_intensity * derivs.fwd[0] - control.ask_intensity * derivs.bwd[0];
346        let local = -0.5 * self.gamma * v * q.powi(2);
347
348        Transport::new(
349            [control.bid_intensity, plus_v, plus_lam],
350            [control.ask_intensity, minus_v, minus_lam],
351            hamiltonian + local - jump_transport,
352        )
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    fn zero_derivs() -> StateDerivatives<3> {
361        StateDerivatives::new([0.0; 3], [0.0; 3])
362    }
363
364    fn default_model() -> HestonHawkes {
365        HestonHawkes::new(0.5, 1.5)
366            .with_heston_params(2.0, 0.25, 0.3)
367            .with_rho(0.0)
368            .with_hawkes_params(0.5, 2.0, 1.0)
369            .with_grid_steps(&[1.0, 0.02, 0.1])
370    }
371
372    #[test]
373    fn zero_gradient_gives_base_spread() {
374        let m = default_model();
375        let derivs = zero_derivs();
376        let (bid, ask) = m.get_spreads(&derivs);
377        let base = (1.0 / m.gamma) * (1.0 + m.gamma / m.kappa).ln();
378        assert!((bid - base).abs() < 1e-12);
379        assert!((ask - base).abs() < 1e-12);
380    }
381
382    #[test]
383    fn bsde_driver_excludes_variance_and_intensity_transport() {
384        let m = default_model();
385        let state = [2.0, 0.25, 1.0];
386        let control = ControlProblem::optimize(&m, 0.0, &state, &zero_derivs());
387
388        let mut derivs = zero_derivs();
389        derivs.grad[1] = 1.0;
390        derivs.grad[2] = 1.0;
391
392        let dt = 0.01;
393        let q = state[0];
394        let v = state[1];
395        let local = -0.5 * m.gamma * v * q.powi(2);
396        let rate_cap = 1.0 / dt;
397        let reward = (control.bid_intensity.min(rate_cap) + control.ask_intensity.min(rate_cap))
398            / (m.gamma + m.kappa);
399        let expected = reward + local;
400
401        assert_eq!(
402            ControlProblem::bsde_driver(&m, 0.0, &state, &control, &derivs, dt),
403            expected
404        );
405        assert!(
406            ControlProblem::bsde_driver(&m, 0.0, &state, &control, &derivs, dt)
407                != ControlProblem::driver(&m, 0.0, &state, &control, &derivs)
408        );
409    }
410
411    #[test]
412    fn next_step_controlled_uses_optimal_intensities() {
413        let m = default_model();
414        let state = [1.0, 0.25, 1.0];
415
416        let ctrl = MarketMakingControl::new(0.0, 0.0);
417        let next =
418            ControlProblem::next_step_controlled(&m, 0.0, &state, &ctrl, 0.01, &[0.0, 0.0, 0.0]);
419        assert_eq!(next[0], 1.0);
420
421        let ctrl = MarketMakingControl::new(1000.0, 0.0);
422        let next =
423            ControlProblem::next_step_controlled(&m, 0.0, &state, &ctrl, 0.01, &[0.0, 0.0, 0.0]);
424        assert_eq!(next[0], 2.0);
425    }
426
427    #[test]
428    fn rho_affects_variance_drift_in_3d() {
429        let mut derivs = zero_derivs();
430        derivs.grad[1] = 1.0;
431        let state = [2.0, 0.25, 1.0];
432        let control = MarketMakingControl::new(0.0, 0.0);
433
434        let g0 = ControlProblem::generator(
435            &HestonHawkes::new(0.5, 1.5)
436                .with_heston_params(2.0, 0.25, 0.3)
437                .with_rho(0.0)
438                .with_hawkes_params(0.5, 2.0, 1.0)
439                .with_grid_steps(&[1.0, 0.02, 0.1]),
440            0.0,
441            &state,
442            &control,
443            &derivs,
444        );
445
446        let g_pos = ControlProblem::generator(
447            &HestonHawkes::new(0.5, 1.5)
448                .with_heston_params(2.0, 0.25, 0.3)
449                .with_rho(0.5)
450                .with_hawkes_params(0.5, 2.0, 1.0)
451                .with_grid_steps(&[1.0, 0.02, 0.1]),
452            0.0,
453            &state,
454            &control,
455            &derivs,
456        );
457
458        assert!(
459            g0 > g_pos,
460            "Positive rho lowers effective variance drift in the 3D generator"
461        );
462    }
463
464    #[test]
465    fn hawkes_intensity_dimension_uses_upwinding() {
466        let m = default_model();
467        let mut derivs = zero_derivs();
468        derivs.grad[2] = 1.0;
469        let control = MarketMakingControl::new(0.0, 0.0);
470        let g = ControlProblem::generator(&m, 0.0, &[0.0, 0.25, 0.1], &control, &derivs);
471        assert!(g > 0.0);
472    }
473
474    #[test]
475    fn builder_produces_correct_params() {
476        let m = HestonHawkes::new(0.3, 2.0)
477            .with_heston_params(3.0, 0.1, 0.5)
478            .with_rho(-0.2)
479            .with_hawkes_params(0.8, 4.0, 2.0)
480            .with_grid_steps(&[2.0, 0.05, 0.2]);
481        assert_eq!(m.gamma, 0.3);
482        assert_eq!(m.kappa, 2.0);
483        assert_eq!(m.v_kappa, 3.0);
484        assert_eq!(m.rho, -0.2);
485        assert_eq!(m.alpha, 0.8);
486        assert_eq!(m.dq, 2.0);
487        assert_eq!(m.dv, 0.05);
488        assert_eq!(m.lambda_step, 0.2);
489    }
490
491    #[test]
492    fn is_diffusion_dimension_correct() {
493        let m = default_model();
494        assert!(
495            !ControlProblem::is_diffusion_dimension(&m, 0),
496            "Inventory is controlled, not diffusion"
497        );
498        assert!(
499            ControlProblem::is_diffusion_dimension(&m, 1),
500            "Variance is diffusion"
501        );
502        assert!(
503            !ControlProblem::is_diffusion_dimension(&m, 2),
504            "Hawkes intensity is deterministic drift"
505        );
506    }
507
508    #[test]
509    fn dimension_kinds_match_state_space() {
510        let m = default_model();
511        assert_eq!(
512            PdeProblem::dimension_kind(&m, 0),
513            DimensionKind::DiscreteJump
514        );
515        assert_eq!(PdeProblem::dimension_kind(&m, 1), DimensionKind::Diffusion);
516        assert_eq!(
517            PdeProblem::dimension_kind(&m, 2),
518            DimensionKind::DeterministicDrift
519        );
520    }
521}