Skip to main content

solver/models/
heston_hawkes.rs

1use super::traits::{ControlOutput, Gradients, Model};
2
3/// Combined Heston-Hawkes Model for Market Making (N=3).
4///
5/// Merges stochastic volatility (Heston) with self-exciting order flow (Hawkes)
6/// into a single three-dimensional model, demonstrating seamless N=3 extensibility.
7///
8/// # State Space
9/// `[q, v, lambda]` where:
10/// - `q` (dim 0): Inventory level (discrete, jump-controlled)
11/// - `v` (dim 1): Stochastic variance following CIR dynamics
12/// - `lambda` (dim 2): Hawkes order arrival intensity (mean-reverting with self-excitation)
13///
14/// # Dynamics
15/// * Inventory: $dq = +1$ (buy fill) or $-1$ (sell fill)
16/// * Variance: $dv_t = \kappa_v (\theta - v_t) dt + \xi \sqrt{v_t} dW^v_t$
17/// * Intensity: $d\lambda_t = \beta (\mu_\lambda - \lambda_t) dt + \alpha \lambda_t dt$
18///
19/// The optimal spreads incorporate both volatility risk premia (from Heston)
20/// and state-dependent arrival rates (from Hawkes).
21#[derive(Clone, Debug)]
22pub struct HestonHawkes {
23    // -- Market-making parameters --
24    /// Risk aversion for inventory holding.
25    pub gamma: f64,
26    /// Order filling intensity decay.
27    pub kappa: f64,
28
29    // -- Heston parameters --
30    /// Mean reversion speed for variance.
31    pub v_kappa: f64,
32    /// Long-run mean variance.
33    pub v_theta: f64,
34    /// Volatility of variance.
35    pub v_xi: f64,
36    /// Correlation between price and variance Brownian motions ($\rho$).
37    /// Enters the HJB through the cross-variation drift: $-\gamma \rho \xi v q$.
38    pub rho: f64,
39    /// Grid step size for variance dimension.
40    pub dv: f64,
41
42    // -- Hawkes parameters --
43    /// Self-excitation jump size.
44    pub alpha: f64,
45    /// Mean-reversion speed of intensity.
46    pub beta: f64,
47    /// Baseline intensity level.
48    pub mu_lambda: f64,
49    /// Grid step for intensity dimension.
50    pub lambda_step: f64,
51    /// Grid step for inventory dimension.
52    pub dq: f64,
53}
54
55impl HestonHawkes {
56    pub fn new(gamma: f64, kappa: f64) -> Self {
57        Self {
58            gamma,
59            kappa,
60            v_kappa: 2.0,
61            v_theta: 0.25,
62            v_xi: 0.3,
63            rho: 0.0,
64            dv: 1.0,
65            alpha: 0.5,
66            beta: 2.0,
67            mu_lambda: 1.0,
68            lambda_step: 1.0,
69            dq: 1.0,
70        }
71    }
72
73    pub fn with_heston_params(mut self, v_kappa: f64, v_theta: f64, v_xi: f64) -> Self {
74        self.v_kappa = v_kappa;
75        self.v_theta = v_theta;
76        self.v_xi = v_xi;
77        self
78    }
79
80    pub fn with_rho(mut self, rho: f64) -> Self {
81        self.rho = rho;
82        self
83    }
84
85    pub fn with_hawkes_params(mut self, alpha: f64, beta: f64, mu_lambda: f64) -> Self {
86        self.alpha = alpha;
87        self.beta = beta;
88        self.mu_lambda = mu_lambda;
89        self
90    }
91
92    /// Sets grid step sizes from a Grid<3>.
93    /// Dimension 0 = inventory (dq), Dimension 1 = variance (dv), Dimension 2 = intensity (lambda_step).
94    pub fn with_grid_steps(mut self, dx: &[f64; 3]) -> Self {
95        self.dq = dx[0].abs().max(1e-8);
96        self.dv = dx[1].abs().max(1e-8);
97        self.lambda_step = dx[2].abs().max(1e-8);
98        self
99    }
100
101    /// Compute optimal bid/ask spreads given the current state and value gradients.
102    ///
103    /// Combines the Heston volatility risk premium with the Hawkes intensity dependence.
104    pub fn get_spreads(&self, _q: f64, v: f64, grads: &Gradients<3>) -> (f64, f64) {
105        // Pure HJB-derived formula: all variance risk is encoded in V(q, v, lambda).
106        // Removing ad-hoc premia ensures the degenerate limit (kappa_v -> inf, alpha -> 0)
107        // collapses cleanly to the base AS model.
108        let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
109
110        let _v_pos = v.max(0.0);
111
112        // Value gradient w.r.t. inventory
113        let dv_dq_buy = grads.fwd[0];
114        let dv_dq_sell = grads.bwd[0];
115
116        let mut delta_bid = base_spread - dv_dq_buy;
117        let mut delta_ask = base_spread + dv_dq_sell;
118
119        delta_bid = delta_bid.clamp(-5.0, 10.0);
120        delta_ask = delta_ask.clamp(-5.0, 10.0);
121
122        (delta_bid, delta_ask)
123    }
124}
125
126impl Model<3> for HestonHawkes {
127    type Process = ();
128
129    fn process(&self) {}
130
131    fn optimize(&self, state: &[f64; 3], grads: &Gradients<3>) -> ControlOutput<3> {
132        let q = state[0];
133        let v = state[1];
134        let lambda = state[2];
135
136        // -- Dimension 0: Inventory controls --
137        let (d_bid, d_ask) = self.get_spreads(q, v, grads);
138
139        // Arrival rate is modulated by Hawkes intensity lambda
140        // Cap fill-rate multiplier to prevent blow-up at extreme inventories
141        let max_fill_mult = 20.0_f64;
142        let lambda_bid = lambda * (-self.kappa * d_bid).exp().min(max_fill_mult);
143        let lambda_ask = lambda * (-self.kappa * d_ask).exp().min(max_fill_mult);
144
145        let hamiltonian_inv = (lambda_bid + lambda_ask) / (self.gamma + self.kappa);
146        let risk_penalty = -0.5 * self.gamma * v * q.powi(2);
147
148        // Cancel solver's q-dimension transport by dq factor to match matrix contribution
149        let drift_correction_q = self.dq * (lambda_bid * grads.fwd[0] - lambda_ask * grads.bwd[0]);
150
151        // -- Dimension 1: Heston variance (CIR diffusion) --
152        // Physical CIR drift plus the HJB cross-variation correction.
153        // d[q*dS, dv] = rho * xi * v * q * dt produces -gamma * rho * xi * v * q.
154        let v_pos = v.max(1e-5);
155        let mu_v =
156            self.v_kappa * (self.v_theta - v_pos) - self.gamma * self.rho * self.v_xi * v_pos * q;
157        let sigma2_v = self.v_xi.powi(2) * v_pos;
158
159        let diff_term = sigma2_v / (2.0 * self.dv.powi(2));
160        let drift_term_abs = mu_v.abs() / self.dv;
161
162        let mut lambda_v_plus = diff_term;
163        let mut lambda_v_minus = diff_term;
164        if mu_v > 0.0 {
165            lambda_v_plus += drift_term_abs;
166        } else {
167            lambda_v_minus += drift_term_abs;
168        }
169
170        // -- Dimension 2: Hawkes intensity (properly in matrix for implicit stability) --
171        let net_drift_lambda = self.beta * (self.mu_lambda - lambda) + self.alpha * lambda;
172        let drift_abs_lambda = net_drift_lambda.abs() / self.lambda_step;
173
174        let (lambda_lam_plus, lambda_lam_minus) = if net_drift_lambda >= 0.0 {
175            (drift_abs_lambda, 0.0)
176        } else {
177            (0.0, drift_abs_lambda)
178        };
179
180        ControlOutput {
181            lambda_plus: [lambda_bid, lambda_v_plus, lambda_lam_plus],
182            lambda_minus: [lambda_ask, lambda_v_minus, lambda_lam_minus],
183            flow: hamiltonian_inv + risk_penalty - drift_correction_q,
184        }
185    }
186
187    fn terminal(&self, _state: &[f64; 3]) -> f64 {
188        0.0
189    }
190
191    fn constant_discount_rate(&self) -> Option<f64> {
192        Some(0.0)
193    }
194
195    fn next_step(&self, current_state: &[f64; 3], dt: f64, noise: &[f64; 3]) -> [f64; 3] {
196        let q = current_state[0];
197        let v = current_state[1];
198        let lambda = current_state[2];
199        let sqrt_dt = dt.sqrt();
200
201        // Dim 0: Inventory held fixed in forward diffusion
202        let next_q = q;
203
204        // Dim 1: Variance — CIR with reflection
205        let v_pos = v.max(1e-5);
206        let drift_v = self.v_kappa * (self.v_theta - v_pos) * dt;
207        let diff_v = self.v_xi * v_pos.sqrt() * sqrt_dt * noise[1];
208        let mut next_v = v_pos + drift_v + diff_v;
209        if next_v < 0.0 {
210            next_v = -next_v;
211        }
212
213        // Dim 2: Hawkes intensity — first-order ODE (no diffusion)
214        let net_drift = self.beta * (self.mu_lambda - lambda) + self.alpha * lambda;
215        let next_lambda = (lambda + net_drift * dt).max(0.0);
216
217        [next_q, next_v, next_lambda]
218    }
219
220    fn is_diffusion_dimension(&self, dim: usize) -> bool {
221        // dim 1 (variance) is a true diffusion
222        // dim 2 (Hawkes) is treated as deterministic drift
223        dim == 1
224    }
225
226    fn is_integer_dimension(&self, dim: usize) -> bool {
227        dim == 0 // q is discrete integer; variance and lambda are continuous
228    }
229
230    fn gradient_step(&self, dim: usize) -> f64 {
231        match dim {
232            1 => self.dv.abs().max(1e-8),
233            2 => self.lambda_step.abs().max(1e-8),
234            _ => 1.0,
235        }
236    }
237
238    /// The base arrival intensity is state-dependent: returns the current
239    /// intensity `state[2]` (clamped above 0) for intensity-to-spread conversion.
240    fn fill_rate_base(&self, state: &[f64; 3]) -> f64 {
241        state[2].max(1e-10)
242    }
243
244    fn fill_rate_decay(&self) -> f64 {
245        self.kappa
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::super::traits::{Gradients, Model};
252    use super::*;
253
254    fn zero_grads() -> Gradients<3> {
255        Gradients {
256            fwd: [0.0; 3],
257            bwd: [0.0; 3],
258        }
259    }
260
261    fn default_model() -> HestonHawkes {
262        HestonHawkes::new(0.5, 1.5)
263            .with_heston_params(2.0, 0.25, 0.3)
264            .with_rho(0.0)
265            .with_hawkes_params(0.5, 2.0, 1.0)
266            .with_grid_steps(&[1.0, 0.02, 0.1])
267    }
268
269    #[test]
270    fn zero_gradient_gives_base_spread() {
271        let m = default_model();
272        let grads = zero_grads();
273        let (bid, ask) = m.get_spreads(0.0, 0.25, &grads);
274        let base = (1.0 / m.gamma) * (1.0 + m.gamma / m.kappa).ln();
275        assert!((bid - base).abs() < 1e-12);
276        assert!((ask - base).abs() < 1e-12);
277    }
278
279    #[test]
280    fn rho_affects_variance_drift_in_3d() {
281        let grads = zero_grads();
282        let state = [2.0, 0.25, 1.0]; // q=2, v=theta, lambda=1
283
284        let ctrl0 = HestonHawkes::new(0.5, 1.5)
285            .with_heston_params(2.0, 0.25, 0.3)
286            .with_rho(0.0)
287            .with_hawkes_params(0.5, 2.0, 1.0)
288            .with_grid_steps(&[1.0, 0.02, 0.1])
289            .optimize(&state, &grads);
290
291        let ctrl_pos = HestonHawkes::new(0.5, 1.5)
292            .with_heston_params(2.0, 0.25, 0.3)
293            .with_rho(0.5)
294            .with_hawkes_params(0.5, 2.0, 1.0)
295            .with_grid_steps(&[1.0, 0.02, 0.1])
296            .optimize(&state, &grads);
297
298        assert!(
299            ctrl_pos.lambda_minus[1] > ctrl0.lambda_minus[1],
300            "Positive rho should increase downward variance transport in 3D model"
301        );
302    }
303
304    #[test]
305    fn hawkes_intensity_dimension_uses_upwinding() {
306        let m = default_model();
307        let grads = zero_grads();
308        // Low lambda: drift positive -> lambda_plus[2] > 0
309        let ctrl = m.optimize(&[0.0, 0.25, 0.1], &grads);
310        assert!(ctrl.lambda_plus[2] > 0.0);
311        assert!(ctrl.lambda_minus[2] < 1e-12);
312    }
313
314    #[test]
315    fn builder_produces_correct_params() {
316        let m = HestonHawkes::new(0.3, 2.0)
317            .with_heston_params(3.0, 0.1, 0.5)
318            .with_rho(-0.2)
319            .with_hawkes_params(0.8, 4.0, 2.0)
320            .with_grid_steps(&[2.0, 0.05, 0.2]);
321        assert_eq!(m.gamma, 0.3);
322        assert_eq!(m.kappa, 2.0);
323        assert_eq!(m.v_kappa, 3.0);
324        assert_eq!(m.rho, -0.2);
325        assert_eq!(m.alpha, 0.8);
326        assert_eq!(m.dq, 2.0);
327        assert_eq!(m.dv, 0.05);
328        assert_eq!(m.lambda_step, 0.2);
329    }
330
331    #[test]
332    fn all_three_dimensions_have_transport() {
333        let m = default_model();
334        let grads = zero_grads();
335        let ctrl = m.optimize(&[1.0, 0.1, 0.5], &grads);
336        assert!(ctrl.lambda_plus[0] > 0.0);
337        assert!(ctrl.lambda_minus[0] > 0.0);
338        assert!(ctrl.lambda_plus[1] > 0.0 || ctrl.lambda_minus[1] > 0.0);
339        assert!(ctrl.lambda_plus[2] > 0.0 || ctrl.lambda_minus[2] > 0.0);
340    }
341
342    #[test]
343    fn is_diffusion_dimension_correct() {
344        let m = default_model();
345        assert!(
346            !m.is_diffusion_dimension(0),
347            "Inventory is controlled, not diffusion"
348        );
349        assert!(m.is_diffusion_dimension(1), "Variance is diffusion");
350        assert!(
351            !m.is_diffusion_dimension(2),
352            "Hawkes intensity is deterministic drift"
353        );
354    }
355}