1use super::normal_cdf;
2use super::traits::{ControlOutput, Gradients, Model};
3
4pub use super::TerminalCondition;
6
7#[derive(Clone, Debug)]
51pub struct BilateralHawkes {
52 pub gamma: f64,
54 pub sigma: f64,
56 pub kappa: f64,
58 pub alpha: f64,
61 pub beta: f64,
63 pub mu: f64,
65 pub lambda_step: f64,
67 pub dq: f64,
69 pub q_min: f64,
71 pub q_max: f64,
73 pub terminal_condition: TerminalCondition,
75 pub terminal_liquidation_half_spread: Option<f64>,
78}
79
80impl BilateralHawkes {
81 pub fn new(gamma: f64, sigma: f64, kappa: f64, alpha: f64, beta: f64, mu: f64) -> Self {
82 Self {
83 gamma,
84 sigma,
85 kappa,
86 alpha,
87 beta,
88 mu,
89 lambda_step: 1.0,
90 dq: 1.0,
91 q_min: f64::NEG_INFINITY,
92 q_max: f64::INFINITY,
93 terminal_condition: TerminalCondition::Zero,
94 terminal_liquidation_half_spread: None,
95 }
96 }
97
98 pub fn with_terminal_condition(mut self, terminal_condition: TerminalCondition) -> Self {
99 self.terminal_condition = terminal_condition;
100 self
101 }
102
103 pub fn with_terminal_liquidation_half_spread(mut self, half_spread: f64) -> Self {
104 self.terminal_liquidation_half_spread = Some(half_spread.max(0.0));
105 self
106 }
107
108 pub fn with_inventory_bounds(mut self, q_min: f64, q_max: f64) -> Self {
109 self.q_min = q_min;
110 self.q_max = q_max;
111 self
112 }
113
114 pub fn with_lambda_step(mut self, lambda_step: f64) -> Self {
115 self.lambda_step = lambda_step.abs().max(1e-8);
116 self
117 }
118
119 pub fn with_dq(mut self, dq: f64) -> Self {
120 self.dq = dq.abs().max(1e-8);
121 self
122 }
123
124 pub fn get_spreads(&self, grads: &Gradients<3>) -> (f64, f64) {
129 let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
130 let delta_bid = (base_spread - grads.fwd[0]).clamp(-10.0, 10.0);
131 let delta_ask = (base_spread + grads.bwd[0]).clamp(-10.0, 10.0);
132 (delta_bid, delta_ask)
133 }
134
135 #[allow(dead_code)]
138 #[inline]
139 fn intensity_mean_reversion_drift(&self, lambda: f64) -> f64 {
140 self.beta * (self.mu - lambda)
141 }
142
143 #[inline]
145 fn intensity_upwind(&self, drift: f64) -> (f64, f64) {
146 let rate = drift.abs() / self.lambda_step;
147 if drift >= 0.0 {
148 (rate, 0.0)
149 } else {
150 (0.0, rate)
151 }
152 }
153}
154
155impl Model<3> for BilateralHawkes {
156 type Process = ();
157
158 fn process(&self) {}
159
160 fn optimize(&self, state: &[f64; 3], grads: &Gradients<3>) -> ControlOutput<3> {
161 let q = state[0];
162 let lambda_plus = state[1].max(0.0);
163 let lambda_minus = state[2].max(0.0);
164
165 let (d_bid, d_ask) = self.get_spreads(grads);
167
168 let max_mult = 20.0_f64;
169 let lambda_bid_fill = lambda_minus * (-self.kappa * d_bid).exp().min(max_mult);
171 let lambda_ask_fill = lambda_plus * (-self.kappa * d_ask).exp().min(max_mult);
172
173 let lambda_bid_fill = if q >= self.q_max {
175 0.0
176 } else {
177 lambda_bid_fill
178 };
179 let lambda_ask_fill = if q <= self.q_min {
180 0.0
181 } else {
182 lambda_ask_fill
183 };
184
185 let hamiltonian_inv = (lambda_bid_fill + lambda_ask_fill) / (self.gamma + self.kappa);
186 let risk_penalty = -0.5 * self.gamma * self.sigma.powi(2) * q.powi(2);
187
188 let drift_correction_q =
190 self.dq * (lambda_bid_fill * grads.fwd[0] - lambda_ask_fill * grads.bwd[0]);
191
192 let net_drift_plus = self.beta * (self.mu - lambda_plus) + self.alpha * lambda_plus;
199 let net_drift_minus = self.beta * (self.mu - lambda_minus) + self.alpha * lambda_minus;
200 let (lp_plus, lp_minus) = self.intensity_upwind(net_drift_plus);
201 let (lm_plus, lm_minus) = self.intensity_upwind(net_drift_minus);
202
203 ControlOutput {
204 lambda_plus: [lambda_bid_fill, lp_plus, lm_plus],
205 lambda_minus: [lambda_ask_fill, lp_minus, lm_minus],
206 flow: hamiltonian_inv + risk_penalty - drift_correction_q,
207 }
208 }
209
210 fn terminal(&self, state: &[f64; 3]) -> f64 {
211 match self.terminal_condition {
212 TerminalCondition::Zero => 0.0,
213 TerminalCondition::LiquidationCost => {
214 let q = state[0];
215 let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
216 let half_spread = self.terminal_liquidation_half_spread.unwrap_or(base_spread);
217 -q.abs() * half_spread
218 }
219 }
220 }
221
222 fn constant_discount_rate(&self) -> Option<f64> {
223 Some(0.0)
224 }
225
226 fn next_step(&self, current_state: &[f64; 3], dt: f64, _noise: &[f64; 3]) -> [f64; 3] {
232 let lambda_plus = current_state[1];
233 let lambda_minus = current_state[2];
234 let drift_plus = self.beta * (self.mu - lambda_plus) + self.alpha * lambda_plus;
235 let drift_minus = self.beta * (self.mu - lambda_minus) + self.alpha * lambda_minus;
236 [
237 current_state[0],
238 (lambda_plus + drift_plus * dt).max(0.0),
239 (lambda_minus + drift_minus * dt).max(0.0),
240 ]
241 }
242
243 fn next_step_controlled(
245 &self,
246 current_state: &[f64; 3],
247 control: &ControlOutput<3>,
248 dt: f64,
249 noise: &[f64; 3],
250 ) -> [f64; 3] {
251 let mut next = self.next_step(current_state, dt, noise);
252
253 let u = normal_cdf(noise[0]);
255 let p_bid = (control.lambda_plus[0] * dt).clamp(0.0, 1.0);
256 let p_ask = (control.lambda_minus[0] * dt).clamp(0.0, 1.0);
257 if u < p_bid {
258 next[0] += 1.0; next[2] += self.alpha; } else if u > 1.0 - p_ask {
261 next[0] -= 1.0; next[1] += self.alpha; }
264
265 next[1] = next[1].max(0.0);
266 next[2] = next[2].max(0.0);
267
268 next
269 }
270
271 fn is_diffusion_dimension(&self, _dim: usize) -> bool {
273 false }
275
276 fn is_integer_dimension(&self, dim: usize) -> bool {
277 dim == 0 }
279
280 fn gradient_step(&self, dim: usize) -> f64 {
281 match dim {
282 1 | 2 => self.lambda_step.abs().max(1e-8),
283 _ => 1.0,
284 }
285 }
286
287 fn fill_rate_base(&self, state: &[f64; 3]) -> f64 {
290 ((state[1] + state[2]) * 0.5).max(1e-10)
291 }
292
293 fn fill_rate_decay(&self) -> f64 {
294 self.kappa
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 fn zero_grads() -> Gradients<3> {
303 Gradients {
304 fwd: [0.0; 3],
305 bwd: [0.0; 3],
306 }
307 }
308
309 fn default_model() -> BilateralHawkes {
310 BilateralHawkes::new(0.5, 0.2, 1.5, 0.5, 2.0, 1.0)
311 .with_lambda_step(0.1)
312 .with_dq(1.0)
313 }
314
315 #[test]
316 fn zero_gradient_gives_symmetric_base_spread() {
317 let m = default_model();
318 let (bid, ask) = m.get_spreads(&zero_grads());
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 bid_driven_by_lambda_minus_ask_by_lambda_plus() {
326 let m = default_model();
328 let g = zero_grads();
329 let ctrl_high_minus = m.optimize(&[0.0, 1.0, 5.0], &g);
331 let ctrl_low_minus = m.optimize(&[0.0, 1.0, 0.5], &g);
333 assert!(
334 ctrl_high_minus.lambda_plus[0] > ctrl_low_minus.lambda_plus[0],
335 "bid fill should scale with lambda-"
336 );
337
338 let ctrl_high_plus = m.optimize(&[0.0, 5.0, 1.0], &g);
340 let ctrl_low_plus = m.optimize(&[0.0, 0.5, 1.0], &g);
341 assert!(
342 ctrl_high_plus.lambda_minus[0] > ctrl_low_plus.lambda_minus[0],
343 "ask fill should scale with lambda+"
344 );
345 }
346
347 #[test]
348 fn symmetric_state_gives_equal_bid_ask() {
349 let m = default_model();
350 let g = zero_grads();
351 let ctrl = m.optimize(&[0.0, 1.0, 1.0], &g);
352 assert!(
353 (ctrl.lambda_plus[0] - ctrl.lambda_minus[0]).abs() < 1e-12,
354 "bid fill == ask fill when lambda+ == lambda- and q == 0"
355 );
356 }
357
358 #[test]
359 fn independent_intensity_upwinding() {
360 let m = default_model();
361 let g = zero_grads();
362 let ctrl = m.optimize(&[0.0, 0.1, 1.0], &g);
364 assert!(ctrl.lambda_plus[1] > 0.0, "lambda+ should drift up");
365 assert!(
366 ctrl.lambda_minus[1] < 1e-12,
367 "no downward lambda+ transport"
368 );
369 let ctrl2 = m.optimize(&[0.0, 1.0, 3.0], &g);
372 assert!(ctrl2.lambda_minus[2] > 0.0, "lambda- should drift down");
373 assert!(
374 ctrl2.lambda_plus[2] < 1e-12,
375 "no upward lambda- transport when net drift is negative"
376 );
377 }
378
379 #[test]
380 fn hawkes_jump_terms_raise_upward_lambda_rates() {
381 let m = default_model();
382 let g = zero_grads();
383 let ctrl = m.optimize(&[0.0, 1.0, 1.0], &g);
384
385 assert!(ctrl.lambda_plus[1] > 0.0);
387 assert!(ctrl.lambda_plus[2] > 0.0);
389 }
390
391 #[test]
392 fn controlled_step_applies_side_specific_self_excitation() {
393 let m = default_model();
394 let ctrl = ControlOutput {
395 lambda_plus: [1.0, 0.0, 0.0],
396 lambda_minus: [0.0, 0.0, 0.0],
397 flow: 0.0,
398 };
399
400 let s0 = [0.0, 1.0, 1.0];
402 let s1 = m.next_step_controlled(&s0, &ctrl, 0.1, &[-10.0, 0.0, 0.0]);
403 assert!(s1[2] >= s0[2], "lambda- should increase on bid fill");
404 }
405
406 #[test]
407 fn forward_step_clamped_to_nonnegative() {
408 let m = default_model();
409 let state = [0.0, 0.0, 0.0];
410 let next = m.next_step(&state, 1.0, &[0.0; 3]);
411 assert!(next[1] >= 0.0);
412 assert!(next[2] >= 0.0);
413 }
414
415 #[test]
416 fn terminal_zero_condition_returns_zero() {
417 let m = default_model().with_terminal_condition(TerminalCondition::Zero);
418 let v = m.terminal(&[3.0, 1.0, 1.0]);
419 assert!(v.abs() < 1e-12);
420 }
421
422 #[test]
423 fn terminal_liquidation_cost_matches_formula() {
424 let m = default_model().with_terminal_condition(TerminalCondition::LiquidationCost);
425 let q: f64 = -4.0;
426 let base = (1.0 / m.gamma) * (1.0 + m.gamma / m.kappa).ln();
427 let expected = -q.abs() * base;
428 let got = m.terminal(&[q, 1.2, 0.8]);
429 assert!((got - expected).abs() < 1e-12);
430 }
431}