Skip to main content

rustyqlib/equity/
heston.rs

1//! Heston (1993) stochastic volatility model.
2//!
3//! Dynamics under the risk-neutral measure:
4//! ```text
5//! dS = (r - q) S dt + sqrt(v) S dW_s
6//! dv = kappa (theta - v) dt + vol_of_vol * sqrt(v) dW_v,   d<W_s, W_v> = rho dt
7//! ```
8//!
9//! Semi-analytic pricing uses the characteristic function in the
10//! "little Heston trap" formulation (Albrecher et al. 2007), which is
11//! branch-cut stable under the principal complex logarithm, integrated
12//! with composite Simpson. Vanilla calls/puts and both binary types come
13//! from the same two probabilities:
14//! `call = S e^{-qT} P1 - K e^{-rT} P2`, cash-or-nothing `= e^{-rT} P2`,
15//! asset-or-nothing `= S e^{-qT} P1`.
16//!
17//! Monte Carlo simulation lives in the Monte Carlo engine through
18//! [`HestonProcess`](crate::equity::processes::HestonProcess): Andersen
19//! QE with martingale correction by default, full-truncation Euler on
20//! request.
21
22use serde::{Deserialize, Serialize};
23
24use crate::core::trade::PutOrCall;
25
26// ── Minimal complex arithmetic (principal branches) ─────────────────────
27
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub(crate) struct Cpx {
30    pub(crate) re: f64,
31    pub(crate) im: f64,
32}
33
34pub(crate) const I: Cpx = Cpx { re: 0.0, im: 1.0 };
35
36impl Cpx {
37    pub(crate) fn new(re: f64, im: f64) -> Self {
38        Cpx { re, im }
39    }
40    pub(crate) fn real(re: f64) -> Self {
41        Cpx { re, im: 0.0 }
42    }
43    pub(crate) fn add(self, o: Cpx) -> Cpx {
44        Cpx::new(self.re + o.re, self.im + o.im)
45    }
46    pub(crate) fn sub(self, o: Cpx) -> Cpx {
47        Cpx::new(self.re - o.re, self.im - o.im)
48    }
49    pub(crate) fn mul(self, o: Cpx) -> Cpx {
50        Cpx::new(self.re * o.re - self.im * o.im, self.re * o.im + self.im * o.re)
51    }
52    pub(crate) fn div(self, o: Cpx) -> Cpx {
53        let denom = o.re * o.re + o.im * o.im;
54        Cpx::new(
55            (self.re * o.re + self.im * o.im) / denom,
56            (self.im * o.re - self.re * o.im) / denom,
57        )
58    }
59    pub(crate) fn scale(self, x: f64) -> Cpx {
60        Cpx::new(self.re * x, self.im * x)
61    }
62    pub(crate) fn exp(self) -> Cpx {
63        let m = self.re.exp();
64        Cpx::new(m * self.im.cos(), m * self.im.sin())
65    }
66    pub(crate) fn ln(self) -> Cpx {
67        Cpx::new(self.norm().ln(), self.im.atan2(self.re))
68    }
69    pub(crate) fn sqrt(self) -> Cpx {
70        let m = self.norm().sqrt();
71        let half_arg = 0.5 * self.im.atan2(self.re);
72        Cpx::new(m * half_arg.cos(), m * half_arg.sin())
73    }
74    pub(crate) fn norm(self) -> f64 {
75        self.re.hypot(self.im)
76    }
77}
78
79// ── Model parameters ────────────────────────────────────────────────────
80
81/// Heston parameters. `theta` is the long-run *variance*, `v0` the initial
82/// variance, `vol_of_vol` the volatility of variance (often written xi or
83/// sigma), `rho` the spot-variance correlation.
84#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq)]
85pub struct HestonParams {
86    pub v0: f64,
87    pub kappa: f64,
88    pub theta: f64,
89    #[serde(alias = "sigma", alias = "xi")]
90    pub vol_of_vol: f64,
91    pub rho: f64,
92}
93
94impl HestonParams {
95    pub fn validate(&self) -> Result<(), RustyQLibError> {
96        if self.v0 <= 0.0 || self.theta <= 0.0 || self.kappa <= 0.0 || self.vol_of_vol <= 0.0 {
97            return Err(RustyQLibError::invalid_input("heston params", "Heston v0, kappa, theta, vol_of_vol must be positive".to_string()));
98        }
99        if !(-1.0..=1.0).contains(&self.rho) {
100            return Err(RustyQLibError::invalid_input("heston params", "Heston rho must be in [-1, 1]".to_string()));
101        }
102        Ok(())
103    }
104
105    /// Whether the Feller condition `2 kappa theta >= vol_of_vol^2` holds
106    /// (if not, the variance process can touch zero; pricing still works).
107    pub fn feller_condition_holds(&self) -> bool {
108        2.0 * self.kappa * self.theta >= self.vol_of_vol * self.vol_of_vol
109    }
110
111    /// Parameters with a parallel shift applied to the instantaneous and
112    /// long-run vol (used for vega bump-and-reprice).
113    pub fn with_vol_shift(&self, shift: f64) -> HestonParams {
114        let bump = |var: f64| {
115            let vol = (var.sqrt() + shift).max(1e-6);
116            vol * vol
117        };
118        HestonParams { v0: bump(self.v0), theta: bump(self.theta), ..*self }
119    }
120
121    /// Map to the unconstrained calibration space: `ln` for the positive
122    /// parameters and `atanh` for the correlation, so any point the
123    /// optimizer visits maps back to a valid parameter set.
124    pub(crate) fn to_unconstrained(&self) -> Vec<f64> {
125        vec![
126            self.v0.ln(),
127            self.kappa.ln(),
128            self.theta.ln(),
129            self.vol_of_vol.ln(),
130            // atanh, clamped away from the +-1 poles
131            self.rho.clamp(-0.999, 0.999).atanh(),
132        ]
133    }
134
135    pub(crate) fn from_unconstrained(u: &[f64]) -> HestonParams {
136        HestonParams {
137            v0: u[0].exp(),
138            kappa: u[1].exp(),
139            theta: u[2].exp(),
140            vol_of_vol: u[3].exp(),
141            rho: u[4].tanh(),
142        }
143    }
144}
145
146// ── Calibration ─────────────────────────────────────────────────────────
147
148/// One vanilla market quote for calibration.
149#[derive(Debug, Clone, Copy)]
150pub struct HestonQuote {
151    pub strike: f64,
152    /// Year fraction to expiry.
153    pub maturity: f64,
154    /// Market price of the option.
155    pub price: f64,
156    pub put_or_call: PutOrCall,
157}
158
159/// Calibration outcome: fitted parameters plus fit diagnostics.
160#[derive(Debug, Clone)]
161pub struct HestonFit {
162    pub params: HestonParams,
163    /// Root-mean-square price error over the quotes.
164    pub rmse: f64,
165    pub iterations: usize,
166    pub converged: bool,
167}
168
169/// Calibrate Heston parameters to European vanilla quotes.
170///
171/// A Levenberg-Marquardt least-squares fit (the calibration workhorse in
172/// [`core::optimization`](crate::core::optimization)) on price residuals,
173/// run in an unconstrained transform space (`ln` for the positive
174/// parameters, `atanh` for rho) so every trial parameter set is valid —
175/// the same pattern any parametric fit (SABR, Nelson-Siegel) should use.
176/// `start` seeds the search; a poor start on a multimodal quote set can
177/// be globalized first with
178/// [`Method::DifferentialEvolution`](crate::core::optimization::Method).
179pub fn calibrate(
180    s: f64,
181    r: f64,
182    q: f64,
183    quotes: &[HestonQuote],
184    start: &HestonParams,
185) -> HestonFit {
186    use crate::core::optimization::{levenberg_marquardt, OptimConfig};
187
188    assert!(!quotes.is_empty(), "calibration needs at least one quote");
189    start.validate().expect("invalid starting parameters");
190    // COS pricing amortizes the CF sweep across every strike of an
191    // expiry, which is where the LM objective spends its life
192    let groups = crate::equity::cos::group_by_maturity(quotes.iter().map(|q| q.maturity));
193    let residuals = |u: &[f64]| -> Vec<f64> {
194        let p = HestonParams::from_unconstrained(u);
195        let mut out = vec![0.0; quotes.len()];
196        for (t, idxs) in &groups {
197            let pricer = crate::equity::cos::CosPricer::new(
198                &|uu| characteristic_fn(uu, s, r, q, *t, &p),
199                r,
200                *t,
201                crate::equity::cos::CALIBRATION_TERMS,
202            );
203            for &i in idxs {
204                out[i] =
205                    pricer.price(quotes[i].strike, quotes[i].put_or_call) - quotes[i].price;
206            }
207        }
208        out
209    };
210    let fit = levenberg_marquardt(
211        &OptimConfig::new(1e-12, 100),
212        &residuals,
213        None,
214        &start.to_unconstrained(),
215    );
216    HestonFit {
217        params: HestonParams::from_unconstrained(&fit.x),
218        rmse: (fit.value / quotes.len() as f64).sqrt(),
219        iterations: fit.iterations,
220        converged: fit.converged,
221    }
222}
223
224// ── Characteristic function and pricing ─────────────────────────────────
225
226/// Characteristic function of ln(S_T) in the trap-free formulation.
227pub(crate) fn characteristic_fn(u: Cpx, s: f64, r: f64, q: f64, t: f64, hp: &HestonParams) -> Cpx {
228    let kappa = Cpx::real(hp.kappa);
229    let eps = hp.vol_of_vol;
230    let eps2 = eps * eps;
231    let iu = I.mul(u);
232    let rho_eps_iu = iu.scale(hp.rho * eps);
233
234    // d = sqrt((rho*eps*iu - kappa)^2 + eps^2 (iu + u^2))
235    let a = rho_eps_iu.sub(kappa);
236    let d = a.mul(a).add(iu.add(u.mul(u)).scale(eps2)).sqrt();
237    // g2 = (kappa - rho*eps*iu - d) / (kappa - rho*eps*iu + d)  (trap-free)
238    let kmr = kappa.sub(rho_eps_iu);
239    let g2 = kmr.sub(d).div(kmr.add(d));
240
241    let exp_mdt = d.scale(-t).exp();
242    let one = Cpx::real(1.0);
243    // A = iu (ln S + (r-q) T)
244    let a_term = iu.scale(s.ln() + (r - q) * t);
245    // B = theta*kappa/eps^2 * ((kappa - rho eps iu - d) T - 2 ln((1 - g2 e^{-dT})/(1 - g2)))
246    let log_term = one.sub(g2.mul(exp_mdt)).div(one.sub(g2)).ln();
247    let b_term = kmr
248        .sub(d)
249        .scale(t)
250        .sub(log_term.scale(2.0))
251        .scale(hp.theta * hp.kappa / eps2);
252    // C = v0/eps^2 * (kappa - rho eps iu - d) (1 - e^{-dT}) / (1 - g2 e^{-dT})
253    let c_term = kmr
254        .sub(d)
255        .mul(one.sub(exp_mdt))
256        .div(one.sub(g2.mul(exp_mdt)))
257        .scale(hp.v0 / eps2);
258
259    a_term.add(b_term).add(c_term).exp()
260}
261
262/// The two Heston probabilities: P2 = P(S_T > K) under the risk-neutral
263/// measure, P1 the same under the spot measure.
264fn probabilities(s: f64, k: f64, r: f64, q: f64, t: f64, hp: &HestonParams) -> (f64, f64) {
265    let forward = s * ((r - q) * t).exp();
266    probabilities_with_cf(&|u| characteristic_fn(u, s, r, q, t, hp), forward, k)
267}
268
269/// P1/P2 from any log-price characteristic function whose martingale
270/// property gives `phi(-i) = forward` — shared by Heston and the Bates
271/// jump-diffusion extensions.
272pub(crate) fn probabilities_with_cf(
273    cf: &dyn Fn(Cpx) -> Cpx,
274    forward: f64,
275    k: f64,
276) -> (f64, f64) {
277    let ln_k = k.ln();
278    // integrands: Re[ e^{-iu lnK} phi_j(u) / (iu) ]
279    let integrand = |u: f64, shifted: bool| -> f64 {
280        let uc = Cpx::real(u);
281        let phi = if shifted {
282            // phi1(u) = phi(u - i) / phi(-i), phi(-i) = forward
283            cf(uc.sub(I)).scale(1.0 / forward)
284        } else {
285            cf(uc)
286        };
287        let num = I.scale(-u * ln_k).exp().mul(phi);
288        num.div(I.scale(u)).re
289    };
290    // integrate [0, 250] and extend block-wise while the tail still
291    // contributes: short-dated / high vol-of-vol CFs decay slowly and a
292    // fixed truncation silently loses ~1e-4 of probability mass
293    let p = |shifted: bool| {
294        let mut total = simpson(|u| integrand(u, shifted), 1e-9, 250.0, 4000);
295        let mut lo = 250.0;
296        while lo < 16_000.0 {
297            let hi = lo * 2.0;
298            let block = simpson(|u| integrand(u, shifted), lo, hi, (64.0 * (hi - lo)) as usize);
299            total += block;
300            if block.abs() < 1e-12 {
301                break;
302            }
303            lo = hi;
304        }
305        0.5 + total / std::f64::consts::PI
306    };
307    (p(true), p(false))
308}
309
310pub(crate) fn simpson<F: Fn(f64) -> f64>(f: F, a: f64, b: f64, n: usize) -> f64 {
311    let n = if n % 2 == 0 { n } else { n + 1 };
312    let h = (b - a) / n as f64;
313    let mut sum = f(a) + f(b);
314    for i in 1..n {
315        let w = if i % 2 == 1 { 4.0 } else { 2.0 };
316        sum += w * f(a + i as f64 * h);
317    }
318    sum * h / 3.0
319}
320
321/// Price a whole strike strip in one COS pass: the characteristic
322/// function is swept once for the expiry and every strike reuses it, so
323/// a 20-strike smile costs about the same as one option. Agrees with
324/// [`heston_price`] (the independent P1/P2 integration oracle) to ~1e-6.
325pub fn cos_smile(
326    s: f64,
327    r: f64,
328    q: f64,
329    t: f64,
330    hp: &HestonParams,
331    strikes: &[f64],
332    put_or_call: crate::core::trade::PutOrCall,
333) -> Vec<f64> {
334    let pricer = crate::equity::cos::CosPricer::new(
335        &|u| characteristic_fn(u, s, r, q, t, hp),
336        r,
337        t,
338        crate::equity::cos::DEFAULT_TERMS,
339    );
340    strikes.iter().map(|&k| pricer.price(k, put_or_call)).collect()
341}
342
343/// Semi-analytic Heston price of a European vanilla option.
344#[allow(clippy::too_many_arguments)]
345pub fn heston_price(
346    s: f64,
347    k: f64,
348    r: f64,
349    q: f64,
350    t: f64,
351    hp: &HestonParams,
352    put_or_call: PutOrCall,
353) -> f64 {
354    assert!(s > 0.0 && k > 0.0 && t > 0.0);
355    hp.validate().expect("invalid Heston parameters");
356    let (p1, p2) = probabilities(s, k, r, q, t, hp);
357    let call = s * (-q * t).exp() * p1 - k * (-r * t).exp() * p2;
358    match put_or_call {
359        PutOrCall::Call => call,
360        // put-call parity
361        PutOrCall::Put => call - s * (-q * t).exp() + k * (-r * t).exp(),
362    }
363}
364
365/// Semi-analytic Heston price of a cash-or-nothing binary
366/// (`cash * e^{-rT} * P(S_T beyond K)`).
367#[allow(clippy::too_many_arguments)]
368pub fn heston_binary_cash_price(
369    s: f64,
370    k: f64,
371    r: f64,
372    q: f64,
373    t: f64,
374    hp: &HestonParams,
375    cash: f64,
376    put_or_call: PutOrCall,
377) -> f64 {
378    let (_, p2) = probabilities(s, k, r, q, t, hp);
379    let df = (-r * t).exp();
380    match put_or_call {
381        PutOrCall::Call => cash * df * p2,
382        PutOrCall::Put => cash * df * (1.0 - p2),
383    }
384}
385
386/// Semi-analytic Heston price of an asset-or-nothing binary
387/// (`S e^{-qT} P1` for a call).
388pub fn heston_binary_asset_price(
389    s: f64,
390    k: f64,
391    r: f64,
392    q: f64,
393    t: f64,
394    hp: &HestonParams,
395    put_or_call: PutOrCall,
396) -> f64 {
397    let (p1, _) = probabilities(s, k, r, q, t, hp);
398    let leg = s * (-q * t).exp();
399    match put_or_call {
400        PutOrCall::Call => leg * p1,
401        PutOrCall::Put => leg * (1.0 - p1),
402    }
403}
404
405// ── Option-level analytic pricing and bump Greeks ───────────────────────
406
407use crate::equity::utils::PayoffType;
408use crate::equity::vanilla_option::{BinaryPayoff, BinaryType, EquityOption};
409use crate::core::errors::RustyQLibError;
410
411/// Reprice the option under Heston with additive bumps to
412/// (spot, vol shift, rate, expiry). The vol bump shifts sqrt(v0) and
413/// sqrt(theta) in parallel.
414pub(crate) fn price_with(option: &EquityOption, ds: f64, dvol: f64, dr: f64, dt_shift: f64) -> f64 {
415    let hp = option.heston_params().with_vol_shift(dvol);
416    let s = option.effective_spot() + ds;
417    let k = option.base.strike_price;
418    let r = option.risk_free_rate() + dr;
419    let q = option.carry_yield();
420    let t = option.time_to_maturity() + dt_shift;
421    let pc = *option.payoff.put_or_call();
422    match option.payoff.payoff_kind() {
423        PayoffType::Vanilla => heston_price(s, k, r, q, t, &hp, pc),
424        PayoffType::Binary => {
425            let payoff = option
426                .payoff
427                .as_any()
428                .downcast_ref::<BinaryPayoff>()
429                .expect("payoff of kind Binary must be a BinaryPayoff");
430            match payoff.binary_type {
431                BinaryType::CashOrNothing => {
432                    heston_binary_cash_price(s, k, r, q, t, &hp, payoff.cash, pc)
433                }
434                BinaryType::AssetOrNothing => heston_binary_asset_price(s, k, r, q, t, &hp, pc),
435            }
436        }
437        _ => panic!(
438            "The Heston analytic pricer supports vanilla and binary payoffs; \
439             use the MonteCarlo engine for path-dependent payoffs"
440        ),
441    }
442}
443
444pub fn analytic_npv(option: &EquityOption) -> f64 {
445    price_with(option, 0.0, 0.0, 0.0, 0.0)
446}
447// Greeks: central-difference bumps of [`price_with`] (a vol bump shifts
448// sqrt(v0) and sqrt(theta) in parallel), produced by the central
449// sensitivity engine (`crate::equity::greeks`) — except vanilla delta,
450// which falls out of the price integration for free below.
451
452/// Vanilla delta directly from the characteristic-function integration:
453/// the call price is `S e^{-qT} P1 - K e^{-rT} P2` and the homogeneity
454/// identity cancels the `dP/dS` terms, so delta is `e^{-qT} P1` for calls
455/// and `e^{-qT} (P1 - 1)` for puts — the probability the pricer already
456/// integrates, no bump and no extra integration. `None` for non-vanilla
457/// payoffs (binaries keep the central bump stencils).
458pub(crate) fn native_vanilla_delta(option: &EquityOption) -> Option<f64> {
459    if option.payoff.payoff_kind() != PayoffType::Vanilla {
460        return None;
461    }
462    let hp = option.heston_params();
463    let s = option.effective_spot();
464    let k = option.base.strike_price;
465    let r = option.risk_free_rate();
466    let q = option.carry_yield();
467    let t = option.time_to_maturity();
468    let (p1, _) = probabilities(s, k, r, q, t, hp);
469    let dfq = (-q * t).exp();
470    Some(match option.payoff.put_or_call() {
471        PutOrCall::Call => dfq * p1,
472        PutOrCall::Put => dfq * (p1 - 1.0),
473    })
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::equity::blackscholes::bs_price;
480
481    fn params() -> HestonParams {
482        HestonParams { v0: 0.09, kappa: 2.0, theta: 0.09, vol_of_vol: 0.4, rho: -0.7 }
483    }
484
485    #[test]
486    fn calibration_recovers_the_generating_parameters() {
487        // price a strike ladder from known parameters, perturb the start,
488        // and calibrate back: the fit must reprice the quotes to sub-cent
489        // accuracy and land near the generating v0 / vol_of_vol / rho
490        let truth = HestonParams { v0: 0.04, kappa: 1.5, theta: 0.05, vol_of_vol: 0.5, rho: -0.7 };
491        let (s, r, q, t) = (100.0, 0.03, 0.01, 1.0);
492        let quotes: Vec<HestonQuote> = [80.0, 90.0, 100.0, 110.0, 120.0]
493            .iter()
494            .map(|&k| HestonQuote {
495                strike: k,
496                maturity: t,
497                price: heston_price(s, k, r, q, t, &truth, PutOrCall::Call),
498                put_or_call: PutOrCall::Call,
499            })
500            .collect();
501
502        let start = HestonParams { v0: 0.06, kappa: 1.0, theta: 0.04, vol_of_vol: 0.3, rho: -0.3 };
503        let fit = calibrate(s, r, q, &quotes, &start);
504
505        assert!(fit.rmse < 1e-3, "price rmse {} too large: {:?}", fit.rmse, fit.params);
506        // the smile pins v0 (level), vol_of_vol (curvature) and rho (skew)
507        assert!((fit.params.v0 - truth.v0).abs() < 0.01, "v0 {}", fit.params.v0);
508        assert!((fit.params.rho - truth.rho).abs() < 0.1, "rho {}", fit.params.rho);
509        assert!(fit.params.validate().is_ok());
510    }
511
512    #[test]
513    fn complex_arithmetic_sanity() {
514        let z = Cpx::new(3.0, 4.0);
515        assert!((z.norm() - 5.0).abs() < 1e-14);
516        let e = Cpx::new(0.0, std::f64::consts::PI).exp();
517        assert!((e.re + 1.0).abs() < 1e-12 && e.im.abs() < 1e-12, "e^{{i pi}} = -1");
518        let s = Cpx::new(-1.0, 0.0).sqrt();
519        assert!(s.re.abs() < 1e-12 && (s.im - 1.0).abs() < 1e-12, "sqrt(-1) = i");
520        let l = z.ln().exp();
521        assert!((l.re - z.re).abs() < 1e-12 && (l.im - z.im).abs() < 1e-12);
522    }
523
524    #[test]
525    fn degenerates_to_black_scholes_when_vol_of_vol_vanishes() {
526        // v0 = theta and vol_of_vol -> 0: variance is constant, so the
527        // price must match Black-Scholes at sigma = sqrt(v0)
528        let hp = HestonParams { v0: 0.09, kappa: 1.0, theta: 0.09, vol_of_vol: 1e-4, rho: 0.0 };
529        for k in [80.0, 100.0, 120.0] {
530            let heston = heston_price(100.0, k, 0.05, 0.02, 1.0, &hp, PutOrCall::Call);
531            let bs = bs_price(100.0, k, 0.05, 0.02, 0.3, 1.0, PutOrCall::Call);
532            assert!((heston - bs).abs() < 1e-4, "K={k}: heston {heston} vs bs {bs}");
533        }
534    }
535
536    #[test]
537    fn put_call_parity() {
538        let hp = params();
539        let (s, k, r, q, t) = (100.0, 95.0, 0.05, 0.02, 1.0);
540        let c = heston_price(s, k, r, q, t, &hp, PutOrCall::Call);
541        let p = heston_price(s, k, r, q, t, &hp, PutOrCall::Put);
542        let parity = s * (-q * t as f64).exp() - k * (-r * t as f64).exp();
543        assert!((c - p - parity).abs() < 1e-10);
544    }
545
546    #[test]
547    fn probabilities_are_probabilities() {
548        let hp = params();
549        for k in [50.0, 100.0, 200.0] {
550            let (p1, p2) = probabilities(100.0, k, 0.05, 0.0, 1.0, &hp);
551            assert!((0.0..=1.0).contains(&p1), "P1 {p1} at K={k}");
552            assert!((0.0..=1.0).contains(&p2), "P2 {p2} at K={k}");
553        }
554        // deep ITM call: both probabilities near 1; deep OTM: near 0
555        let (p1, p2) = probabilities(100.0, 1.0, 0.05, 0.0, 1.0, &hp);
556        assert!(p1 > 0.999 && p2 > 0.999);
557        let (p1, p2) = probabilities(100.0, 10_000.0, 0.05, 0.0, 1.0, &hp);
558        assert!(p1 < 1e-3 && p2 < 1e-3);
559    }
560
561    #[test]
562    fn binaries_replicate_vanilla() {
563        // vanilla call = asset-or-nothing - K * cash-or-nothing, under any
564        // model with these probabilities
565        let hp = params();
566        let (s, k, r, q, t) = (100.0, 100.0, 0.05, 0.02, 1.0);
567        let vanilla = heston_price(s, k, r, q, t, &hp, PutOrCall::Call);
568        let asset = heston_binary_asset_price(s, k, r, q, t, &hp, PutOrCall::Call);
569        let cash = heston_binary_cash_price(s, k, r, q, t, &hp, k, PutOrCall::Call);
570        assert!((vanilla - (asset - cash)).abs() < 1e-10);
571    }
572
573    #[test]
574    fn negative_correlation_creates_skew() {
575        // rho < 0 fattens the left tail: OTM puts gain value relative to
576        // the symmetric case
577        let hp_neg = params();
578        let hp_zero = HestonParams { rho: 0.0, ..params() };
579        let otm_put_neg = heston_price(100.0, 80.0, 0.05, 0.0, 1.0, &hp_neg, PutOrCall::Put);
580        let otm_put_zero = heston_price(100.0, 80.0, 0.05, 0.0, 1.0, &hp_zero, PutOrCall::Put);
581        assert!(otm_put_neg > otm_put_zero);
582    }
583
584    #[test]
585    fn validation_rejects_bad_params() {
586        assert!(HestonParams { v0: -0.1, ..params() }.validate().is_err());
587        assert!(HestonParams { rho: -1.5, ..params() }.validate().is_err());
588        assert!(params().validate().is_ok());
589        assert!(params().feller_condition_holds()); // 2*2*0.09 = 0.36 >= 0.4^2
590        assert!(!HestonParams { vol_of_vol: 0.9, ..params() }.feller_condition_holds());
591    }
592}