1use super::normal_cdf;
2use super::traits::{ControlOutput, Gradients, Model};
3
4pub use super::TerminalCondition;
6
7#[derive(Clone, Debug)]
53pub struct BilateralHawkesOrderFlowImbalance {
54 pub gamma: f64,
56 pub sigma: f64,
58 pub kappa: f64,
60 pub alpha: f64,
63 pub beta: f64,
65 pub mu: f64,
67 pub eta_ofi: f64,
72 pub lambda_step: f64,
74 pub dq: f64,
76 pub q_min: f64,
78 pub q_max: f64,
80 pub terminal_condition: TerminalCondition,
82 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 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 #[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 let (d_bid, d_ask) = self.get_spreads(state, grads);
182
183 let max_mult = 20.0_f64;
184 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 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 let drift_correction_q =
205 self.dq * (lambda_bid_fill * grads.fwd[0] - lambda_ask_fill * grads.bwd[0]);
206
207 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 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 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 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; next[2] += self.alpha; } else if u > 1.0 - p_ask {
267 next[0] -= 1.0; next[1] += self.alpha; }
270
271 next[1] = next[1].max(0.0);
272 next[2] = next[2].max(0.0);
273
274 next
275 }
276
277 fn is_diffusion_dimension(&self, _dim: usize) -> bool {
279 false
280 }
281
282 fn is_integer_dimension(&self, dim: usize) -> bool {
283 dim == 0 }
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 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 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 let m = default_model(); 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 let m = default_model(); 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 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]; 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 assert!(bid1 > bid0);
400 assert!(bid2 > bid1);
401 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 assert_eq!(output.lambda_plus.len(), 3);
437 assert_eq!(output.lambda_minus.len(), 3);
438
439 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]; let grads = Gradients {
453 fwd: [0.0, 0.0, 0.0],
454 bwd: [-1.0, 0.0, 0.0], };
456
457 let output = m.optimize(&state, &grads);
458 assert_eq!(output.lambda_plus[0], 0.0);
461 }
462
463 #[test]
464 fn hawkes_dynamics_stability() {
465 let m = default_model();
467
468 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 for _ in 0..1000 {
476 state = m.next_step(&state, 0.01, &[0.0, 0.0, 0.0]);
477 }
478
479 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 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 assert_eq!(next[0], state[0]);
495
496 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 let m = default_model();
506 let state = [0.0, 2.0, 2.0];
507
508 let control = ControlOutput {
510 lambda_plus: [1.0, 0.0, 0.0], lambda_minus: [0.01, 0.0, 0.0], flow: 0.0,
513 };
514
515 let dt = 0.1;
516
517 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]; 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 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)); assert!(!m.is_integer_dimension(1)); assert!(!m.is_integer_dimension(2)); }
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 let m = default_model(); 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 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 assert!((bid - ask).abs() < 1e-12);
596 }
597
598 #[test]
599 fn extreme_imbalance_clamping() {
600 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]; 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 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 assert_eq!(output_max.lambda_plus[0], 0.0);
629
630 assert_eq!(output_min.lambda_minus[0], 0.0);
632 }
633
634 #[test]
635 fn xi_zero_recovers_baseline() {
636 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}