1use serde::{Deserialize, Serialize};
22
23use crate::core::trade::PutOrCall;
24use crate::equity::heston::{characteristic_fn, probabilities_with_cf, Cpx, HestonParams, I};
25use crate::core::errors::RustyQLibError;
26
27#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
32pub struct MertonJumps {
33 pub intensity: f64,
35 pub mean_jump: f64,
38 pub jump_vol: f64,
40}
41
42impl MertonJumps {
43 pub fn validate(&self) -> Result<(), RustyQLibError> {
44 if self.intensity < 0.0 {
45 return Err(RustyQLibError::invalid_input("bates params", "jump intensity must be non-negative"));
46 }
47 if self.mean_jump <= -1.0 {
48 return Err(RustyQLibError::invalid_input("bates params", "mean jump must be greater than -100%"));
49 }
50 if self.jump_vol <= 0.0 {
51 return Err(RustyQLibError::invalid_input("bates params", "jump vol must be positive"));
52 }
53 Ok(())
54 }
55
56 fn kbar(&self) -> f64 {
58 self.mean_jump
59 }
60
61 fn cf(&self, u: Cpx) -> Cpx {
63 let nu = (1.0 + self.mean_jump).ln() - 0.5 * self.jump_vol * self.jump_vol;
64 I.mul(u)
65 .scale(nu)
66 .sub(u.mul(u).scale(0.5 * self.jump_vol * self.jump_vol))
67 .exp()
68 }
69}
70
71#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
75pub struct KouJumps {
76 pub intensity: f64,
78 pub p_up: f64,
80 pub eta_up: f64,
83 pub eta_down: f64,
85}
86
87impl KouJumps {
88 pub fn validate(&self) -> Result<(), RustyQLibError> {
89 if self.intensity < 0.0 {
90 return Err(RustyQLibError::invalid_input("bates params", "jump intensity must be non-negative"));
91 }
92 if !(0.0..=1.0).contains(&self.p_up) {
93 return Err(RustyQLibError::invalid_input("bates params", "p_up must be in [0, 1]"));
94 }
95 if self.eta_up <= 1.0 {
96 return Err(RustyQLibError::invalid_input("bates params", "eta_up must exceed 1 (finite expected up-jump)"));
97 }
98 if self.eta_down <= 0.0 {
99 return Err(RustyQLibError::invalid_input("bates params", "eta_down must be positive"));
100 }
101 Ok(())
102 }
103
104 fn kbar(&self) -> f64 {
106 self.p_up * self.eta_up / (self.eta_up - 1.0)
107 + (1.0 - self.p_up) * self.eta_down / (self.eta_down + 1.0)
108 - 1.0
109 }
110
111 fn cf(&self, u: Cpx) -> Cpx {
113 let iu = I.mul(u);
114 let up = Cpx::real(self.eta_up).div(Cpx::real(self.eta_up).sub(iu)).scale(self.p_up);
115 let down = Cpx::real(self.eta_down)
116 .div(Cpx::real(self.eta_down).add(iu))
117 .scale(1.0 - self.p_up);
118 up.add(down)
119 }
120}
121
122#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
126pub struct BatesParams {
127 pub heston: HestonParams,
128 pub jumps: MertonJumps,
129}
130
131#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
133pub struct BatesDoubleExpParams {
134 pub heston: HestonParams,
135 pub jumps: KouJumps,
136}
137
138impl BatesParams {
139 pub fn validate(&self) -> Result<(), RustyQLibError> {
140 self.heston.validate()?;
141 self.jumps.validate()
142 }
143}
144
145impl BatesDoubleExpParams {
146 pub fn validate(&self) -> Result<(), RustyQLibError> {
147 self.heston.validate()?;
148 self.jumps.validate()
149 }
150}
151
152fn jump_factor(u: Cpx, t: f64, intensity: f64, kbar: f64, jump_cf: Cpx) -> Cpx {
155 let one = Cpx::real(1.0);
156 jump_cf
157 .sub(one)
158 .scale(intensity * t)
159 .sub(I.mul(u).scale(intensity * t * kbar))
160 .exp()
161}
162
163pub(crate) fn ln_price_cf(
166 u: Cpx,
167 s: f64,
168 r: f64,
169 q: f64,
170 t: f64,
171 params: &BatesParams,
172) -> Cpx {
173 let j = params.jumps;
174 characteristic_fn(u, s, r, q, t, ¶ms.heston)
175 .mul(jump_factor(u, t, j.intensity, j.kbar(), j.cf(u)))
176}
177
178pub(crate) fn ln_price_cf_double_exp(
180 u: Cpx,
181 s: f64,
182 r: f64,
183 q: f64,
184 t: f64,
185 params: &BatesDoubleExpParams,
186) -> Cpx {
187 let j = params.jumps;
188 characteristic_fn(u, s, r, q, t, ¶ms.heston)
189 .mul(jump_factor(u, t, j.intensity, j.kbar(), j.cf(u)))
190}
191
192#[allow(clippy::too_many_arguments)]
193fn price_with_jumps(
194 s: f64,
195 k: f64,
196 r: f64,
197 q: f64,
198 t: f64,
199 hp: &HestonParams,
200 intensity: f64,
201 kbar: f64,
202 jump_cf: &dyn Fn(Cpx) -> Cpx,
203 put_or_call: PutOrCall,
204) -> f64 {
205 assert!(s > 0.0 && k > 0.0 && t > 0.0);
206 let cf = |u: Cpx| -> Cpx {
207 characteristic_fn(u, s, r, q, t, hp)
208 .mul(jump_factor(u, t, intensity, kbar, jump_cf(u)))
209 };
210 let forward = s * ((r - q) * t).exp();
211 let (p1, p2) = probabilities_with_cf(&cf, forward, k);
212 let call = s * (-q * t).exp() * p1 - k * (-r * t).exp() * p2;
213 match put_or_call {
214 PutOrCall::Call => call,
215 PutOrCall::Put => call - s * (-q * t).exp() + k * (-r * t).exp(),
216 }
217}
218
219pub fn bates_price(
221 s: f64,
222 k: f64,
223 r: f64,
224 q: f64,
225 t: f64,
226 params: &BatesParams,
227 put_or_call: PutOrCall,
228) -> f64 {
229 params.validate().expect("invalid Bates parameters");
230 let jumps = params.jumps;
231 price_with_jumps(
232 s, k, r, q, t,
233 ¶ms.heston,
234 jumps.intensity,
235 jumps.kbar(),
236 &|u| jumps.cf(u),
237 put_or_call,
238 )
239}
240
241pub fn bates_double_exp_price(
244 s: f64,
245 k: f64,
246 r: f64,
247 q: f64,
248 t: f64,
249 params: &BatesDoubleExpParams,
250 put_or_call: PutOrCall,
251) -> f64 {
252 params.validate().expect("invalid Bates double-exponential parameters");
253 let jumps = params.jumps;
254 price_with_jumps(
255 s, k, r, q, t,
256 ¶ms.heston,
257 jumps.intensity,
258 jumps.kbar(),
259 &|u| jumps.cf(u),
260 put_or_call,
261 )
262}
263
264pub use crate::equity::heston::HestonQuote;
267
268#[derive(Debug, Clone)]
270pub struct BatesFit {
271 pub params: BatesParams,
272 pub rmse: f64,
274 pub iterations: usize,
275 pub converged: bool,
276}
277
278#[derive(Debug, Clone)]
280pub struct BatesDoubleExpFit {
281 pub params: BatesDoubleExpParams,
282 pub rmse: f64,
283 pub iterations: usize,
284 pub converged: bool,
285}
286
287impl BatesParams {
288 fn to_unconstrained(&self) -> Vec<f64> {
291 let mut u = self.heston.to_unconstrained();
292 u.push(self.jumps.intensity.max(1e-8).ln());
293 u.push((1.0 + self.jumps.mean_jump).ln());
294 u.push(self.jumps.jump_vol.ln());
295 u
296 }
297
298 fn from_unconstrained(u: &[f64]) -> BatesParams {
299 BatesParams {
300 heston: HestonParams::from_unconstrained(&u[..5]),
301 jumps: MertonJumps {
302 intensity: u[5].exp(),
303 mean_jump: u[6].exp() - 1.0,
304 jump_vol: u[7].exp(),
305 },
306 }
307 }
308}
309
310impl BatesDoubleExpParams {
311 fn to_unconstrained(&self) -> Vec<f64> {
314 let p = self.jumps.p_up.clamp(1e-6, 1.0 - 1e-6);
315 let mut u = self.heston.to_unconstrained();
316 u.push(self.jumps.intensity.max(1e-8).ln());
317 u.push((p / (1.0 - p)).ln());
318 u.push((self.jumps.eta_up - 1.0).max(1e-8).ln());
319 u.push(self.jumps.eta_down.ln());
320 u
321 }
322
323 fn from_unconstrained(u: &[f64]) -> BatesDoubleExpParams {
324 BatesDoubleExpParams {
325 heston: HestonParams::from_unconstrained(&u[..5]),
326 jumps: KouJumps {
327 intensity: u[5].exp(),
328 p_up: 1.0 / (1.0 + (-u[6]).exp()),
329 eta_up: 1.0 + u[7].exp(),
330 eta_down: u[8].exp(),
331 },
332 }
333 }
334}
335
336fn calibrate_generic<P>(
337 quotes: &[HestonQuote],
338 x0: Vec<f64>,
339 r: f64,
340 unpack: impl Fn(&[f64]) -> P,
341 cf: impl Fn(&P, Cpx, f64) -> Cpx,
342) -> (P, f64, usize, bool) {
343 use crate::core::optimization::{levenberg_marquardt, OptimConfig};
344 use crate::equity::cos::{group_by_maturity, CosPricer, CALIBRATION_TERMS};
345 assert!(!quotes.is_empty(), "calibration needs at least one quote");
346 let groups = group_by_maturity(quotes.iter().map(|q| q.maturity));
349 let residuals = |u: &[f64]| -> Vec<f64> {
350 let p = unpack(u);
351 let mut out = vec![0.0; quotes.len()];
352 for (t, idxs) in &groups {
353 let pricer =
354 CosPricer::new(&|uu| cf(&p, uu, *t), r, *t, CALIBRATION_TERMS);
355 for &i in idxs {
356 out[i] = pricer.price(quotes[i].strike, quotes[i].put_or_call) - quotes[i].price;
357 }
358 }
359 out
360 };
361 let fit = levenberg_marquardt(&OptimConfig::new(1e-10, 100), &residuals, None, &x0);
362 let params = unpack(&fit.x);
363 let rmse = (fit.value / quotes.len() as f64).sqrt();
364 (params, rmse, fit.iterations, fit.converged)
365}
366
367pub fn calibrate(
372 s: f64,
373 r: f64,
374 q: f64,
375 quotes: &[HestonQuote],
376 start: &BatesParams,
377) -> BatesFit {
378 start.validate().expect("invalid starting parameters");
379 let (params, rmse, iterations, converged) = calibrate_generic(
380 quotes,
381 start.to_unconstrained(),
382 r,
383 BatesParams::from_unconstrained,
384 |p, u, t| ln_price_cf(u, s, r, q, t, p),
385 );
386 BatesFit { params, rmse, iterations, converged }
387}
388
389pub fn calibrate_double_exp(
392 s: f64,
393 r: f64,
394 q: f64,
395 quotes: &[HestonQuote],
396 start: &BatesDoubleExpParams,
397) -> BatesDoubleExpFit {
398 start.validate().expect("invalid starting parameters");
399 let (params, rmse, iterations, converged) = calibrate_generic(
400 quotes,
401 start.to_unconstrained(),
402 r,
403 BatesDoubleExpParams::from_unconstrained,
404 |p, u, t| ln_price_cf_double_exp(u, s, r, q, t, p),
405 );
406 BatesDoubleExpFit { params, rmse, iterations, converged }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use crate::equity::blackscholes::bs_price;
413 use crate::equity::heston::heston_price;
414
415 const S: f64 = 100.0;
416 const K: f64 = 100.0;
417 const R: f64 = 0.03;
418 const Q: f64 = 0.01;
419 const T: f64 = 1.0;
420
421 fn heston() -> HestonParams {
422 HestonParams { v0: 0.04, kappa: 1.5, theta: 0.05, vol_of_vol: 0.5, rho: -0.7 }
423 }
424
425 fn bates() -> BatesParams {
426 BatesParams {
427 heston: heston(),
428 jumps: MertonJumps { intensity: 0.6, mean_jump: -0.08, jump_vol: 0.15 },
429 }
430 }
431
432 fn kou() -> BatesDoubleExpParams {
433 BatesDoubleExpParams {
434 heston: heston(),
435 jumps: KouJumps { intensity: 0.6, p_up: 0.35, eta_up: 20.0, eta_down: 12.0 },
436 }
437 }
438
439 #[test]
440 fn zero_intensity_collapses_to_heston() {
441 let mut b = bates();
442 b.jumps.intensity = 0.0;
443 let mut d = kou();
444 d.jumps.intensity = 0.0;
445 for k in [80.0, 100.0, 120.0] {
446 let h = heston_price(S, k, R, Q, T, &heston(), PutOrCall::Call);
447 let bp = bates_price(S, k, R, Q, T, &b, PutOrCall::Call);
448 let dp = bates_double_exp_price(S, k, R, Q, T, &d, PutOrCall::Call);
449 assert!((bp - h).abs() < 1e-10, "bates k={k}: {bp} vs {h}");
450 assert!((dp - h).abs() < 1e-10, "kou k={k}: {dp} vs {h}");
451 }
452 }
453
454 #[test]
455 fn put_call_parity_holds_for_both_models() {
456 let parity = S * (-Q * T).exp() - K * (-R * T).exp();
457 let b = bates();
458 let c = bates_price(S, K, R, Q, T, &b, PutOrCall::Call);
459 let p = bates_price(S, K, R, Q, T, &b, PutOrCall::Put);
460 assert!((c - p - parity).abs() < 1e-8, "bates parity {}", c - p - parity);
461 let d = kou();
462 let c2 = bates_double_exp_price(S, K, R, Q, T, &d, PutOrCall::Call);
463 let p2 = bates_double_exp_price(S, K, R, Q, T, &d, PutOrCall::Put);
464 assert!((c2 - p2 - parity).abs() < 1e-8, "kou parity {}", c2 - p2 - parity);
465 }
466
467 #[test]
468 fn merton_limit_matches_the_independent_series_solution() {
469 let sigma = 0.2_f64;
472 let flat = HestonParams {
473 v0: sigma * sigma,
474 kappa: 1.0,
475 theta: sigma * sigma,
476 vol_of_vol: 1e-4,
477 rho: 0.0,
478 };
479 let jumps = MertonJumps { intensity: 0.8, mean_jump: -0.1, jump_vol: 0.2 };
480 let b = BatesParams { heston: flat, jumps };
481
482 let merton_series = |k: f64, pc: PutOrCall| -> f64 {
483 let lam_bar = jumps.intensity * (1.0 + jumps.mean_jump);
484 let mut price = 0.0;
485 for n in 0..60 {
486 let nf = n as f64;
487 let weight =
488 (-lam_bar * T).exp() * (lam_bar * T).powi(n) / (1..=n).map(|i| i as f64).product::<f64>().max(1.0);
489 let sigma_n =
490 (sigma * sigma + nf * jumps.jump_vol * jumps.jump_vol / T).sqrt();
491 let r_n = R - jumps.intensity * jumps.mean_jump
492 + nf * (1.0 + jumps.mean_jump).ln() / T;
493 price += weight * bs_price(S, k, r_n, Q, sigma_n, T, pc);
494 }
495 price
496 };
497 for k in [85.0, 100.0, 115.0] {
498 let via_cf = bates_price(S, k, R, Q, T, &b, PutOrCall::Call);
499 let via_series = merton_series(k, PutOrCall::Call);
500 assert!(
501 (via_cf - via_series).abs() < 2e-3,
502 "k = {k}: cf {via_cf} vs series {via_series}"
503 );
504 }
505 }
506
507 #[test]
508 fn kou_limit_matches_a_seeded_monte_carlo_oracle() {
509 use crate::core::montecarlo::path_rng;
512 use rand::Rng;
513
514 let sigma = 0.2_f64;
515 let flat = HestonParams {
516 v0: sigma * sigma,
517 kappa: 1.0,
518 theta: sigma * sigma,
519 vol_of_vol: 1e-4,
520 rho: 0.0,
521 };
522 let jumps = KouJumps { intensity: 0.7, p_up: 0.3, eta_up: 25.0, eta_down: 10.0 };
523 let d = BatesDoubleExpParams { heston: flat, jumps };
524
525 let n_paths = 400_000;
526 let kbar = jumps.kbar();
527 let drift = (R - Q - 0.5 * sigma * sigma - jumps.intensity * kbar) * T;
528 let mut sum = 0.0;
529 let mut sum_sq = 0.0;
530 let strike = 95.0;
531 for path in 0..n_paths {
532 let mut rng = path_rng(20260724, path);
533 let z: f64 = rng.sample(rand_distr::StandardNormal);
534 let mut jumps_sum = 0.0;
536 let threshold = (-jumps.intensity * T).exp();
537 let mut product: f64 = rng.gen();
538 while product > threshold {
539 let up: f64 = rng.gen();
540 let e: f64 = -(rng.gen::<f64>().max(1e-300)).ln();
541 jumps_sum += if up < jumps.p_up { e / jumps.eta_up } else { -e / jumps.eta_down };
542 product *= rng.gen::<f64>();
543 }
544 let s_t = S * (drift + sigma * T.sqrt() * z + jumps_sum).exp();
545 let payoff = (s_t - strike).max(0.0) * (-R * T).exp();
546 sum += payoff;
547 sum_sq += payoff * payoff;
548 }
549 let mc = sum / n_paths as f64;
550 let se = ((sum_sq / n_paths as f64 - mc * mc) / n_paths as f64).sqrt();
551 let via_cf = bates_double_exp_price(S, strike, R, Q, T, &d, PutOrCall::Call);
552 assert!(
553 (via_cf - mc).abs() < (3.0 * se).max(0.02),
554 "cf {via_cf} vs mc {mc} +/- {se}"
555 );
556 }
557
558 #[test]
559 fn jumps_raise_option_values_and_shape_the_skew() {
560 let base = heston_price(S, K, R, Q, T, &heston(), PutOrCall::Call);
561 let with_jumps = bates_price(S, K, R, Q, T, &bates(), PutOrCall::Call);
562 assert!(with_jumps > base, "{with_jumps} vs {base}");
563 let with_mean = |mean: f64| BatesParams {
567 heston: heston(),
568 jumps: MertonJumps { intensity: 0.6, mean_jump: mean, jump_vol: 0.15 },
569 };
570 let put_down = bates_price(S, 85.0, R, Q, T, &with_mean(-0.08), PutOrCall::Put);
571 let put_up = bates_price(S, 85.0, R, Q, T, &with_mean(0.08), PutOrCall::Put);
572 assert!(put_down > put_up, "put: down {put_down} vs up {put_up}");
573 let call_up = bates_price(S, 115.0, R, Q, T, &with_mean(0.08), PutOrCall::Call);
574 let call_down = bates_price(S, 115.0, R, Q, T, &with_mean(-0.08), PutOrCall::Call);
575 assert!(call_up > call_down, "call: up {call_up} vs down {call_down}");
576 let kou_with = |p_up: f64| BatesDoubleExpParams {
578 heston: heston(),
579 jumps: KouJumps { intensity: 0.6, p_up, eta_up: 20.0, eta_down: 12.0 },
580 };
581 let kp_down = bates_double_exp_price(S, 85.0, R, Q, T, &kou_with(0.1), PutOrCall::Put);
582 let kp_up = bates_double_exp_price(S, 85.0, R, Q, T, &kou_with(0.9), PutOrCall::Put);
583 assert!(kp_down > kp_up, "kou put: down-heavy {kp_down} vs up-heavy {kp_up}");
584 }
585
586 #[test]
587 fn calibration_recovers_bates_prices() {
588 let truth = bates();
591 let mut quotes = Vec::new();
592 for (t, strikes) in [(0.25_f64, [90.0, 100.0, 110.0]), (1.0, [85.0, 100.0, 115.0])] {
593 for k in strikes {
594 quotes.push(HestonQuote {
595 strike: k,
596 maturity: t,
597 price: bates_price(S, k, R, Q, t, &truth, PutOrCall::Call),
598 put_or_call: PutOrCall::Call,
599 });
600 }
601 }
602 let start = BatesParams {
603 heston: HestonParams { v0: 0.05, kappa: 1.5, theta: 0.04, vol_of_vol: 0.4, rho: -0.5 },
604 jumps: MertonJumps { intensity: 0.4, mean_jump: -0.04, jump_vol: 0.2 },
605 };
606 let fit = calibrate(S, R, Q, "es, &start);
607 assert!(fit.rmse < 1e-3, "price rmse {} params {:?}", fit.rmse, fit.params);
608 assert!(fit.params.validate().is_ok());
609 }
610
611 #[test]
612 fn calibration_recovers_double_exp_prices() {
613 let truth = kou();
614 let quotes: Vec<HestonQuote> = [85.0, 95.0, 100.0, 105.0, 115.0]
615 .iter()
616 .map(|&k| HestonQuote {
617 strike: k,
618 maturity: 0.5,
619 price: bates_double_exp_price(S, k, R, Q, 0.5, &truth, PutOrCall::Call),
620 put_or_call: PutOrCall::Call,
621 })
622 .collect();
623 let start = BatesDoubleExpParams {
624 heston: HestonParams { v0: 0.05, kappa: 1.5, theta: 0.04, vol_of_vol: 0.4, rho: -0.5 },
625 jumps: KouJumps { intensity: 0.4, p_up: 0.5, eta_up: 15.0, eta_down: 15.0 },
626 };
627 let fit = calibrate_double_exp(S, R, Q, "es, &start);
628 assert!(fit.rmse < 1e-3, "price rmse {} params {:?}", fit.rmse, fit.params);
629 assert!(fit.params.validate().is_ok());
630 }
631
632 #[test]
633 fn parameter_validation_rejects_bad_inputs() {
634 let mut b = bates();
635 b.jumps.mean_jump = -1.5;
636 assert!(b.validate().is_err());
637 let mut d = kou();
638 d.jumps.eta_up = 0.9; assert!(d.validate().is_err());
640 d = kou();
641 d.jumps.p_up = 1.4;
642 assert!(d.validate().is_err());
643 }
644}