Skip to main content

rustyqlib/equity/
bates.rs

1//! Bates stochastic-volatility jump-diffusion models: Heston dynamics
2//! plus a compound-Poisson jump in the log-price.
3//!
4//! - **Bates (1996)**, `BatesParams`: lognormal (Merton) jump sizes —
5//!   the classic SVJ model, adding the short-dated skew and smile that
6//!   pure Heston cannot produce;
7//! - **Bates double-exponential**, `BatesDoubleExpParams`: Kou (2002)
8//!   asymmetric double-exponential jump sizes — separate up/down tail
9//!   decay rates, giving independent control of the two wings.
10//!
11//! Both price semi-analytically through the characteristic function:
12//! the log-price CF is the Heston CF **times** an independent jump
13//! factor `exp(lambda t (E[e^{iuY}] - 1) - iu lambda t kbar)` with
14//! `kbar = E[e^Y] - 1` the martingale compensator, so the P1/P2
15//! machinery of [`heston`](crate::equity::heston) is reused unchanged
16//! (the compensator keeps `phi(-i) = forward` exactly). With
17//! `intensity = 0` both models collapse to Heston to machine precision
18//! (tested); with the vol-of-vol collapsed they reduce to Merton / Kou
19//! jump-diffusion, which the tests verify against independent oracles.
20
21use 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// ── Jump specifications ─────────────────────────────────────────────────
28
29/// Lognormal (Merton) jumps: `ln(1 + J) ~ N(ln(1 + mean_jump) -
30/// jump_vol^2/2, jump_vol^2)`, arriving at `intensity` per year.
31#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
32pub struct MertonJumps {
33    /// Expected number of jumps per year (`lambda >= 0`).
34    pub intensity: f64,
35    /// Mean relative jump size `E[J] = E[e^Y] - 1 > -1` (negative =
36    /// downward jumps).
37    pub mean_jump: f64,
38    /// Volatility of the log jump size (`> 0`).
39    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    /// Martingale compensator `kbar = E[e^Y] - 1`.
57    fn kbar(&self) -> f64 {
58        self.mean_jump
59    }
60
61    /// `E[e^{iuY}]` for complex `u`: `exp(iu nu - u^2 delta^2 / 2)`.
62    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/// Kou (2002) double-exponential jumps: upward moves with probability
72/// `p_up` and decay `eta_up`, downward with decay `eta_down` —
73/// independent control of the two smile wings.
74#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
75pub struct KouJumps {
76    /// Expected number of jumps per year (`lambda >= 0`).
77    pub intensity: f64,
78    /// Probability a jump is upward (`0..=1`).
79    pub p_up: f64,
80    /// Upward tail decay (`> 1` so the compensator is finite; the mean
81    /// up-jump in log space is `1/eta_up`).
82    pub eta_up: f64,
83    /// Downward tail decay (`> 0`; mean down-jump `1/eta_down`).
84    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    /// `kbar = E[e^Y] - 1 = p eta1/(eta1 - 1) + (1-p) eta2/(eta2 + 1) - 1`.
105    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    /// `E[e^{iuY}] = p eta1/(eta1 - iu) + (1-p) eta2/(eta2 + iu)`.
112    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// ── Models ──────────────────────────────────────────────────────────────
123
124/// Bates (1996): Heston diffusion plus lognormal jumps.
125#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
126pub struct BatesParams {
127    pub heston: HestonParams,
128    pub jumps: MertonJumps,
129}
130
131/// Heston diffusion plus Kou double-exponential jumps.
132#[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
152/// The compensated compound-Poisson factor
153/// `exp(lambda t (cf_jump(u) - 1) - iu lambda t kbar)`.
154fn 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
163/// Log-price characteristic function of the Bates (Heston + Merton
164/// jumps) model — the diffusion CF times the compensated jump factor.
165pub(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, &params.heston)
175        .mul(jump_factor(u, t, j.intensity, j.kbar(), j.cf(u)))
176}
177
178/// Log-price characteristic function of the Heston + Kou model.
179pub(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, &params.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
219/// Semi-analytic Bates (SVJ) price of a European vanilla option.
220pub 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        &params.heston,
234        jumps.intensity,
235        jumps.kbar(),
236        &|u| jumps.cf(u),
237        put_or_call,
238    )
239}
240
241/// Semi-analytic Bates double-exponential (Heston + Kou jumps) price of
242/// a European vanilla option.
243pub 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        &params.heston,
257        jumps.intensity,
258        jumps.kbar(),
259        &|u| jumps.cf(u),
260        put_or_call,
261    )
262}
263
264// ── Calibration ─────────────────────────────────────────────────────────
265
266pub use crate::equity::heston::HestonQuote;
267
268/// Calibration outcome for the lognormal-jump Bates model.
269#[derive(Debug, Clone)]
270pub struct BatesFit {
271    pub params: BatesParams,
272    /// Root-mean-square price error over the quotes.
273    pub rmse: f64,
274    pub iterations: usize,
275    pub converged: bool,
276}
277
278/// Calibration outcome for the double-exponential-jump Bates model.
279#[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    /// Unconstrained space: Heston's five transforms plus
289    /// `[ln lambda, ln(1 + mean_jump), ln jump_vol]`.
290    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    /// Unconstrained space: Heston's five transforms plus
312    /// `[ln lambda, logit p_up, ln(eta_up - 1), ln eta_down]`.
313    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    // one COS pricer (one CF sweep) per expiry per residual evaluation:
347    // the whole smile prices for the cost of one option
348    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
367/// Calibrate all eight Bates parameters to European vanilla quotes —
368/// the same Levenberg-Marquardt-in-transform-space pattern as
369/// [`heston::calibrate`](crate::equity::heston::calibrate). Short-dated
370/// quotes are what identify the jump parameters against the diffusion.
371pub 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
389/// Calibrate all nine double-exponential Bates parameters to European
390/// vanilla quotes.
391pub 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        // collapse the vol-of-vol: Bates -> Merton jump-diffusion, which
470        // has the classic Poisson-weighted Black-Scholes series
471        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        // collapse the vol-of-vol: the double-exp model becomes Kou
510        // jump-diffusion, simulated directly and deterministically
511        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            // Poisson(lambda T) by exponential inter-arrival products
535            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        // jump direction moves the wings the right way: down-biased jumps
564        // price OTM puts above what up-biased jumps do, and vice versa
565        // for OTM calls (same intensity and jump vol, so a clean contrast)
566        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        // the same asymmetry holds for Kou jumps through p_up
577        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        // quotes across two expiries (the short one identifies the jumps);
589        // perturbed start; the fit must reprice to sub-cent accuracy
590        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, &quotes, &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, &quotes, &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; // infinite expected up-jump
639        assert!(d.validate().is_err());
640        d = kou();
641        d.jumps.p_up = 1.4;
642        assert!(d.validate().is_err());
643    }
644}