Skip to main content

solver/models/
bilateral_hawkes_order_flow_imbalance.rs

1use super::normal_cdf;
2use super::traits::{ControlOutput, Gradients, Model};
3
4// Re-export so existing paths compile.
5pub use super::TerminalCondition;
6
7/// Bilateral Hawkes model with price-impact-driven adverse selection.
8///
9/// # State Space: `[q, lambda_plus, lambda_minus]`
10///
11/// | dim | symbol       | description                                      |
12/// |-----|--------------|--------------------------------------------------|
13/// | 0   | q            | Inventory level (integer, jump-controlled)       |
14/// | 1   | lambda_plus  | Buy market-order intensity (ODE drift, no noise) |
15/// | 2   | lambda_minus | Sell market-order intensity (ODE drift, no noise)|
16///
17/// # Dynamics
18///
19/// `d(lambda+) = beta*(mu - lambda+) dt + alpha dN+`
20/// `d(lambda-) = beta*(mu - lambda-) dt + alpha dN-`
21/// `dS = sigma dW + eta_ofi (dN+ - dN-)`
22///
23/// Each buy market order (dN+) moves the mid price UP by `eta_ofi`; each sell market
24/// order (dN-) moves it DOWN by `eta_ofi`. The market maker accounts for this
25/// price impact when setting optimal spreads.
26///
27/// # Optimal Spreads Under Price Impact
28///
29/// Deriving the FOC from the CARA HJB with per-fill wealth effect `(q±1)*eta_ofi` gives:
30///
31/// $$ \delta_{bid}^* = \delta_{base} - \frac{\partial V}{\partial q} + (q+1)\eta_{OFI} $$
32/// $$ \delta_{ask}^* = \delta_{base} + \frac{\partial V}{\partial q} - (q-1)\eta_{OFI} $$
33///
34/// Economic interpretation:
35/// - Long inventory (q > 0): ask narrows (selling into a rising price is favourable),
36///   bid widens (avoid accumulating more when sell MOs push price down).
37/// - Short inventory (q < 0): ask widens (avoid shorting further into a rising price),
38///   bid narrows (cover cheaply when sell MOs push price down).
39///
40/// Setting `eta_ofi = 0` recovers the plain `BilateralHawkes` (BHK) model exactly.
41///
42/// In the simulation pass `impact_factor = -eta_ofi` to `SimulatedDataSource` so the
43/// price process matches the assumed dynamics `dS = sigma dW + eta_ofi (dN+ - dN-)`.
44///
45/// # Hawkes Approximation
46///
47/// Same fluid (mean-field) approximation as `BilateralHawkes`:
48///
49/// $$ d(\lambda_+)/dt \approx \beta(\mu - \lambda_+) + \alpha \lambda_+ $$
50/// $$ d(\lambda_-)/dt \approx \beta(\mu - \lambda_-) + \alpha \lambda_- $$
51///
52#[derive(Clone, Debug)]
53pub struct BilateralHawkesOrderFlowImbalance {
54    /// Risk-aversion parameter.
55    pub gamma: f64,
56    /// Price volatility.
57    pub sigma: f64,
58    /// Fill-rate decay (Avellaneda-Stoikov kappa).
59    pub kappa: f64,
60    // --- Hawkes parameters (shared for both sides) ---
61    /// Jump size added to the excited side's intensity.
62    pub alpha: f64,
63    /// Mean-reversion speed.
64    pub beta: f64,
65    /// Baseline intensity (mean-reversion target).
66    pub mu: f64,
67    // --- Price-impact parameter ---
68    /// Price impact per fill event (eta_ofi in dS = sigma*dW + eta_ofi*(dN+ - dN-)).
69    /// Higher eta_ofi = stronger q-dependent spread correction.
70    /// Setting eta_ofi = 0 recovers the plain BilateralHawkes (BHK) model.
71    pub eta_ofi: f64,
72    /// Grid step for both lambda+ and lambda- dimensions.
73    pub lambda_step: f64,
74    /// Grid step for the inventory dimension.
75    pub dq: f64,
76    /// Minimum inventory (lower hard boundary). Defaults to -infinity.
77    pub q_min: f64,
78    /// Maximum inventory (upper hard boundary). Defaults to +infinity.
79    pub q_max: f64,
80    /// Terminal condition for the value function at T. Defaults to Zero.
81    pub terminal_condition: TerminalCondition,
82    /// Optional terminal liquidation half-spread used when terminal_condition is
83    /// LiquidationCost. If None, uses the AS base spread.
84    pub terminal_liquidation_half_spread: Option<f64>,
85}
86
87impl BilateralHawkesOrderFlowImbalance {
88    pub fn new(
89        gamma: f64,
90        sigma: f64,
91        kappa: f64,
92        alpha: f64,
93        beta: f64,
94        mu: f64,
95        eta_ofi: f64,
96    ) -> Self {
97        Self {
98            gamma,
99            sigma,
100            kappa,
101            alpha,
102            beta,
103            mu,
104            eta_ofi,
105            lambda_step: 1.0,
106            dq: 1.0,
107            q_min: f64::NEG_INFINITY,
108            q_max: f64::INFINITY,
109            terminal_condition: TerminalCondition::Zero,
110            terminal_liquidation_half_spread: None,
111        }
112    }
113
114    pub fn with_terminal_condition(mut self, terminal_condition: TerminalCondition) -> Self {
115        self.terminal_condition = terminal_condition;
116        self
117    }
118
119    pub fn with_terminal_liquidation_half_spread(mut self, half_spread: f64) -> Self {
120        self.terminal_liquidation_half_spread = Some(half_spread.max(0.0));
121        self
122    }
123
124    pub fn with_inventory_bounds(mut self, q_min: f64, q_max: f64) -> Self {
125        self.q_min = q_min;
126        self.q_max = q_max;
127        self
128    }
129
130    pub fn with_lambda_step(mut self, lambda_step: f64) -> Self {
131        self.lambda_step = lambda_step.abs().max(1e-8);
132        self
133    }
134
135    pub fn with_dq(mut self, dq: f64) -> Self {
136        self.dq = dq.abs().max(1e-8);
137        self
138    }
139
140    /// Computes optimal bid/ask half-spreads with price-impact-driven inventory correction.
141    ///
142    /// When eta_ofi > 0 each fill carries an adverse-selection wealth effect `(q±1)*eta_ofi`.
143    /// The FOC on the CARA HJB gives:
144    ///   delta_bid = base_spread - dV/dq_fwd + (q+1)*eta_ofi
145    ///   delta_ask = base_spread + dV/dq_bwd - (q-1)*eta_ofi
146    ///
147    /// Long inventory (q > 0): ask narrows (sell into rising price), bid widens.
148    /// Short inventory (q < 0): ask widens (avoid adding short), bid narrows.
149    /// eta_ofi = 0 recovers the plain BHK spread formula identically.
150    pub fn get_spreads(&self, state: &[f64; 3], grads: &Gradients<3>) -> (f64, f64) {
151        let q = state[0];
152        let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
153        let delta_bid = (base_spread - grads.fwd[0] + (q + 1.0) * self.eta_ofi).clamp(-10.0, 10.0);
154        let delta_ask = (base_spread + grads.bwd[0] - (q - 1.0) * self.eta_ofi).clamp(-10.0, 10.0);
155        (delta_bid, delta_ask)
156    }
157
158    /// Upwind transport rates for one intensity dimension.
159    #[inline]
160    fn intensity_upwind(&self, drift: f64) -> (f64, f64) {
161        let rate = drift.abs() / self.lambda_step;
162        if drift >= 0.0 {
163            (rate, 0.0)
164        } else {
165            (0.0, rate)
166        }
167    }
168}
169
170impl Model<3> for BilateralHawkesOrderFlowImbalance {
171    type Process = ();
172
173    fn process(&self) {}
174
175    fn optimize(&self, state: &[f64; 3], grads: &Gradients<3>) -> ControlOutput<3> {
176        let q = state[0];
177        let lambda_plus = state[1].max(0.0);
178        let lambda_minus = state[2].max(0.0);
179
180        // --- Dimension 0: Inventory controls ---
181        let (d_bid, d_ask) = self.get_spreads(state, grads);
182
183        let max_mult = 20.0_f64;
184        // Bid filled by sell MOs (lambda-); ask filled by buy MOs (lambda+)
185        let lambda_bid_fill = lambda_minus * (-self.kappa * d_bid).exp().min(max_mult);
186        let lambda_ask_fill = lambda_plus * (-self.kappa * d_ask).exp().min(max_mult);
187
188        // Shut off forbidden quoting side at inventory bounds.
189        let lambda_bid_fill = if q >= self.q_max {
190            0.0
191        } else {
192            lambda_bid_fill
193        };
194        let lambda_ask_fill = if q <= self.q_min {
195            0.0
196        } else {
197            lambda_ask_fill
198        };
199
200        let hamiltonian_inv = (lambda_bid_fill + lambda_ask_fill) / (self.gamma + self.kappa);
201        let risk_penalty = -0.5 * self.gamma * self.sigma.powi(2) * q.powi(2);
202
203        // Cancel the solver's q-transport contribution so we can inject the Hamiltonian.
204        let drift_correction_q =
205            self.dq * (lambda_bid_fill * grads.fwd[0] - lambda_ask_fill * grads.bwd[0]);
206
207        // --- Dimensions 1/2: lambda+ and lambda- dynamics (fluid approximation) ---
208        let net_drift_plus = self.beta * (self.mu - lambda_plus) + self.alpha * lambda_plus;
209        let net_drift_minus = self.beta * (self.mu - lambda_minus) + self.alpha * lambda_minus;
210        let (lp_plus, lp_minus) = self.intensity_upwind(net_drift_plus);
211        let (lm_plus, lm_minus) = self.intensity_upwind(net_drift_minus);
212
213        ControlOutput {
214            lambda_plus: [lambda_bid_fill, lp_plus, lm_plus],
215            lambda_minus: [lambda_ask_fill, lp_minus, lm_minus],
216            flow: hamiltonian_inv + risk_penalty - drift_correction_q,
217        }
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 constant_discount_rate(&self) -> Option<f64> {
233        Some(0.0)
234    }
235
236    /// Forward step using the fluid (mean-field) drift.
237    fn next_step(&self, current_state: &[f64; 3], dt: f64, _noise: &[f64; 3]) -> [f64; 3] {
238        let lambda_plus = current_state[1];
239        let lambda_minus = current_state[2];
240        let drift_plus = self.beta * (self.mu - lambda_plus) + self.alpha * lambda_plus;
241        let drift_minus = self.beta * (self.mu - lambda_minus) + self.alpha * lambda_minus;
242        [
243            current_state[0],
244            (lambda_plus + drift_plus * dt).max(0.0),
245            (lambda_minus + drift_minus * dt).max(0.0),
246        ]
247    }
248
249    /// Coupled step: inventory jumps based on current fill probabilities.
250    fn next_step_controlled(
251        &self,
252        current_state: &[f64; 3],
253        control: &ControlOutput<3>,
254        dt: f64,
255        noise: &[f64; 3],
256    ) -> [f64; 3] {
257        let mut next = self.next_step(current_state, dt, noise);
258
259        // Bernoulli inventory jump using noise[0] mapped through CDF -> uniform
260        let u = normal_cdf(noise[0]);
261        let p_bid = (control.lambda_plus[0] * dt).clamp(0.0, 1.0);
262        let p_ask = (control.lambda_minus[0] * dt).clamp(0.0, 1.0);
263        if u < p_bid {
264            next[0] += 1.0; // sell MO hits bid  -> inventory +1
265            next[2] += self.alpha; // lambda- self-excites on sell MO fill
266        } else if u > 1.0 - p_ask {
267            next[0] -= 1.0; // buy  MO hits ask  -> inventory -1
268            next[1] += self.alpha; // lambda+ self-excites on buy MO fill
269        }
270
271        next[1] = next[1].max(0.0);
272        next[2] = next[2].max(0.0);
273
274        next
275    }
276
277    /// Both intensity dimensions follow deterministic ODE drift with no Brownian noise.
278    fn is_diffusion_dimension(&self, _dim: usize) -> bool {
279        false
280    }
281
282    fn is_integer_dimension(&self, dim: usize) -> bool {
283        dim == 0 // q is discrete integer; lambdas are continuous
284    }
285
286    fn gradient_step(&self, dim: usize) -> f64 {
287        match dim {
288            1 | 2 => self.lambda_step.abs().max(1e-8),
289            _ => 1.0,
290        }
291    }
292
293    /// The base arrival intensity is state-dependent: returns the average of
294    /// the buy/sell intensity states for intensity-to-spread conversion.
295    fn fill_rate_base(&self, state: &[f64; 3]) -> f64 {
296        ((state[1] + state[2]) * 0.5).max(1e-10)
297    }
298
299    fn fill_rate_decay(&self) -> f64 {
300        self.kappa
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    fn zero_grads() -> Gradients<3> {
309        Gradients {
310            fwd: [0.0; 3],
311            bwd: [0.0; 3],
312        }
313    }
314
315    fn zero_state() -> [f64; 3] {
316        [0.0, 1.0, 1.0]
317    }
318
319    fn default_model() -> BilateralHawkesOrderFlowImbalance {
320        BilateralHawkesOrderFlowImbalance::new(0.5, 0.2, 1.5, 0.5, 2.0, 1.0, 0.5)
321            .with_lambda_step(0.1)
322            .with_dq(1.0)
323    }
324
325    #[test]
326    fn model_creation() {
327        let m = default_model();
328        assert_eq!(m.gamma, 0.5);
329        assert_eq!(m.sigma, 0.2);
330        assert_eq!(m.kappa, 1.5);
331        assert_eq!(m.alpha, 0.5);
332        assert_eq!(m.beta, 2.0);
333        assert_eq!(m.mu, 1.0);
334        assert_eq!(m.eta_ofi, 0.5);
335    }
336
337    #[test]
338    fn zero_inventory_gives_symmetric_spreads() {
339        // At q=0, zero grads: delta_bid = base + (0+1)*xi, delta_ask = base - (0-1)*xi
340        // Both equal base + xi, so bid == ask (symmetric market)
341        let m = default_model();
342        let state = [0.0, 1.0, 1.0];
343        let (bid, ask) = m.get_spreads(&state, &zero_grads());
344        let base = (1.0 / m.gamma) * (1.0 + m.gamma / m.kappa).ln();
345        assert!(
346            (bid - ask).abs() < 1e-12,
347            "bid and ask should be equal at q=0"
348        );
349        assert!((bid - (base + m.eta_ofi)).abs() < 1e-12);
350    }
351
352    #[test]
353    fn long_inventory_widens_bid_narrows_ask() {
354        // q > 0: MM is long. To offload, widen bid (post higher), narrow ask.
355        // delta_bid = base + (q+1)*xi > base+xi, delta_ask = base - (q-1)*xi < base+xi
356        let m = default_model(); // xi = 0.5
357        let q = 5.0;
358        let state_long = [q, 1.0, 1.0];
359        let state_zero = [0.0, 1.0, 1.0];
360        let (bid_long, ask_long) = m.get_spreads(&state_long, &zero_grads());
361        let (bid_zero, ask_zero) = m.get_spreads(&state_zero, &zero_grads());
362        assert!(bid_long > bid_zero, "long position widens bid");
363        assert!(ask_long < ask_zero, "long position narrows ask");
364    }
365
366    #[test]
367    fn short_inventory_narrows_bid_widens_ask() {
368        // q < 0: MM is short. To cover, narrow bid (easier buy), widen ask.
369        // delta_bid = base + (q+1)*xi < base+xi, delta_ask = base - (q-1)*xi > base+xi
370        let m = default_model(); // xi = 0.5
371        let q = -5.0;
372        let state_short = [q, 1.0, 1.0];
373        let state_zero = [0.0, 1.0, 1.0];
374        let (bid_short, ask_short) = m.get_spreads(&state_short, &zero_grads());
375        let (bid_zero, ask_zero) = m.get_spreads(&state_zero, &zero_grads());
376        assert!(bid_short < bid_zero, "short position narrows bid");
377        assert!(ask_short > ask_zero, "short position widens ask");
378    }
379
380    #[test]
381    fn larger_xi_gives_larger_spread_corrections() {
382        // At q=5 (long), higher xi should produce larger deviations from zero-xi baseline.
383        // delta_bid = base + (q+1)*xi, delta_ask = base - (q-1)*xi
384        let m1 = BilateralHawkesOrderFlowImbalance::new(0.5, 0.2, 1.5, 0.5, 2.0, 1.0, 0.5)
385            .with_lambda_step(0.1);
386        let m2 = BilateralHawkesOrderFlowImbalance::new(0.5, 0.2, 1.5, 0.5, 2.0, 1.0, 1.0)
387            .with_lambda_step(0.1);
388        let m0 = BilateralHawkesOrderFlowImbalance::new(0.5, 0.2, 1.5, 0.5, 2.0, 1.0, 0.0)
389            .with_lambda_step(0.1);
390
391        let state = [5.0, 1.0, 1.0]; // q=5 (long)
392        let grads = zero_grads();
393
394        let (bid0, ask0) = m0.get_spreads(&state, &grads);
395        let (bid1, ask1) = m1.get_spreads(&state, &grads);
396        let (bid2, ask2) = m2.get_spreads(&state, &grads);
397
398        // bid monotone increasing in xi at q>0
399        assert!(bid1 > bid0);
400        assert!(bid2 > bid1);
401        // ask monotone decreasing in xi at q>0
402        assert!(ask1 < ask0);
403        assert!(ask2 < ask1);
404    }
405
406    #[test]
407    fn terminal_condition_zero() {
408        let m = default_model().with_terminal_condition(TerminalCondition::Zero);
409        let state = [5.0, 1.0, 1.0];
410        assert_eq!(m.terminal(&state), 0.0);
411    }
412
413    #[test]
414    fn terminal_condition_liquidation_cost() {
415        let m = default_model()
416            .with_terminal_condition(TerminalCondition::LiquidationCost)
417            .with_terminal_liquidation_half_spread(0.1);
418
419        let state_q5 = [5.0, 1.0, 1.0];
420        let state_q0 = [0.0, 1.0, 1.0];
421        let state_qm3 = [-3.0, 1.0, 1.0];
422
423        assert_eq!(m.terminal(&state_q5), -5.0 * 0.1);
424        assert_eq!(m.terminal(&state_q0), 0.0);
425        assert_eq!(m.terminal(&state_qm3), -3.0 * 0.1);
426    }
427
428    #[test]
429    fn optimize_output_structure() {
430        let m = default_model();
431        let state = zero_state();
432        let grads = zero_grads();
433        let output = m.optimize(&state, &grads);
434
435        // Check that output dimensions are correctly sized
436        assert_eq!(output.lambda_plus.len(), 3);
437        assert_eq!(output.lambda_minus.len(), 3);
438
439        // All rates should be non-negative
440        for rate in &output.lambda_plus {
441            assert!(*rate >= 0.0);
442        }
443        for rate in &output.lambda_minus {
444            assert!(*rate >= 0.0);
445        }
446    }
447
448    #[test]
449    fn inventory_bounds_enforcement() {
450        let m = default_model().with_inventory_bounds(-2.0, 2.0);
451        let state = [2.0, 2.0, 1.0]; // at upper bound
452        let grads = Gradients {
453            fwd: [0.0, 0.0, 0.0],
454            bwd: [-1.0, 0.0, 0.0], // negative gradient would encourage sells
455        };
456
457        let output = m.optimize(&state, &grads);
458        // At upper bound, can't increase inventory further
459        // lambda_plus[0] = lambda_bid_fill should be zero (no more sell MOs accepted)
460        assert_eq!(output.lambda_plus[0], 0.0);
461    }
462
463    #[test]
464    fn hawkes_dynamics_stability() {
465        // Test that Hawkes dynamics remain stable (lambda doesn't grow without bound)
466        let m = default_model();
467
468        // Stationary intensity should be mu / (1 - alpha/beta) when both equal
469        let rho = m.alpha / m.beta;
470        let stationary_intensity = m.mu / (1.0 - rho);
471
472        let mut state = [0.0, stationary_intensity, stationary_intensity];
473
474        // Step forward many times; intensity should stay bounded
475        for _ in 0..1000 {
476            state = m.next_step(&state, 0.01, &[0.0, 0.0, 0.0]);
477        }
478
479        // After many steps, intensity should remain close to stationary level
480        assert!(state[1] > 0.0 && state[1] < 10.0 * stationary_intensity);
481        assert!(state[2] > 0.0 && state[2] < 10.0 * stationary_intensity);
482    }
483
484    #[test]
485    fn next_step_consistency() {
486        // Verify that next_step uses the same fluid dynamics as optimize()
487        let m = default_model();
488        let state = [0.0, 1.5, 1.0];
489        let dt = 0.01;
490
491        let next = m.next_step(&state, dt, &[0.0; 3]);
492
493        // Inventory should stay the same (no fills in next_step)
494        assert_eq!(next[0], state[0]);
495
496        // Lambda+ should decay slightly toward mu (since lambda+ > mu * (1 - rho))
497        let drift_plus = m.beta * (m.mu - state[1]) + m.alpha * state[1];
498        let expected_lambda_plus = state[1] + drift_plus * dt;
499        assert!((next[1] - expected_lambda_plus).abs() < 1e-10);
500    }
501
502    #[test]
503    fn next_step_controlled_fills_inventory() {
504        // Test that next_step_controlled can generate inventory jumps
505        let m = default_model();
506        let state = [0.0, 2.0, 2.0];
507
508        // Create a control with high bid fill rate
509        let control = ControlOutput {
510            lambda_plus: [1.0, 0.0, 0.0],   // high bid fill
511            lambda_minus: [0.01, 0.0, 0.0], // low ask fill
512            flow: 0.0,
513        };
514
515        let dt = 0.1;
516
517        // Run many steps and count fills
518        let mut up_count = 0;
519        let mut down_count = 0;
520        for i in 0..100 {
521            let noise = [(i as f64 - 50.0) / 10.0; 3]; // sweep through normal values
522            let next = m.next_step_controlled(&state, &control, dt, &noise);
523
524            if next[0] > state[0] {
525                up_count += 1;
526            } else if next[0] < state[0] {
527                down_count += 1;
528            }
529        }
530
531        // With high bid fill rate, we should see many upward inventory moves
532        assert!(up_count > down_count);
533    }
534
535    #[test]
536    fn gradient_step_returns_correct_values() {
537        let m = default_model();
538
539        assert_eq!(m.gradient_step(0), 1.0);
540        assert_eq!(m.gradient_step(1), 0.1);
541        assert_eq!(m.gradient_step(2), 0.1);
542    }
543
544    #[test]
545    fn is_integer_dimension() {
546        let m = default_model();
547
548        assert!(m.is_integer_dimension(0)); // inventory is discrete
549        assert!(!m.is_integer_dimension(1)); // lambda+ is continuous
550        assert!(!m.is_integer_dimension(2)); // lambda- is continuous
551    }
552
553    #[test]
554    fn is_diffusion_dimension() {
555        let m = default_model();
556
557        assert!(!m.is_diffusion_dimension(0));
558        assert!(!m.is_diffusion_dimension(1));
559        assert!(!m.is_diffusion_dimension(2));
560    }
561
562    #[test]
563    fn constant_discount_rate() {
564        let m = default_model();
565        assert_eq!(m.constant_discount_rate(), Some(0.0));
566    }
567
568    #[test]
569    fn spread_formula_matches_analytical_expression() {
570        // delta_bid(q) = base + (q+1)*xi, delta_ask(q) = base - (q-1)*xi
571        let m = default_model(); // xi = 0.5
572        let base = (1.0 / m.gamma) * (1.0 + m.gamma / m.kappa).ln();
573        for &q in &[-5.0_f64, -1.0, 0.0, 1.0, 5.0] {
574            let state = [q, 1.0, 1.0];
575            let (bid, ask) = m.get_spreads(&state, &zero_grads());
576            let expected_bid = base + (q + 1.0) * m.eta_ofi;
577            let expected_ask = base - (q - 1.0) * m.eta_ofi;
578            assert!((bid - expected_bid).abs() < 1e-12, "bid mismatch at q={q}");
579            assert!((ask - expected_ask).abs() < 1e-12, "ask mismatch at q={q}");
580        }
581    }
582
583    #[test]
584    fn compare_with_balanced_no_imbalance() {
585        // When lambdas are balanced, should behave like a symmetric market maker
586        let m = default_model();
587        let state = [0.0, 1.0, 1.0];
588        let grads = Gradients {
589            fwd: [0.0, 0.0, 0.0],
590            bwd: [0.0, 0.0, 0.0],
591        };
592
593        let (bid, ask) = m.get_spreads(&state, &grads);
594        // At q=0, zero grads, xi=0.5: bid == ask == base + xi (symmetric)
595        assert!((bid - ask).abs() < 1e-12);
596    }
597
598    #[test]
599    fn extreme_imbalance_clamping() {
600        // Spreads should be clamped to [-10, 10] range
601        let m = BilateralHawkesOrderFlowImbalance::new(0.5, 0.2, 1.5, 0.5, 2.0, 1.0, 100.0)
602            .with_lambda_step(0.1);
603
604        let state = [0.0, 10.0, 0.0]; // extreme imbalance
605        let grads = Gradients {
606            fwd: [100.0, 0.0, 0.0],
607            bwd: [100.0, 0.0, 0.0],
608        };
609
610        let (bid, ask) = m.get_spreads(&state, &grads);
611
612        // Spreads should be bounded
613        assert!(bid >= -10.0 && bid <= 10.0);
614        assert!(ask >= -10.0 && ask <= 10.0);
615    }
616
617    #[test]
618    fn inventory_control_respects_bounds() {
619        let m = default_model().with_inventory_bounds(-5.0, 5.0);
620        let state_at_max = [5.0, 2.0, 1.0];
621        let state_at_min = [-5.0, 1.0, 2.0];
622        let grads = zero_grads();
623
624        let output_max = m.optimize(&state_at_max, &grads);
625        let output_min = m.optimize(&state_at_min, &grads);
626
627        // At max inventory, can't increase (lambda_plus[0] = lambda_bid_fill should be zero)
628        assert_eq!(output_max.lambda_plus[0], 0.0);
629
630        // At min inventory, can't decrease (lambda_minus[0] = lambda_ask_fill should be zero)
631        assert_eq!(output_min.lambda_minus[0], 0.0);
632    }
633
634    #[test]
635    fn xi_zero_recovers_baseline() {
636        // With xi=0, get_spreads reduces to pure AS formula at q=0:
637        // delta_bid = base + (0+1)*0 = base, delta_ask = base - (0-1)*0 = base
638        let m = BilateralHawkesOrderFlowImbalance::new(0.5, 0.2, 1.5, 0.5, 2.0, 1.0, 0.0)
639            .with_lambda_step(0.1);
640        let state = [0.0, 2.0, 1.0];
641        let (bid, ask) = m.get_spreads(&state, &zero_grads());
642        let base = (1.0 / m.gamma) * (1.0 + m.gamma / m.kappa).ln();
643        assert!((bid - base).abs() < 1e-12);
644        assert!((ask - base).abs() < 1e-12);
645    }
646
647    #[test]
648    fn model_builder_methods() {
649        let m = BilateralHawkesOrderFlowImbalance::new(0.5, 0.2, 1.5, 0.5, 2.0, 1.0, 0.5)
650            .with_lambda_step(0.2)
651            .with_dq(0.5)
652            .with_inventory_bounds(-10.0, 10.0)
653            .with_terminal_condition(TerminalCondition::LiquidationCost)
654            .with_terminal_liquidation_half_spread(0.05);
655
656        assert_eq!(m.lambda_step, 0.2);
657        assert_eq!(m.dq, 0.5);
658        assert_eq!(m.q_min, -10.0);
659        assert_eq!(m.q_max, 10.0);
660        assert_eq!(m.terminal_liquidation_half_spread, Some(0.05));
661    }
662}