1use super::control::{ControlProblem, StateDerivatives};
2use super::market_making::MarketMakingControl;
3use super::normal_cdf;
4use crate::numeric::finite_difference::discretization::{DimensionKind, Transport};
5use crate::numeric::finite_difference::pde::PdeProblem;
6
7pub use super::TerminalCondition;
9
10#[derive(Clone, Debug)]
54pub struct BilateralHawkes {
55 pub gamma: f64,
57 pub sigma: f64,
59 pub kappa: f64,
61 pub alpha: f64,
64 pub beta: f64,
66 pub mu: f64,
68 pub lambda_step: f64,
70 pub dq: f64,
72 pub q_min: f64,
74 pub q_max: f64,
76 pub terminal_condition: TerminalCondition,
78 pub terminal_liquidation_half_spread: Option<f64>,
81}
82
83impl BilateralHawkes {
84 pub fn new(gamma: f64, sigma: f64, kappa: f64, alpha: f64, beta: f64, mu: f64) -> Self {
85 Self {
86 gamma,
87 sigma,
88 kappa,
89 alpha,
90 beta,
91 mu,
92 lambda_step: 1.0,
93 dq: 1.0,
94 q_min: f64::NEG_INFINITY,
95 q_max: f64::INFINITY,
96 terminal_condition: TerminalCondition::Zero,
97 terminal_liquidation_half_spread: None,
98 }
99 }
100
101 pub fn with_terminal_condition(mut self, terminal_condition: TerminalCondition) -> Self {
102 self.terminal_condition = terminal_condition;
103 self
104 }
105
106 pub fn with_terminal_liquidation_half_spread(mut self, half_spread: f64) -> Self {
107 self.terminal_liquidation_half_spread = Some(half_spread.max(0.0));
108 self
109 }
110
111 pub fn with_inventory_bounds(mut self, q_min: f64, q_max: f64) -> Self {
112 self.q_min = q_min;
113 self.q_max = q_max;
114 self
115 }
116
117 pub fn with_lambda_step(mut self, lambda_step: f64) -> Self {
118 self.lambda_step = lambda_step.abs().max(1e-8);
119 self
120 }
121
122 pub fn with_dq(mut self, dq: f64) -> Self {
123 self.dq = dq.abs().max(1e-8);
124 self
125 }
126
127 pub fn get_spreads(&self, derivs: &StateDerivatives<3>) -> (f64, f64) {
132 let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
133 let delta_bid = (base_spread - derivs.fwd[0]).clamp(-10.0, 10.0);
134 let delta_ask = (base_spread + derivs.bwd[0]).clamp(-10.0, 10.0);
135 (delta_bid, delta_ask)
136 }
137
138 pub fn fill_rate_base(&self, state: &[f64; 3]) -> f64 {
141 ((state[1] + state[2]) * 0.5).max(1e-10)
142 }
143
144 pub fn fill_rate_decay(&self) -> f64 {
146 self.kappa
147 }
148}
149
150impl ControlProblem<3> for BilateralHawkes {
151 type Control = MarketMakingControl;
152
153 fn optimize(&self, _t: f64, state: &[f64; 3], derivs: &StateDerivatives<3>) -> Self::Control {
154 let q = state[0];
155 let lambda_plus = state[1].max(0.0);
156 let lambda_minus = state[2].max(0.0);
157
158 let (d_bid, d_ask) = self.get_spreads(derivs);
159
160 let max_mult = 20.0_f64;
161 let lambda_bid_fill = lambda_minus * (-self.kappa * d_bid).exp().min(max_mult);
162 let lambda_ask_fill = lambda_plus * (-self.kappa * d_ask).exp().min(max_mult);
163
164 let lambda_bid_fill = if q >= self.q_max {
165 0.0
166 } else {
167 lambda_bid_fill
168 };
169 let lambda_ask_fill = if q <= self.q_min {
170 0.0
171 } else {
172 lambda_ask_fill
173 };
174
175 MarketMakingControl::new(lambda_bid_fill, lambda_ask_fill)
176 }
177
178 fn running_reward(&self, _t: f64, _state: &[f64; 3], control: &Self::Control) -> f64 {
179 (control.bid_intensity + control.ask_intensity) / (self.gamma + self.kappa)
180 }
181
182 fn bsde_driver(
183 &self,
184 _t: f64,
185 state: &[f64; 3],
186 control: &Self::Control,
187 _derivs: &StateDerivatives<3>,
188 dt: f64,
189 ) -> f64 {
190 let q = state[0];
195 let local = -0.5 * self.gamma * self.sigma.powi(2) * q.powi(2);
196 let rate_cap = 1.0 / dt.max(1e-12);
197 let reward = (control.bid_intensity.min(rate_cap) + control.ask_intensity.min(rate_cap))
198 / (self.gamma + self.kappa);
199 reward + local
200 }
201
202 fn generator(
203 &self,
204 _t: f64,
205 state: &[f64; 3],
206 _control: &Self::Control,
207 derivs: &StateDerivatives<3>,
208 ) -> f64 {
209 let q = state[0];
210 let lambda_plus = state[1].max(0.0);
211 let lambda_minus = state[2].max(0.0);
212 let net_drift_plus = self.beta * (self.mu - lambda_plus) + self.alpha * lambda_plus;
213 let net_drift_minus = self.beta * (self.mu - lambda_minus) + self.alpha * lambda_minus;
214
215 -0.5 * self.gamma * self.sigma.powi(2) * q.powi(2)
216 + net_drift_plus * derivs.grad[1]
217 + net_drift_minus * derivs.grad[2]
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 discount_rate(&self, _state: &[f64; 3]) -> f64 {
233 0.0
234 }
235
236 fn constant_discount_rate(&self) -> Option<f64> {
237 Some(0.0)
238 }
239
240 fn next_step(&self, _t: f64, state: &[f64; 3], dt: f64, noise: &[f64; 3]) -> [f64; 3] {
241 let lambda_plus = state[1].max(0.0);
242 let lambda_minus = state[2].max(0.0);
243 let drift_plus = self.beta * (self.mu - lambda_plus) + self.alpha * lambda_plus;
244 let drift_minus = self.beta * (self.mu - lambda_minus) + self.alpha * lambda_minus;
245 let mut next = [
246 state[0],
247 (lambda_plus + drift_plus * dt).max(0.0),
248 (lambda_minus + drift_minus * dt).max(0.0),
249 ];
250
251 let u = normal_cdf(noise[0]);
252 let lambda_plus = state[1].max(0.0);
253 let lambda_minus = state[2].max(0.0);
254 let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
255 let lambda_bid_fill = lambda_minus * (-self.kappa * base_spread).exp();
256 let lambda_ask_fill = lambda_plus * (-self.kappa * base_spread).exp();
257 let p_bid = (lambda_bid_fill * dt).clamp(0.0, 1.0);
258 let p_ask = (lambda_ask_fill * dt).clamp(0.0, 1.0);
259 if u < p_bid {
260 next[0] += 1.0;
261 next[2] += self.alpha;
262 } else if u > 1.0 - p_ask {
263 next[0] -= 1.0;
264 next[1] += self.alpha;
265 }
266
267 next[1] = next[1].max(0.0);
268 next[2] = next[2].max(0.0);
269 next
270 }
271
272 fn next_step_controlled(
273 &self,
274 _t: f64,
275 state: &[f64; 3],
276 control: &Self::Control,
277 dt: f64,
278 noise: &[f64; 3],
279 ) -> [f64; 3] {
280 let lambda_plus = state[1].max(0.0);
281 let lambda_minus = state[2].max(0.0);
282 let drift_plus = self.beta * (self.mu - lambda_plus) + self.alpha * lambda_plus;
283 let drift_minus = self.beta * (self.mu - lambda_minus) + self.alpha * lambda_minus;
284 let mut next = [
285 state[0],
286 (lambda_plus + drift_plus * dt).max(0.0),
287 (lambda_minus + drift_minus * dt).max(0.0),
288 ];
289
290 let u = normal_cdf(noise[0]);
293 let p_bid = (control.bid_intensity * dt).clamp(0.0, 1.0);
294 let p_ask = (control.ask_intensity * dt).clamp(0.0, 1.0);
295 if u < p_bid {
296 next[0] += 1.0;
297 next[2] += self.alpha;
298 } else if u > 1.0 - p_ask {
299 next[0] -= 1.0;
300 next[1] += self.alpha;
301 }
302
303 next[1] = next[1].max(0.0);
304 next[2] = next[2].max(0.0);
305 next
306 }
307
308 fn is_diffusion_dimension(&self, _dim: usize) -> bool {
309 false
310 }
311
312 fn gradient_step(&self, dim: usize) -> f64 {
313 match dim {
314 1 | 2 => self.lambda_step.abs().max(1e-8),
315 _ => 1.0,
316 }
317 }
318}
319
320impl PdeProblem<3> for BilateralHawkes {
321 fn dimension_kind(&self, dim: usize) -> DimensionKind {
322 match dim {
323 0 => DimensionKind::DiscreteJump,
324 _ => DimensionKind::DeterministicDrift,
325 }
326 }
327
328 fn transport(
329 &self,
330 _t: f64,
331 state: &[f64; 3],
332 control: &Self::Control,
333 derivs: &StateDerivatives<3>,
334 ) -> Transport<3> {
335 let q = state[0];
336 let lambda_plus = state[1].max(0.0);
337 let lambda_minus = state[2].max(0.0);
338 let net_drift_plus = self.beta * (self.mu - lambda_plus) + self.alpha * lambda_plus;
339 let net_drift_minus = self.beta * (self.mu - lambda_minus) + self.alpha * lambda_minus;
340 let step = self.lambda_step.abs().max(1e-12);
341
342 let rate_plus = net_drift_plus.abs() / step;
343 let rate_minus = net_drift_minus.abs() / step;
344 let (lp_plus, lp_minus) = if net_drift_plus >= 0.0 {
345 (rate_plus, 0.0)
346 } else {
347 (0.0, rate_plus)
348 };
349 let (lm_plus, lm_minus) = if net_drift_minus >= 0.0 {
350 (rate_minus, 0.0)
351 } else {
352 (0.0, rate_minus)
353 };
354
355 let hamiltonian =
356 (control.bid_intensity + control.ask_intensity) / (self.gamma + self.kappa);
357 let jump_transport =
358 control.bid_intensity * derivs.fwd[0] - control.ask_intensity * derivs.bwd[0];
359 let local = -0.5 * self.gamma * self.sigma.powi(2) * q.powi(2);
360
361 Transport::new(
362 [control.bid_intensity, lp_plus, lm_plus],
363 [control.ask_intensity, lp_minus, lm_minus],
364 hamiltonian + local - jump_transport,
365 )
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 fn zero_derivs() -> StateDerivatives<3> {
374 StateDerivatives::new([0.0; 3], [0.0; 3])
375 }
376
377 fn default_model() -> BilateralHawkes {
378 BilateralHawkes::new(0.5, 0.2, 1.5, 0.5, 2.0, 1.0)
379 .with_lambda_step(0.1)
380 .with_dq(1.0)
381 }
382
383 #[test]
384 fn zero_gradient_gives_symmetric_base_spread() {
385 let m = default_model();
386 let (bid, ask) = m.get_spreads(&zero_derivs());
387 let base = (1.0 / m.gamma) * (1.0 + m.gamma / m.kappa).ln();
388 assert!((bid - base).abs() < 1e-12);
389 assert!((ask - base).abs() < 1e-12);
390 }
391
392 #[test]
393 fn bsde_driver_excludes_intensity_transport() {
394 let m = default_model();
395 let state = [2.0, 1.0, 1.0];
396 let control = ControlProblem::optimize(&m, 0.0, &state, &zero_derivs());
397
398 let mut derivs = zero_derivs();
399 derivs.grad[1] = 1.0;
400 derivs.grad[2] = 1.0;
401
402 let dt = 0.01;
403 let q = state[0];
404 let local = -0.5 * m.gamma * m.sigma.powi(2) * q.powi(2);
405 let rate_cap = 1.0 / dt;
406 let reward = (control.bid_intensity.min(rate_cap) + control.ask_intensity.min(rate_cap))
407 / (m.gamma + m.kappa);
408 let expected = reward + local;
409
410 assert_eq!(
411 ControlProblem::bsde_driver(&m, 0.0, &state, &control, &derivs, dt),
412 expected
413 );
414 assert!(
415 ControlProblem::bsde_driver(&m, 0.0, &state, &control, &derivs, dt)
416 != ControlProblem::driver(&m, 0.0, &state, &control, &derivs),
417 "full driver adds intensity transport, bsde driver must not"
418 );
419 }
420
421 #[test]
422 fn next_step_controlled_uses_optimal_intensities() {
423 let m = default_model();
424 let state = [1.0, 1.0, 1.0];
425
426 let ctrl = MarketMakingControl::new(0.0, 0.0);
427 let next =
428 ControlProblem::next_step_controlled(&m, 0.0, &state, &ctrl, 0.01, &[0.0, 0.0, 0.0]);
429 assert_eq!(next[0], 1.0);
430
431 let ctrl = MarketMakingControl::new(1000.0, 0.0);
432 let next =
433 ControlProblem::next_step_controlled(&m, 0.0, &state, &ctrl, 0.01, &[0.0, 0.0, 0.0]);
434 assert_eq!(next[0], 2.0);
435 }
436
437 #[test]
438 fn bid_driven_by_lambda_minus_ask_by_lambda_plus() {
439 let m = default_model();
440 let d = zero_derivs();
441 let ctrl_high_minus = ControlProblem::optimize(&m, 0.0, &[0.0, 1.0, 5.0], &d);
442 let ctrl_low_minus = ControlProblem::optimize(&m, 0.0, &[0.0, 1.0, 0.5], &d);
443 assert!(
444 ctrl_high_minus.bid_intensity > ctrl_low_minus.bid_intensity,
445 "bid fill should scale with lambda-"
446 );
447
448 let ctrl_high_plus = ControlProblem::optimize(&m, 0.0, &[0.0, 5.0, 1.0], &d);
449 let ctrl_low_plus = ControlProblem::optimize(&m, 0.0, &[0.0, 0.5, 1.0], &d);
450 assert!(
451 ctrl_high_plus.ask_intensity > ctrl_low_plus.ask_intensity,
452 "ask fill should scale with lambda+"
453 );
454 }
455
456 #[test]
457 fn symmetric_state_gives_equal_bid_ask() {
458 let m = default_model();
459 let d = zero_derivs();
460 let ctrl = ControlProblem::optimize(&m, 0.0, &[0.0, 1.0, 1.0], &d);
461 assert!(
462 (ctrl.bid_intensity - ctrl.ask_intensity).abs() < 1e-12,
463 "bid fill == ask fill when lambda+ == lambda- and q == 0"
464 );
465 }
466
467 #[test]
468 fn generator_encodes_intensity_upwinding() {
469 let m = default_model();
470 let control = MarketMakingControl::new(1.0, 1.0);
471 let state = [0.0, 0.1, 1.0];
472
473 let zero = StateDerivatives::new([0.0; 3], [0.0; 3]);
474 let mut plus_grad = zero;
475 plus_grad.grad[1] = 1.0;
476
477 let base = ControlProblem::generator(&m, 0.0, &state, &control, &zero);
478 let upwind = ControlProblem::generator(&m, 0.0, &state, &control, &plus_grad);
479 assert!(upwind > base);
482 }
483
484 #[test]
485 fn next_step_clamped_to_nonnegative() {
486 let m = default_model();
487 let state = [0.0, 0.0, 0.0];
488 let next = ControlProblem::next_step(&m, 0.0, &state, 1.0, &[0.0; 3]);
489 assert!(next[1] >= 0.0);
490 assert!(next[2] >= 0.0);
491 }
492
493 #[test]
494 fn terminal_zero_condition_returns_zero() {
495 let m = default_model().with_terminal_condition(TerminalCondition::Zero);
496 let v = ControlProblem::terminal(&m, &[3.0, 1.0, 1.0]);
497 assert!(v.abs() < 1e-12);
498 }
499
500 #[test]
501 fn terminal_liquidation_cost_matches_formula() {
502 let m = default_model().with_terminal_condition(TerminalCondition::LiquidationCost);
503 let q: f64 = -4.0;
504 let base = (1.0 / m.gamma) * (1.0 + m.gamma / m.kappa).ln();
505 let expected = -q.abs() * base;
506 let got = ControlProblem::terminal(&m, &[q, 1.2, 0.8]);
507 assert!((got - expected).abs() < 1e-12);
508 }
509}