Skip to main content

solver/models/
bilateral_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// Re-export so existing `hjb_solver::models::bilateral_hawkes::TerminalCondition` paths compile.
8pub use super::TerminalCondition;
9
10/// Avellaneda-Stoikov model with a bilateral (2-D) Hawkes process.
11///
12/// # State Space  `[q, lambda_plus, lambda_minus]`
13///
14/// | dim | symbol   | description                                      |
15/// |-----|----------|--------------------------------------------------|
16/// | 0   | q        | Inventory level (integer, jump-controlled)       |
17/// | 1   | lambda+  | Buy market-order intensity (ODE drift, no noise) |
18/// | 2   | lambda-  | Sell market-order intensity (ODE drift, no noise)|
19///
20/// # Dynamics
21///
22/// `d(lambda+) = beta*(mu - lambda+) dt + alpha dN+`
23/// `d(lambda-) = beta*(mu - lambda-) dt + alpha dN-`
24///
25/// `N+` counts buy market orders (rate `lambda+`), `N-` counts sell market orders (rate `lambda-`).
26/// Each side self-excites independently — there is no cross-excitation.
27///
28/// # Market-making mapping
29///
30/// | event              | driven by | inventory effect |
31/// |--------------------|-----------|-----------------|
32/// | Sell MO hits bid   | lambda-   | q -> q + 1      |
33/// | Buy  MO hits ask   | lambda+   | q -> q - 1      |
34///
35/// Effective fill rates:
36/// * `Lambda_bid = lambda- * exp(-kappa * delta_bid)`  (sell MO hits MM bid)
37/// * `Lambda_ask = lambda+ * exp(-kappa * delta_ask)`  (buy  MO hits MM ask)
38///
39/// # Hawkes approximation used in the FD solver
40///
41/// The lambda dimensions use the fluid (mean-field) approximation:
42///
43///   `d(lambda+)/dt ~ beta*(mu - lambda+) + alpha * lambda+`
44///   `d(lambda-)/dt ~ beta*(mu - lambda-) + alpha * lambda-`
45///
46/// This is identical to the treatment in `AvellanedaHawkes` and ensures the
47/// stationary mean of each intensity sits at `mu / (1 - alpha/beta)` in the
48/// value-function PDE.  Using discrete fill-triggered jumps instead requires
49/// `lambda_step << alpha` for accuracy; at the grid resolution affordable for
50/// 4-D tables that condition is violated, causing the PDE to see a much lower
51/// effective mean intensity than the actual simulation (`mu` vs `mu/(1-rho)`),
52/// which corrupts the value function and the extracted spreads.
53#[derive(Clone, Debug)]
54pub struct BilateralHawkes {
55    /// Risk-aversion parameter.
56    pub gamma: f64,
57    /// Price volatility.
58    pub sigma: f64,
59    /// Fill-rate decay (Avellaneda-Stoikov kappa).
60    pub kappa: f64,
61    // --- Hawkes parameters (shared for both sides) ---
62    /// Jump size added to the excited side's intensity.
63    pub alpha: f64,
64    /// Mean-reversion speed.
65    pub beta: f64,
66    /// Baseline intensity (mean-reversion target).
67    pub mu: f64,
68    /// Grid step for both lambda+ and lambda- dimensions.
69    pub lambda_step: f64,
70    /// Grid step for the inventory dimension.
71    pub dq: f64,
72    /// Minimum inventory (lower hard boundary). Defaults to -infinity.
73    pub q_min: f64,
74    /// Maximum inventory (upper hard boundary). Defaults to +infinity.
75    pub q_max: f64,
76    /// Terminal condition for the value function at T. Defaults to Zero.
77    pub terminal_condition: TerminalCondition,
78    /// Optional terminal liquidation half-spread used when terminal_condition is
79    /// LiquidationCost. If None, uses the AS base spread.
80    pub terminal_liquidation_half_spread: Option<f64>,
81}
82
83impl BilateralHawkes {
84    pub fn new(gamma: f64, sigma: f64, kappa: f64, alpha: f64, beta: f64, mu: f64) -> Self {
85        Self {
86            gamma,
87            sigma,
88            kappa,
89            alpha,
90            beta,
91            mu,
92            lambda_step: 1.0,
93            dq: 1.0,
94            q_min: f64::NEG_INFINITY,
95            q_max: f64::INFINITY,
96            terminal_condition: TerminalCondition::Zero,
97            terminal_liquidation_half_spread: None,
98        }
99    }
100
101    pub fn with_terminal_condition(mut self, terminal_condition: TerminalCondition) -> Self {
102        self.terminal_condition = terminal_condition;
103        self
104    }
105
106    pub fn with_terminal_liquidation_half_spread(mut self, half_spread: f64) -> Self {
107        self.terminal_liquidation_half_spread = Some(half_spread.max(0.0));
108        self
109    }
110
111    pub fn with_inventory_bounds(mut self, q_min: f64, q_max: f64) -> Self {
112        self.q_min = q_min;
113        self.q_max = q_max;
114        self
115    }
116
117    pub fn with_lambda_step(mut self, lambda_step: f64) -> Self {
118        self.lambda_step = lambda_step.abs().max(1e-8);
119        self
120    }
121
122    pub fn with_dq(mut self, dq: f64) -> Self {
123        self.dq = dq.abs().max(1e-8);
124        self
125    }
126
127    /// Computes optimal bid/ask half-spreads from value-function gradients.
128    ///
129    /// Bid spread: driven by the inventory gradient in the +q direction (sell MO fills).
130    /// Ask spread: driven by the inventory gradient in the -q direction (buy  MO fills).
131    pub fn get_spreads(&self, derivs: &StateDerivatives<3>) -> (f64, f64) {
132        let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
133        let delta_bid = (base_spread - derivs.fwd[0]).clamp(-10.0, 10.0);
134        let delta_ask = (base_spread + derivs.bwd[0]).clamp(-10.0, 10.0);
135        (delta_bid, delta_ask)
136    }
137
138    /// The base arrival intensity is state-dependent: returns the average of
139    /// the buy/sell intensity states for intensity-to-spread conversion.
140    pub fn fill_rate_base(&self, state: &[f64; 3]) -> f64 {
141        ((state[1] + state[2]) * 0.5).max(1e-10)
142    }
143
144    /// The fill-rate decay parameter used for intensity-to-spread conversion.
145    pub fn fill_rate_decay(&self) -> f64 {
146        self.kappa
147    }
148}
149
150impl ControlProblem<3> for BilateralHawkes {
151    type Control = MarketMakingControl;
152
153    fn optimize(&self, _t: f64, state: &[f64; 3], derivs: &StateDerivatives<3>) -> Self::Control {
154        let q = state[0];
155        let lambda_plus = state[1].max(0.0);
156        let lambda_minus = state[2].max(0.0);
157
158        let (d_bid, d_ask) = self.get_spreads(derivs);
159
160        let max_mult = 20.0_f64;
161        let lambda_bid_fill = lambda_minus * (-self.kappa * d_bid).exp().min(max_mult);
162        let lambda_ask_fill = lambda_plus * (-self.kappa * d_ask).exp().min(max_mult);
163
164        let lambda_bid_fill = if q >= self.q_max {
165            0.0
166        } else {
167            lambda_bid_fill
168        };
169        let lambda_ask_fill = if q <= self.q_min {
170            0.0
171        } else {
172            lambda_ask_fill
173        };
174
175        MarketMakingControl::new(lambda_bid_fill, lambda_ask_fill)
176    }
177
178    fn running_reward(&self, _t: f64, _state: &[f64; 3], control: &Self::Control) -> f64 {
179        (control.bid_intensity + control.ask_intensity) / (self.gamma + self.kappa)
180    }
181
182    fn bsde_driver(
183        &self,
184        _t: f64,
185        state: &[f64; 3],
186        control: &Self::Control,
187        _derivs: &StateDerivatives<3>,
188        dt: f64,
189    ) -> f64 {
190        // Full-value problem: the two Hawkes intensity drifts are already
191        // simulated forward, so the backward driver adds only the running
192        // reward plus the local inventory-risk source. The reward is bounded at
193        // `1/dt` per fill side to match the forward fill-probability clamp.
194        let q = state[0];
195        let local = -0.5 * self.gamma * self.sigma.powi(2) * q.powi(2);
196        let rate_cap = 1.0 / dt.max(1e-12);
197        let reward = (control.bid_intensity.min(rate_cap) + control.ask_intensity.min(rate_cap))
198            / (self.gamma + self.kappa);
199        reward + local
200    }
201
202    fn generator(
203        &self,
204        _t: f64,
205        state: &[f64; 3],
206        _control: &Self::Control,
207        derivs: &StateDerivatives<3>,
208    ) -> f64 {
209        let q = state[0];
210        let lambda_plus = state[1].max(0.0);
211        let lambda_minus = state[2].max(0.0);
212        let net_drift_plus = self.beta * (self.mu - lambda_plus) + self.alpha * lambda_plus;
213        let net_drift_minus = self.beta * (self.mu - lambda_minus) + self.alpha * lambda_minus;
214
215        -0.5 * self.gamma * self.sigma.powi(2) * q.powi(2)
216            + net_drift_plus * derivs.grad[1]
217            + net_drift_minus * derivs.grad[2]
218    }
219
220    fn terminal(&self, state: &[f64; 3]) -> f64 {
221        match self.terminal_condition {
222            TerminalCondition::Zero => 0.0,
223            TerminalCondition::LiquidationCost => {
224                let q = state[0];
225                let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
226                let half_spread = self.terminal_liquidation_half_spread.unwrap_or(base_spread);
227                -q.abs() * half_spread
228            }
229        }
230    }
231
232    fn discount_rate(&self, _state: &[f64; 3]) -> f64 {
233        0.0
234    }
235
236    fn constant_discount_rate(&self) -> Option<f64> {
237        Some(0.0)
238    }
239
240    fn next_step(&self, _t: f64, state: &[f64; 3], dt: f64, noise: &[f64; 3]) -> [f64; 3] {
241        let lambda_plus = state[1].max(0.0);
242        let lambda_minus = state[2].max(0.0);
243        let drift_plus = self.beta * (self.mu - lambda_plus) + self.alpha * lambda_plus;
244        let drift_minus = self.beta * (self.mu - lambda_minus) + self.alpha * lambda_minus;
245        let mut next = [
246            state[0],
247            (lambda_plus + drift_plus * dt).max(0.0),
248            (lambda_minus + drift_minus * dt).max(0.0),
249        ];
250
251        let u = normal_cdf(noise[0]);
252        let lambda_plus = state[1].max(0.0);
253        let lambda_minus = state[2].max(0.0);
254        let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
255        let lambda_bid_fill = lambda_minus * (-self.kappa * base_spread).exp();
256        let lambda_ask_fill = lambda_plus * (-self.kappa * base_spread).exp();
257        let p_bid = (lambda_bid_fill * dt).clamp(0.0, 1.0);
258        let p_ask = (lambda_ask_fill * dt).clamp(0.0, 1.0);
259        if u < p_bid {
260            next[0] += 1.0;
261            next[2] += self.alpha;
262        } else if u > 1.0 - p_ask {
263            next[0] -= 1.0;
264            next[1] += self.alpha;
265        }
266
267        next[1] = next[1].max(0.0);
268        next[2] = next[2].max(0.0);
269        next
270    }
271
272    fn next_step_controlled(
273        &self,
274        _t: f64,
275        state: &[f64; 3],
276        control: &Self::Control,
277        dt: f64,
278        noise: &[f64; 3],
279    ) -> [f64; 3] {
280        let lambda_plus = state[1].max(0.0);
281        let lambda_minus = state[2].max(0.0);
282        let drift_plus = self.beta * (self.mu - lambda_plus) + self.alpha * lambda_plus;
283        let drift_minus = self.beta * (self.mu - lambda_minus) + self.alpha * lambda_minus;
284        let mut next = [
285            state[0],
286            (lambda_plus + drift_plus * dt).max(0.0),
287            (lambda_minus + drift_minus * dt).max(0.0),
288        ];
289
290        // The optimal control already carries the fill intensities, so the
291        // Bernoulli fill events use them instead of a frozen proxy quote.
292        let u = normal_cdf(noise[0]);
293        let p_bid = (control.bid_intensity * dt).clamp(0.0, 1.0);
294        let p_ask = (control.ask_intensity * dt).clamp(0.0, 1.0);
295        if u < p_bid {
296            next[0] += 1.0;
297            next[2] += self.alpha;
298        } else if u > 1.0 - p_ask {
299            next[0] -= 1.0;
300            next[1] += self.alpha;
301        }
302
303        next[1] = next[1].max(0.0);
304        next[2] = next[2].max(0.0);
305        next
306    }
307
308    fn is_diffusion_dimension(&self, _dim: usize) -> bool {
309        false
310    }
311
312    fn gradient_step(&self, dim: usize) -> f64 {
313        match dim {
314            1 | 2 => self.lambda_step.abs().max(1e-8),
315            _ => 1.0,
316        }
317    }
318}
319
320impl PdeProblem<3> for BilateralHawkes {
321    fn dimension_kind(&self, dim: usize) -> DimensionKind {
322        match dim {
323            0 => DimensionKind::DiscreteJump,
324            _ => DimensionKind::DeterministicDrift,
325        }
326    }
327
328    fn transport(
329        &self,
330        _t: f64,
331        state: &[f64; 3],
332        control: &Self::Control,
333        derivs: &StateDerivatives<3>,
334    ) -> Transport<3> {
335        let q = state[0];
336        let lambda_plus = state[1].max(0.0);
337        let lambda_minus = state[2].max(0.0);
338        let net_drift_plus = self.beta * (self.mu - lambda_plus) + self.alpha * lambda_plus;
339        let net_drift_minus = self.beta * (self.mu - lambda_minus) + self.alpha * lambda_minus;
340        let step = self.lambda_step.abs().max(1e-12);
341
342        let rate_plus = net_drift_plus.abs() / step;
343        let rate_minus = net_drift_minus.abs() / step;
344        let (lp_plus, lp_minus) = if net_drift_plus >= 0.0 {
345            (rate_plus, 0.0)
346        } else {
347            (0.0, rate_plus)
348        };
349        let (lm_plus, lm_minus) = if net_drift_minus >= 0.0 {
350            (rate_minus, 0.0)
351        } else {
352            (0.0, rate_minus)
353        };
354
355        let hamiltonian =
356            (control.bid_intensity + control.ask_intensity) / (self.gamma + self.kappa);
357        let jump_transport =
358            control.bid_intensity * derivs.fwd[0] - control.ask_intensity * derivs.bwd[0];
359        let local = -0.5 * self.gamma * self.sigma.powi(2) * q.powi(2);
360
361        Transport::new(
362            [control.bid_intensity, lp_plus, lm_plus],
363            [control.ask_intensity, lp_minus, lm_minus],
364            hamiltonian + local - jump_transport,
365        )
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    fn zero_derivs() -> StateDerivatives<3> {
374        StateDerivatives::new([0.0; 3], [0.0; 3])
375    }
376
377    fn default_model() -> BilateralHawkes {
378        BilateralHawkes::new(0.5, 0.2, 1.5, 0.5, 2.0, 1.0)
379            .with_lambda_step(0.1)
380            .with_dq(1.0)
381    }
382
383    #[test]
384    fn zero_gradient_gives_symmetric_base_spread() {
385        let m = default_model();
386        let (bid, ask) = m.get_spreads(&zero_derivs());
387        let base = (1.0 / m.gamma) * (1.0 + m.gamma / m.kappa).ln();
388        assert!((bid - base).abs() < 1e-12);
389        assert!((ask - base).abs() < 1e-12);
390    }
391
392    #[test]
393    fn bsde_driver_excludes_intensity_transport() {
394        let m = default_model();
395        let state = [2.0, 1.0, 1.0];
396        let control = ControlProblem::optimize(&m, 0.0, &state, &zero_derivs());
397
398        let mut derivs = zero_derivs();
399        derivs.grad[1] = 1.0;
400        derivs.grad[2] = 1.0;
401
402        let dt = 0.01;
403        let q = state[0];
404        let local = -0.5 * m.gamma * m.sigma.powi(2) * q.powi(2);
405        let rate_cap = 1.0 / dt;
406        let reward = (control.bid_intensity.min(rate_cap) + control.ask_intensity.min(rate_cap))
407            / (m.gamma + m.kappa);
408        let expected = reward + local;
409
410        assert_eq!(
411            ControlProblem::bsde_driver(&m, 0.0, &state, &control, &derivs, dt),
412            expected
413        );
414        assert!(
415            ControlProblem::bsde_driver(&m, 0.0, &state, &control, &derivs, dt)
416                != ControlProblem::driver(&m, 0.0, &state, &control, &derivs),
417            "full driver adds intensity transport, bsde driver must not"
418        );
419    }
420
421    #[test]
422    fn next_step_controlled_uses_optimal_intensities() {
423        let m = default_model();
424        let state = [1.0, 1.0, 1.0];
425
426        let ctrl = MarketMakingControl::new(0.0, 0.0);
427        let next =
428            ControlProblem::next_step_controlled(&m, 0.0, &state, &ctrl, 0.01, &[0.0, 0.0, 0.0]);
429        assert_eq!(next[0], 1.0);
430
431        let ctrl = MarketMakingControl::new(1000.0, 0.0);
432        let next =
433            ControlProblem::next_step_controlled(&m, 0.0, &state, &ctrl, 0.01, &[0.0, 0.0, 0.0]);
434        assert_eq!(next[0], 2.0);
435    }
436
437    #[test]
438    fn bid_driven_by_lambda_minus_ask_by_lambda_plus() {
439        let m = default_model();
440        let d = zero_derivs();
441        let ctrl_high_minus = ControlProblem::optimize(&m, 0.0, &[0.0, 1.0, 5.0], &d);
442        let ctrl_low_minus = ControlProblem::optimize(&m, 0.0, &[0.0, 1.0, 0.5], &d);
443        assert!(
444            ctrl_high_minus.bid_intensity > ctrl_low_minus.bid_intensity,
445            "bid fill should scale with lambda-"
446        );
447
448        let ctrl_high_plus = ControlProblem::optimize(&m, 0.0, &[0.0, 5.0, 1.0], &d);
449        let ctrl_low_plus = ControlProblem::optimize(&m, 0.0, &[0.0, 0.5, 1.0], &d);
450        assert!(
451            ctrl_high_plus.ask_intensity > ctrl_low_plus.ask_intensity,
452            "ask fill should scale with lambda+"
453        );
454    }
455
456    #[test]
457    fn symmetric_state_gives_equal_bid_ask() {
458        let m = default_model();
459        let d = zero_derivs();
460        let ctrl = ControlProblem::optimize(&m, 0.0, &[0.0, 1.0, 1.0], &d);
461        assert!(
462            (ctrl.bid_intensity - ctrl.ask_intensity).abs() < 1e-12,
463            "bid fill == ask fill when lambda+ == lambda- and q == 0"
464        );
465    }
466
467    #[test]
468    fn generator_encodes_intensity_upwinding() {
469        let m = default_model();
470        let control = MarketMakingControl::new(1.0, 1.0);
471        let state = [0.0, 0.1, 1.0];
472
473        let zero = StateDerivatives::new([0.0; 3], [0.0; 3]);
474        let mut plus_grad = zero;
475        plus_grad.grad[1] = 1.0;
476
477        let base = ControlProblem::generator(&m, 0.0, &state, &control, &zero);
478        let upwind = ControlProblem::generator(&m, 0.0, &state, &control, &plus_grad);
479        // At lambda+ = 0.1 the net drift is positive, so a positive gradient in
480        // the lambda+ dimension increases the generator.
481        assert!(upwind > base);
482    }
483
484    #[test]
485    fn next_step_clamped_to_nonnegative() {
486        let m = default_model();
487        let state = [0.0, 0.0, 0.0];
488        let next = ControlProblem::next_step(&m, 0.0, &state, 1.0, &[0.0; 3]);
489        assert!(next[1] >= 0.0);
490        assert!(next[2] >= 0.0);
491    }
492
493    #[test]
494    fn terminal_zero_condition_returns_zero() {
495        let m = default_model().with_terminal_condition(TerminalCondition::Zero);
496        let v = ControlProblem::terminal(&m, &[3.0, 1.0, 1.0]);
497        assert!(v.abs() < 1e-12);
498    }
499
500    #[test]
501    fn terminal_liquidation_cost_matches_formula() {
502        let m = default_model().with_terminal_condition(TerminalCondition::LiquidationCost);
503        let q: f64 = -4.0;
504        let base = (1.0 / m.gamma) * (1.0 + m.gamma / m.kappa).ln();
505        let expected = -q.abs() * base;
506        let got = ControlProblem::terminal(&m, &[q, 1.2, 0.8]);
507        assert!((got - expected).abs() < 1e-12);
508    }
509}