Skip to main content

solver/models/
avellaneda_hawkes.rs

1use super::TerminalCondition;
2use super::normal_cdf;
3use super::traits::{ControlOutput, Gradients, Model};
4
5/// Avellaneda-Stoikov model with Hawkes Process for order arrival intensity.
6///
7/// State Space: [Inventory (q), Intensity (lambda)]
8/// - q: Discrete inventory level
9/// - lambda: Continuous order arrival intensity
10///
11/// Dynamics:
12/// - q: Jumps ±1 upon fills.
13/// - lambda: Mean-reverting jump-diffusion (approximated).
14///   d(lambda) = beta * (mu - lambda) * dt + alpha * dN_market
15///   where N_market is the market order counting process with intensity lambda.
16///
17/// The solver uses a grid-based approach. We approximate the Hawkes dynamics
18/// on the lambda dimension using an upwind finite difference scheme mapped to
19/// the solver's jump interface.
20#[derive(Clone)]
21pub struct AvellanedaHawkes {
22    pub gamma: f64,
23    pub sigma: f64,
24    pub kappa: f64,
25    // Hawkes Parameters
26    pub alpha: f64, // Jump size of intensity
27    pub beta: f64,  // Decay rate of intensity
28    pub mu: f64,    // Baseline intensity level (mean reversion target)
29    pub lambda_step: f64,
30    /// Grid step size for inventory dimension (set from `grid.dx[0]` for FD).
31    pub dq: f64,
32    /// Terminal condition V(T, q, lambda).  Defaults to Zero.
33    pub terminal_condition: TerminalCondition,
34    /// Minimum inventory (lower hard boundary). Defaults to -infinity (no constraint).
35    pub q_min: f64,
36    /// Maximum inventory (upper hard boundary). Defaults to +infinity (no constraint).
37    pub q_max: f64,
38    /// Optional terminal liquidation half-spread used when terminal_condition is
39    /// LiquidationCost. If None, uses the AS base spread.
40    pub terminal_liquidation_half_spread: Option<f64>,
41}
42
43impl AvellanedaHawkes {
44    pub fn new(gamma: f64, sigma: f64, kappa: f64, alpha: f64, beta: f64, mu: f64) -> Self {
45        Self {
46            gamma,
47            sigma,
48            kappa,
49            alpha,
50            beta,
51            mu,
52            lambda_step: 1.0,
53            dq: 1.0,
54            terminal_condition: TerminalCondition::Zero,
55            q_min: f64::NEG_INFINITY,
56            q_max: f64::INFINITY,
57            terminal_liquidation_half_spread: None,
58        }
59    }
60
61    pub fn with_terminal_liquidation_half_spread(mut self, half_spread: f64) -> Self {
62        self.terminal_liquidation_half_spread = Some(half_spread.max(0.0));
63        self
64    }
65
66    pub fn with_inventory_bounds(mut self, q_min: f64, q_max: f64) -> Self {
67        self.q_min = q_min;
68        self.q_max = q_max;
69        self
70    }
71
72    pub fn with_terminal_condition(mut self, terminal_condition: TerminalCondition) -> Self {
73        self.terminal_condition = terminal_condition;
74        self
75    }
76
77    pub fn with_lambda_step(mut self, lambda_step: f64) -> Self {
78        self.lambda_step = lambda_step.abs().max(1e-8);
79        self
80    }
81
82    pub fn with_dq(mut self, dq: f64) -> Self {
83        self.dq = dq.abs().max(1e-8);
84        self
85    }
86
87    /// Computes optimal spreads based on current inventory and intensity.
88    /// Note: The base intensity 'A' from standard Avellaneda is replaced by the state variable 'lambda'.
89    pub fn get_spreads(&self, _lambda: f64, grads: &Gradients<2>) -> (f64, f64) {
90        // Standard Avellaneda formula: delta = (1/gamma) * ln(1 + gamma/kappa) + ...
91        // The 'A' parameter cancels out in the spread optimization, so it doesn't appear here explicitly.
92        // However, the value function V depends on lambda, so grads will reflect that.
93
94        let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
95
96        let dv_dq_buy = grads.fwd[0];
97        let dv_dq_sell = grads.bwd[0];
98
99        let mut delta_bid = base_spread - dv_dq_buy;
100        let mut delta_ask = base_spread + dv_dq_sell;
101
102        let min_spread = -10.0;
103        let max_spread = 10.0;
104        delta_bid = delta_bid.max(min_spread).min(max_spread);
105        delta_ask = delta_ask.max(min_spread).min(max_spread);
106
107        (delta_bid, delta_ask)
108    }
109}
110
111impl Model<2> for AvellanedaHawkes {
112    type Process = ();
113
114    fn process(&self) {}
115
116    fn optimize(&self, state: &[f64; 2], grads: &Gradients<2>) -> ControlOutput<2> {
117        let q = state[0];
118        let lambda = state[1]; // Current intensity state
119
120        // 1. Optimize Inventory Controls (Dim 0)
121        let (d_bid, d_ask) = self.get_spreads(lambda, grads);
122
123        // Fill probabilities (intensities)
124        // Cap fill-rate multiplier to prevent blow-up at extreme inventories
125        let max_fill_mult = 20.0_f64;
126        let lambda_bid = lambda * (-self.kappa * d_bid).exp().min(max_fill_mult);
127        let lambda_ask = lambda * (-self.kappa * d_ask).exp().min(max_fill_mult);
128
129        // Shut off the forbidden quoting side at the inventory boundary.
130        let lambda_bid = if q >= self.q_max { 0.0 } else { lambda_bid };
131        let lambda_ask = if q <= self.q_min { 0.0 } else { lambda_ask };
132
133        // Hamiltonian part for Inventory
134        let hamiltonian_inv = (lambda_bid + lambda_ask) / (self.gamma + self.kappa);
135
136        // Risk Penalty
137        let risk_penalty = -0.5 * self.gamma * self.sigma.powi(2) * q.powi(2);
138
139        // Cancel solver's q-dimension transport. The solver adds:
140        //   lambda_bid * (V[q+dq]-V[q]) + lambda_ask * (V[q-dq]-V[q])
141        //   = dq * (lambda_bid * fwd[0] - lambda_ask * bwd[0])
142        // We must subtract exactly that amount to replace it with the Hamiltonian.
143        let drift_correction_q = self.dq * (lambda_bid * grads.fwd[0] - lambda_ask * grads.bwd[0]);
144
145        // 2. Hawkes Dynamics (Dim 1) — properly encoded in matrix for implicit stability
146        // Expected drift: D = beta*(mu - lambda) + lambda * alpha
147        let net_drift_lambda = self.beta * (self.mu - lambda) + lambda * self.alpha;
148        let drift_abs = net_drift_lambda.abs() / self.lambda_step;
149
150        let (lambda_lam_plus, lambda_lam_minus) = if net_drift_lambda >= 0.0 {
151            (drift_abs, 0.0)
152        } else {
153            (0.0, drift_abs)
154        };
155
156        ControlOutput {
157            lambda_plus: [lambda_bid, lambda_lam_plus],
158            lambda_minus: [lambda_ask, lambda_lam_minus],
159            flow: hamiltonian_inv + risk_penalty - drift_correction_q,
160        }
161    }
162
163    fn terminal(&self, state: &[f64; 2]) -> f64 {
164        match self.terminal_condition {
165            TerminalCondition::Zero => 0.0,
166            TerminalCondition::LiquidationCost => {
167                let q = state[0];
168                let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
169                let half_spread = self.terminal_liquidation_half_spread.unwrap_or(base_spread);
170                -q.abs() * half_spread
171            }
172        }
173    }
174
175    fn constant_discount_rate(&self) -> Option<f64> {
176        Some(0.0)
177    }
178
179    fn next_step(&self, current_state: &[f64; 2], dt: f64, noise: &[f64; 2]) -> [f64; 2] {
180        let mut next = *current_state;
181        let lambda = current_state[1];
182        let net_drift_lambda = self.beta * (self.mu - lambda) + self.alpha * lambda;
183
184        // Keep Hawkes intensity evolution consistent with the first-order drift treatment
185        // used in the finite-difference HJB discretization.
186        let _ = noise;
187        next[1] += net_drift_lambda * dt;
188        next[1] = next[1].max(0.0);
189
190        next
191    }
192
193    fn next_step_controlled(
194        &self,
195        current_state: &[f64; 2],
196        control: &ControlOutput<2>,
197        dt: f64,
198        noise: &[f64; 2],
199    ) -> [f64; 2] {
200        let mut next = *current_state;
201        let lambda = current_state[1];
202
203        // Dim 0 (q): Bernoulli fill simulation.
204        // Transform noise[0] (N(0,1)) to uniform [0,1] via normal CDF, then gate against
205        // fill probabilities.  Bid and ask fills are mutually exclusive within one step,
206        // which is accurate to O(dt^2) via the small-dt Poisson approximation.
207        let u = normal_cdf(noise[0]);
208        let p_bid = (control.lambda_plus[0] * dt).clamp(0.0, 1.0);
209        let p_ask = (control.lambda_minus[0] * dt).clamp(0.0, 1.0);
210        if u < p_bid {
211            next[0] += 1.0; // bid fill: MM sold, inventory increases
212        } else if u > 1.0 - p_ask {
213            next[0] -= 1.0; // ask fill: MM bought, inventory decreases
214        }
215
216        // Dim 1 (lambda): deterministic mean-reversion drift (same as next_step).
217        let net_drift_lambda = self.beta * (self.mu - lambda) + self.alpha * lambda;
218        let _ = noise[1];
219        next[1] += net_drift_lambda * dt;
220        next[1] = next[1].max(0.0);
221
222        next
223    }
224
225    fn is_diffusion_dimension(&self, _dim: usize) -> bool {
226        false // neither q nor lambda has Brownian noise
227    }
228
229    fn is_integer_dimension(&self, dim: usize) -> bool {
230        dim == 0 // q is a discrete integer; lambda is continuous
231    }
232
233    fn gradient_step(&self, dim: usize) -> f64 {
234        if dim == 1 { self.lambda_step } else { 1.0 }
235    }
236
237    /// The base arrival intensity is state-dependent: returns the current
238    /// intensity `state[1]` (clamped above 0) for intensity-to-spread conversion.
239    fn fill_rate_base(&self, state: &[f64; 2]) -> f64 {
240        state[1].max(1e-10)
241    }
242
243    fn fill_rate_decay(&self) -> f64 {
244        self.kappa
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::super::traits::{Gradients, Model};
251    use super::*;
252
253    fn zero_grads() -> Gradients<2> {
254        Gradients {
255            fwd: [0.0; 2],
256            bwd: [0.0; 2],
257        }
258    }
259
260    fn default_model() -> AvellanedaHawkes {
261        AvellanedaHawkes::new(0.5, 0.5, 1.5, 0.5, 2.0, 1.0)
262            .with_lambda_step(0.1)
263            .with_dq(1.0)
264    }
265
266    #[test]
267    fn zero_gradient_gives_base_spread() {
268        let m = default_model();
269        let grads = zero_grads();
270        let (bid, ask) = m.get_spreads(1.0, &grads);
271        let base = (1.0 / m.gamma) * (1.0 + m.gamma / m.kappa).ln();
272        assert!((bid - base).abs() < 1e-12);
273        assert!((ask - base).abs() < 1e-12);
274    }
275
276    #[test]
277    fn intensity_scales_fill_rates() {
278        let m = default_model();
279        let grads = zero_grads();
280        let ctrl_low = m.optimize(&[0.0, 0.5], &grads);
281        let ctrl_high = m.optimize(&[0.0, 2.0], &grads);
282        let total_low = ctrl_low.lambda_plus[0] + ctrl_low.lambda_minus[0];
283        let total_high = ctrl_high.lambda_plus[0] + ctrl_high.lambda_minus[0];
284        assert!(
285            total_high > total_low,
286            "Higher intensity should give higher fill rates: low={}, high={}",
287            total_low,
288            total_high
289        );
290    }
291
292    #[test]
293    fn subcritical_regime_mean_reverts() {
294        let m = AvellanedaHawkes::new(0.5, 0.5, 1.5, 0.5, 2.0, 1.0);
295        assert!(m.alpha < m.beta, "Subcritical: alpha < beta");
296
297        // Equilibrium intensity = beta * mu / (beta - alpha)
298        let eq = m.beta * m.mu / (m.beta - m.alpha);
299
300        let state_high = [0.0, eq + 1.0];
301        let next_high = m.next_step(&state_high, 0.01, &[0.0, 0.0]);
302        assert!(
303            next_high[1] < state_high[1],
304            "Above equilibrium, intensity should decrease"
305        );
306
307        let state_low = [0.0, eq - 0.5];
308        let next_low = m.next_step(&state_low, 0.01, &[0.0, 0.0]);
309        assert!(
310            next_low[1] > state_low[1],
311            "Below equilibrium, intensity should increase"
312        );
313    }
314
315    #[test]
316    fn intensity_drift_uses_upwinding() {
317        let m = default_model();
318        let grads = zero_grads();
319        let ctrl = m.optimize(&[0.0, 0.1], &grads);
320        assert!(
321            ctrl.lambda_plus[1] > 0.0,
322            "Positive drift should give lambda_plus > 0"
323        );
324        assert!(
325            ctrl.lambda_minus[1] < 1e-12,
326            "Positive drift should give lambda_minus ~ 0"
327        );
328    }
329
330    #[test]
331    fn intensity_stays_non_negative() {
332        let m = default_model();
333        let state = [0.0, 0.001];
334        let next = m.next_step(&state, 0.01, &[0.0, 0.0]);
335        assert!(next[1] >= 0.0, "Intensity must stay non-negative");
336    }
337
338    #[test]
339    fn alpha_zero_makes_drift_simple_mean_reversion() {
340        let m = AvellanedaHawkes::new(0.5, 0.5, 1.5, 0.0, 2.0, 1.0).with_lambda_step(0.1);
341        let grads = zero_grads();
342        // With alpha=0, drift = beta*(mu - lambda). At lambda=mu, drift is zero.
343        let ctrl = m.optimize(&[0.0, m.mu], &grads);
344        assert!(
345            ctrl.lambda_plus[1].abs() < 1e-10 && ctrl.lambda_minus[1].abs() < 1e-10,
346            "At lambda=mu with alpha=0, net drift should be zero"
347        );
348    }
349}