Skip to main content

solver/models/
avellaneda_hawkes.rs

1use super::TerminalCondition;
2use super::control::{ControlProblem, StateDerivatives};
3use super::market_making::MarketMakingControl;
4use super::normal_cdf;
5use crate::numeric::finite_difference::discretization::{DimensionKind, Transport};
6use crate::numeric::finite_difference::pde::PdeProblem;
7
8/// Avellaneda-Stoikov model with Hawkes Process for order arrival intensity.
9///
10/// State Space: [Inventory (q), Intensity (lambda)]
11/// - q: Discrete inventory level
12/// - lambda: Continuous order arrival intensity
13///
14/// Dynamics:
15/// - q: Jumps ±1 upon fills.
16/// - lambda: Mean-reverting jump-diffusion (approximated).
17///   d(lambda) = beta * (mu - lambda) * dt + alpha * dN_market
18///   where N_market is the market order counting process with intensity lambda.
19///
20/// The solver uses a grid-based approach. We approximate the Hawkes dynamics
21/// on the lambda dimension using an upwind finite difference scheme mapped to
22/// the solver's jump interface.
23#[derive(Clone)]
24pub struct AvellanedaHawkes {
25    pub gamma: f64,
26    pub sigma: f64,
27    pub kappa: f64,
28    // Hawkes Parameters
29    pub alpha: f64, // Jump size of intensity
30    pub beta: f64,  // Decay rate of intensity
31    pub mu: f64,    // Baseline intensity level (mean reversion target)
32    pub lambda_step: f64,
33    /// Grid step size for inventory dimension (set from `grid.dx[0]` for FD).
34    pub dq: f64,
35    /// Terminal condition V(T, q, lambda).  Defaults to Zero.
36    pub terminal_condition: TerminalCondition,
37    /// Minimum inventory (lower hard boundary). Defaults to -infinity (no constraint).
38    pub q_min: f64,
39    /// Maximum inventory (upper hard boundary). Defaults to +infinity (no constraint).
40    pub q_max: f64,
41    /// Optional terminal liquidation half-spread used when terminal_condition is
42    /// LiquidationCost. If None, uses the AS base spread.
43    pub terminal_liquidation_half_spread: Option<f64>,
44}
45
46impl AvellanedaHawkes {
47    pub fn new(gamma: f64, sigma: f64, kappa: f64, alpha: f64, beta: f64, mu: f64) -> Self {
48        Self {
49            gamma,
50            sigma,
51            kappa,
52            alpha,
53            beta,
54            mu,
55            lambda_step: 1.0,
56            dq: 1.0,
57            terminal_condition: TerminalCondition::Zero,
58            q_min: f64::NEG_INFINITY,
59            q_max: f64::INFINITY,
60            terminal_liquidation_half_spread: None,
61        }
62    }
63
64    pub fn with_terminal_liquidation_half_spread(mut self, half_spread: f64) -> Self {
65        self.terminal_liquidation_half_spread = Some(half_spread.max(0.0));
66        self
67    }
68
69    pub fn with_inventory_bounds(mut self, q_min: f64, q_max: f64) -> Self {
70        self.q_min = q_min;
71        self.q_max = q_max;
72        self
73    }
74
75    pub fn with_terminal_condition(mut self, terminal_condition: TerminalCondition) -> Self {
76        self.terminal_condition = terminal_condition;
77        self
78    }
79
80    pub fn with_lambda_step(mut self, lambda_step: f64) -> Self {
81        self.lambda_step = lambda_step.abs().max(1e-8);
82        self
83    }
84
85    pub fn with_dq(mut self, dq: f64) -> Self {
86        self.dq = dq.abs().max(1e-8);
87        self
88    }
89
90    /// Computes optimal spreads based on current inventory and intensity.
91    /// Note: The base intensity 'A' from standard Avellaneda is replaced by the state variable 'lambda'.
92    pub fn get_spreads(&self, _lambda: f64, derivs: &StateDerivatives<2>) -> (f64, f64) {
93        let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
94
95        let dv_dq_buy = derivs.fwd[0];
96        let dv_dq_sell = derivs.bwd[0];
97
98        let mut delta_bid = base_spread - dv_dq_buy;
99        let mut delta_ask = base_spread + dv_dq_sell;
100
101        let min_spread = -10.0;
102        let max_spread = 10.0;
103        delta_bid = delta_bid.max(min_spread).min(max_spread);
104        delta_ask = delta_ask.max(min_spread).min(max_spread);
105
106        (delta_bid, delta_ask)
107    }
108
109    /// The base arrival intensity is state-dependent: returns the current
110    /// intensity `state[1]` (clamped above 0) for intensity-to-spread conversion.
111    pub fn fill_rate_base(&self, state: &[f64; 2]) -> f64 {
112        state[1].max(1e-10)
113    }
114
115    /// The fill-rate decay parameter used for intensity-to-spread conversion.
116    pub fn fill_rate_decay(&self) -> f64 {
117        self.kappa
118    }
119}
120
121impl ControlProblem<2> for AvellanedaHawkes {
122    type Control = MarketMakingControl;
123
124    fn optimize(&self, _t: f64, state: &[f64; 2], derivs: &StateDerivatives<2>) -> Self::Control {
125        let q = state[0];
126        let lambda = state[1];
127        let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
128        let delta_bid = (base_spread - derivs.fwd[0]).clamp(-10.0, 10.0);
129        let delta_ask = (base_spread + derivs.bwd[0]).clamp(-10.0, 10.0);
130
131        let max_fill_mult = 20.0_f64;
132        let lambda_bid = lambda * (-self.kappa * delta_bid).exp().min(max_fill_mult);
133        let lambda_ask = lambda * (-self.kappa * delta_ask).exp().min(max_fill_mult);
134
135        let lambda_bid = if q >= self.q_max { 0.0 } else { lambda_bid };
136        let lambda_ask = if q <= self.q_min { 0.0 } else { lambda_ask };
137
138        MarketMakingControl::new(lambda_bid, lambda_ask)
139    }
140
141    fn running_reward(&self, _t: f64, _state: &[f64; 2], control: &Self::Control) -> f64 {
142        (control.bid_intensity + control.ask_intensity) / (self.gamma + self.kappa)
143    }
144
145    fn bsde_driver(
146        &self,
147        _t: f64,
148        state: &[f64; 2],
149        control: &Self::Control,
150        _derivs: &StateDerivatives<2>,
151        dt: f64,
152    ) -> f64 {
153        // Full-value problem: the Hawkes intensity drift transport is already
154        // simulated forward, so only the running reward plus the local
155        // inventory-risk source belong in the backward driver. The reward is
156        // bounded at `1/dt` per fill side to match the forward fill-probability
157        // clamp `lambda * dt <= 1`.
158        let q = state[0];
159        let local = -0.5 * self.gamma * self.sigma.powi(2) * q.powi(2);
160        let rate_cap = 1.0 / dt.max(1e-12);
161        let reward = (control.bid_intensity.min(rate_cap) + control.ask_intensity.min(rate_cap))
162            / (self.gamma + self.kappa);
163        reward + local
164    }
165
166    fn generator(
167        &self,
168        _t: f64,
169        state: &[f64; 2],
170        _control: &Self::Control,
171        derivs: &StateDerivatives<2>,
172    ) -> f64 {
173        let q = state[0];
174        let lambda = state[1];
175        let net_drift_lambda = self.beta * (self.mu - lambda) + self.alpha * lambda;
176
177        // Risk penalty plus Hawkes intensity transport, upwinded on the
178        // intensity dimension.
179        -0.5 * self.gamma * self.sigma.powi(2) * q.powi(2) + net_drift_lambda * derivs.grad[1]
180    }
181
182    fn terminal(&self, state: &[f64; 2]) -> f64 {
183        match self.terminal_condition {
184            TerminalCondition::Zero => 0.0,
185            TerminalCondition::LiquidationCost => {
186                let q = state[0];
187                let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
188                let half_spread = self.terminal_liquidation_half_spread.unwrap_or(base_spread);
189                -q.abs() * half_spread
190            }
191        }
192    }
193
194    fn discount_rate(&self, _state: &[f64; 2]) -> f64 {
195        0.0
196    }
197
198    fn constant_discount_rate(&self) -> Option<f64> {
199        Some(0.0)
200    }
201
202    fn next_step(&self, _t: f64, state: &[f64; 2], dt: f64, noise: &[f64; 2]) -> [f64; 2] {
203        let mut next = *state;
204
205        let u = normal_cdf(noise[0]);
206        let lambda = state[1].max(0.0);
207        let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
208        let lambda_bid = lambda * (-self.kappa * base_spread).exp();
209        let lambda_ask = lambda * (-self.kappa * base_spread).exp();
210        let p_bid = (lambda_bid * dt).clamp(0.0, 1.0);
211        let p_ask = (lambda_ask * dt).clamp(0.0, 1.0);
212        if u < p_bid {
213            next[0] += 1.0;
214        } else if u > 1.0 - p_ask {
215            next[0] -= 1.0;
216        }
217
218        let net_drift_lambda = self.beta * (self.mu - lambda) + self.alpha * lambda;
219        next[1] += net_drift_lambda * dt;
220        next[1] = next[1].max(0.0);
221        next
222    }
223
224    fn next_step_controlled(
225        &self,
226        _t: f64,
227        state: &[f64; 2],
228        control: &Self::Control,
229        dt: f64,
230        noise: &[f64; 2],
231    ) -> [f64; 2] {
232        let mut next = *state;
233
234        // The optimal control already carries the fill intensities, so the
235        // Bernoulli fill events use them instead of a frozen proxy quote.
236        let u = normal_cdf(noise[0]);
237        let p_bid = (control.bid_intensity * dt).clamp(0.0, 1.0);
238        let p_ask = (control.ask_intensity * dt).clamp(0.0, 1.0);
239        if u < p_bid {
240            next[0] += 1.0;
241        } else if u > 1.0 - p_ask {
242            next[0] -= 1.0;
243        }
244
245        let lambda = state[1].max(0.0);
246        let net_drift_lambda = self.beta * (self.mu - lambda) + self.alpha * lambda;
247        next[1] += net_drift_lambda * dt;
248        next[1] = next[1].max(0.0);
249        next
250    }
251
252    fn is_diffusion_dimension(&self, _dim: usize) -> bool {
253        false
254    }
255
256    fn gradient_step(&self, dim: usize) -> f64 {
257        if dim == 1 { self.lambda_step } else { 1.0 }
258    }
259}
260
261impl PdeProblem<2> for AvellanedaHawkes {
262    fn dimension_kind(&self, dim: usize) -> DimensionKind {
263        match dim {
264            0 => DimensionKind::DiscreteJump,
265            _ => DimensionKind::DeterministicDrift,
266        }
267    }
268
269    fn transport(
270        &self,
271        _t: f64,
272        state: &[f64; 2],
273        control: &Self::Control,
274        derivs: &StateDerivatives<2>,
275    ) -> Transport<2> {
276        let q = state[0];
277        let lambda = state[1].max(0.0);
278        let net_drift = self.beta * (self.mu - lambda) + self.alpha * lambda;
279        let hamiltonian =
280            (control.bid_intensity + control.ask_intensity) / (self.gamma + self.kappa);
281        let jump_transport =
282            control.bid_intensity * derivs.fwd[0] - control.ask_intensity * derivs.bwd[0];
283        let local = -0.5 * self.gamma * self.sigma.powi(2) * q.powi(2);
284
285        let rate = net_drift.abs() / self.lambda_step.abs().max(1e-12);
286        let (plus_lam, minus_lam) = if net_drift >= 0.0 {
287            (rate, 0.0)
288        } else {
289            (0.0, rate)
290        };
291
292        Transport::new(
293            [control.bid_intensity, plus_lam],
294            [control.ask_intensity, minus_lam],
295            hamiltonian + local - jump_transport,
296        )
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    fn zero_derivs() -> StateDerivatives<2> {
305        StateDerivatives::new([0.0; 2], [0.0; 2])
306    }
307
308    fn default_model() -> AvellanedaHawkes {
309        AvellanedaHawkes::new(0.5, 0.5, 1.5, 0.5, 2.0, 1.0)
310            .with_lambda_step(0.1)
311            .with_dq(1.0)
312    }
313
314    #[test]
315    fn zero_gradient_gives_base_spread() {
316        let m = default_model();
317        let derivs = zero_derivs();
318        let (bid, ask) = m.get_spreads(1.0, &derivs);
319        let base = (1.0 / m.gamma) * (1.0 + m.gamma / m.kappa).ln();
320        assert!((bid - base).abs() < 1e-12);
321        assert!((ask - base).abs() < 1e-12);
322    }
323
324    #[test]
325    fn bsde_driver_excludes_intensity_transport() {
326        let m = default_model();
327        let state = [2.0, 1.0];
328        let control = ControlProblem::optimize(&m, 0.0, &state, &zero_derivs());
329
330        let mut derivs = zero_derivs();
331        derivs.grad[1] = 1.0;
332
333        let dt = 0.01;
334        let q = state[0];
335        let local = -0.5 * m.gamma * m.sigma.powi(2) * q.powi(2);
336        let rate_cap = 1.0 / dt;
337        let reward = (control.bid_intensity.min(rate_cap) + control.ask_intensity.min(rate_cap))
338            / (m.gamma + m.kappa);
339        let expected = reward + local;
340
341        assert_eq!(
342            ControlProblem::bsde_driver(&m, 0.0, &state, &control, &derivs, dt),
343            expected
344        );
345        assert!(
346            ControlProblem::bsde_driver(&m, 0.0, &state, &control, &derivs, dt)
347                != ControlProblem::driver(&m, 0.0, &state, &control, &derivs)
348        );
349    }
350
351    #[test]
352    fn next_step_controlled_uses_optimal_intensities() {
353        let m = default_model();
354        let state = [1.0, 1.0];
355
356        let ctrl = MarketMakingControl::new(0.0, 0.0);
357        let next = ControlProblem::next_step_controlled(&m, 0.0, &state, &ctrl, 0.01, &[0.0, 0.0]);
358        assert_eq!(next[0], 1.0);
359
360        let ctrl = MarketMakingControl::new(1000.0, 0.0);
361        let next = ControlProblem::next_step_controlled(&m, 0.0, &state, &ctrl, 0.01, &[0.0, 0.0]);
362        assert_eq!(next[0], 2.0);
363    }
364
365    #[test]
366    fn intensity_scales_fill_rates() {
367        let m = default_model();
368        let derivs = zero_derivs();
369        let ctrl_low = ControlProblem::optimize(&m, 0.0, &[0.0, 0.5], &derivs);
370        let ctrl_high = ControlProblem::optimize(&m, 0.0, &[0.0, 2.0], &derivs);
371        let total_low = ctrl_low.bid_intensity + ctrl_low.ask_intensity;
372        let total_high = ctrl_high.bid_intensity + ctrl_high.ask_intensity;
373        assert!(
374            total_high > total_low,
375            "Higher intensity should give higher fill rates: low={}, high={}",
376            total_low,
377            total_high
378        );
379    }
380
381    #[test]
382    fn subcritical_regime_mean_reverts() {
383        let m = AvellanedaHawkes::new(0.5, 0.5, 1.5, 0.5, 2.0, 1.0);
384        assert!(m.alpha < m.beta, "Subcritical: alpha < beta");
385
386        // Equilibrium intensity = beta * mu / (beta - alpha)
387        let eq = m.beta * m.mu / (m.beta - m.alpha);
388
389        let state_high = [0.0, eq + 1.0];
390        let next_high = ControlProblem::next_step(&m, 0.0, &state_high, 0.01, &[0.0, 0.0]);
391        assert!(
392            next_high[1] < state_high[1],
393            "Above equilibrium, intensity should decrease"
394        );
395
396        let state_low = [0.0, eq - 0.5];
397        let next_low = ControlProblem::next_step(&m, 0.0, &state_low, 0.01, &[0.0, 0.0]);
398        assert!(
399            next_low[1] > state_low[1],
400            "Below equilibrium, intensity should increase"
401        );
402    }
403
404    #[test]
405    fn intensity_stays_non_negative() {
406        let m = default_model();
407        let state = [0.0, 0.001];
408        let next = ControlProblem::next_step(&m, 0.0, &state, 0.01, &[0.0, 0.0]);
409        assert!(next[1] >= 0.0, "Intensity must stay non-negative");
410    }
411
412    #[test]
413    fn dimension_kinds_match_state_space() {
414        let m = default_model();
415        assert_eq!(
416            PdeProblem::dimension_kind(&m, 0),
417            DimensionKind::DiscreteJump
418        );
419        assert_eq!(
420            PdeProblem::dimension_kind(&m, 1),
421            DimensionKind::DeterministicDrift
422        );
423    }
424}