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 (full-truncation
18//! Euler; the Andersen QE scheme is the planned upgrade).
19
20use serde::{Deserialize, Serialize};
21
22use crate::core::trade::PutOrCall;
23
24// ── Minimal complex arithmetic (principal branches) ─────────────────────
25
26#[derive(Debug, Clone, Copy, PartialEq)]
27struct Cpx {
28    re: f64,
29    im: f64,
30}
31
32const I: Cpx = Cpx { re: 0.0, im: 1.0 };
33
34impl Cpx {
35    fn new(re: f64, im: f64) -> Self {
36        Cpx { re, im }
37    }
38    fn real(re: f64) -> Self {
39        Cpx { re, im: 0.0 }
40    }
41    fn add(self, o: Cpx) -> Cpx {
42        Cpx::new(self.re + o.re, self.im + o.im)
43    }
44    fn sub(self, o: Cpx) -> Cpx {
45        Cpx::new(self.re - o.re, self.im - o.im)
46    }
47    fn mul(self, o: Cpx) -> Cpx {
48        Cpx::new(self.re * o.re - self.im * o.im, self.re * o.im + self.im * o.re)
49    }
50    fn div(self, o: Cpx) -> Cpx {
51        let denom = o.re * o.re + o.im * o.im;
52        Cpx::new(
53            (self.re * o.re + self.im * o.im) / denom,
54            (self.im * o.re - self.re * o.im) / denom,
55        )
56    }
57    fn scale(self, x: f64) -> Cpx {
58        Cpx::new(self.re * x, self.im * x)
59    }
60    fn exp(self) -> Cpx {
61        let m = self.re.exp();
62        Cpx::new(m * self.im.cos(), m * self.im.sin())
63    }
64    fn ln(self) -> Cpx {
65        Cpx::new(self.norm().ln(), self.im.atan2(self.re))
66    }
67    fn sqrt(self) -> Cpx {
68        let m = self.norm().sqrt();
69        let half_arg = 0.5 * self.im.atan2(self.re);
70        Cpx::new(m * half_arg.cos(), m * half_arg.sin())
71    }
72    fn norm(self) -> f64 {
73        self.re.hypot(self.im)
74    }
75}
76
77// ── Model parameters ────────────────────────────────────────────────────
78
79/// Heston parameters. `theta` is the long-run *variance*, `v0` the initial
80/// variance, `vol_of_vol` the volatility of variance (often written xi or
81/// sigma), `rho` the spot-variance correlation.
82#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
83pub struct HestonParams {
84    pub v0: f64,
85    pub kappa: f64,
86    pub theta: f64,
87    #[serde(alias = "sigma", alias = "xi")]
88    pub vol_of_vol: f64,
89    pub rho: f64,
90}
91
92impl HestonParams {
93    pub fn validate(&self) -> Result<(), String> {
94        if self.v0 <= 0.0 || self.theta <= 0.0 || self.kappa <= 0.0 || self.vol_of_vol <= 0.0 {
95            return Err("Heston v0, kappa, theta, vol_of_vol must be positive".to_string());
96        }
97        if !(-1.0..=1.0).contains(&self.rho) {
98            return Err("Heston rho must be in [-1, 1]".to_string());
99        }
100        Ok(())
101    }
102
103    /// Whether the Feller condition `2 kappa theta >= vol_of_vol^2` holds
104    /// (if not, the variance process can touch zero; pricing still works).
105    pub fn feller_condition_holds(&self) -> bool {
106        2.0 * self.kappa * self.theta >= self.vol_of_vol * self.vol_of_vol
107    }
108
109    /// Parameters with a parallel shift applied to the instantaneous and
110    /// long-run vol (used for vega bump-and-reprice).
111    pub fn with_vol_shift(&self, shift: f64) -> HestonParams {
112        let bump = |var: f64| {
113            let vol = (var.sqrt() + shift).max(1e-6);
114            vol * vol
115        };
116        HestonParams { v0: bump(self.v0), theta: bump(self.theta), ..*self }
117    }
118}
119
120// ── Characteristic function and pricing ─────────────────────────────────
121
122/// Characteristic function of ln(S_T) in the trap-free formulation.
123fn characteristic_fn(u: Cpx, s: f64, r: f64, q: f64, t: f64, hp: &HestonParams) -> Cpx {
124    let kappa = Cpx::real(hp.kappa);
125    let eps = hp.vol_of_vol;
126    let eps2 = eps * eps;
127    let iu = I.mul(u);
128    let rho_eps_iu = iu.scale(hp.rho * eps);
129
130    // d = sqrt((rho*eps*iu - kappa)^2 + eps^2 (iu + u^2))
131    let a = rho_eps_iu.sub(kappa);
132    let d = a.mul(a).add(iu.add(u.mul(u)).scale(eps2)).sqrt();
133    // g2 = (kappa - rho*eps*iu - d) / (kappa - rho*eps*iu + d)  (trap-free)
134    let kmr = kappa.sub(rho_eps_iu);
135    let g2 = kmr.sub(d).div(kmr.add(d));
136
137    let exp_mdt = d.scale(-t).exp();
138    let one = Cpx::real(1.0);
139    // A = iu (ln S + (r-q) T)
140    let a_term = iu.scale(s.ln() + (r - q) * t);
141    // B = theta*kappa/eps^2 * ((kappa - rho eps iu - d) T - 2 ln((1 - g2 e^{-dT})/(1 - g2)))
142    let log_term = one.sub(g2.mul(exp_mdt)).div(one.sub(g2)).ln();
143    let b_term = kmr
144        .sub(d)
145        .scale(t)
146        .sub(log_term.scale(2.0))
147        .scale(hp.theta * hp.kappa / eps2);
148    // C = v0/eps^2 * (kappa - rho eps iu - d) (1 - e^{-dT}) / (1 - g2 e^{-dT})
149    let c_term = kmr
150        .sub(d)
151        .mul(one.sub(exp_mdt))
152        .div(one.sub(g2.mul(exp_mdt)))
153        .scale(hp.v0 / eps2);
154
155    a_term.add(b_term).add(c_term).exp()
156}
157
158/// The two Heston probabilities: P2 = P(S_T > K) under the risk-neutral
159/// measure, P1 the same under the spot measure.
160fn probabilities(s: f64, k: f64, r: f64, q: f64, t: f64, hp: &HestonParams) -> (f64, f64) {
161    let ln_k = k.ln();
162    let forward = s * ((r - q) * t).exp();
163    // integrands: Re[ e^{-iu lnK} phi_j(u) / (iu) ]
164    let integrand = |u: f64, shifted: bool| -> f64 {
165        let uc = Cpx::real(u);
166        let phi = if shifted {
167            // phi1(u) = phi(u - i) / phi(-i), phi(-i) = forward
168            characteristic_fn(uc.sub(I), s, r, q, t, hp).scale(1.0 / forward)
169        } else {
170            characteristic_fn(uc, s, r, q, t, hp)
171        };
172        let num = I.scale(-u * ln_k).exp().mul(phi);
173        num.div(I.scale(u)).re
174    };
175    let p = |shifted: bool| 0.5 + simpson(|u| integrand(u, shifted), 1e-9, 250.0, 4000) / std::f64::consts::PI;
176    (p(true), p(false))
177}
178
179fn simpson<F: Fn(f64) -> f64>(f: F, a: f64, b: f64, n: usize) -> f64 {
180    let n = if n % 2 == 0 { n } else { n + 1 };
181    let h = (b - a) / n as f64;
182    let mut sum = f(a) + f(b);
183    for i in 1..n {
184        let w = if i % 2 == 1 { 4.0 } else { 2.0 };
185        sum += w * f(a + i as f64 * h);
186    }
187    sum * h / 3.0
188}
189
190/// Semi-analytic Heston price of a European vanilla option.
191#[allow(clippy::too_many_arguments)]
192pub fn heston_price(
193    s: f64,
194    k: f64,
195    r: f64,
196    q: f64,
197    t: f64,
198    hp: &HestonParams,
199    put_or_call: PutOrCall,
200) -> f64 {
201    assert!(s > 0.0 && k > 0.0 && t > 0.0);
202    hp.validate().expect("invalid Heston parameters");
203    let (p1, p2) = probabilities(s, k, r, q, t, hp);
204    let call = s * (-q * t).exp() * p1 - k * (-r * t).exp() * p2;
205    match put_or_call {
206        PutOrCall::Call => call,
207        // put-call parity
208        PutOrCall::Put => call - s * (-q * t).exp() + k * (-r * t).exp(),
209    }
210}
211
212/// Semi-analytic Heston price of a cash-or-nothing binary
213/// (`cash * e^{-rT} * P(S_T beyond K)`).
214#[allow(clippy::too_many_arguments)]
215pub fn heston_binary_cash_price(
216    s: f64,
217    k: f64,
218    r: f64,
219    q: f64,
220    t: f64,
221    hp: &HestonParams,
222    cash: f64,
223    put_or_call: PutOrCall,
224) -> f64 {
225    let (_, p2) = probabilities(s, k, r, q, t, hp);
226    let df = (-r * t).exp();
227    match put_or_call {
228        PutOrCall::Call => cash * df * p2,
229        PutOrCall::Put => cash * df * (1.0 - p2),
230    }
231}
232
233/// Semi-analytic Heston price of an asset-or-nothing binary
234/// (`S e^{-qT} P1` for a call).
235pub fn heston_binary_asset_price(
236    s: f64,
237    k: f64,
238    r: f64,
239    q: f64,
240    t: f64,
241    hp: &HestonParams,
242    put_or_call: PutOrCall,
243) -> f64 {
244    let (p1, _) = probabilities(s, k, r, q, t, hp);
245    let leg = s * (-q * t).exp();
246    match put_or_call {
247        PutOrCall::Call => leg * p1,
248        PutOrCall::Put => leg * (1.0 - p1),
249    }
250}
251
252// ── Option-level analytic pricing and bump Greeks ───────────────────────
253
254use crate::equity::utils::PayoffType;
255use crate::equity::vanila_option::{BinaryPayoff, BinaryType, EquityOption};
256
257/// Reprice the option under Heston with additive bumps to
258/// (spot, vol shift, rate, expiry). The vol bump shifts sqrt(v0) and
259/// sqrt(theta) in parallel.
260fn price_with(option: &EquityOption, ds: f64, dvol: f64, dr: f64, dt_shift: f64) -> f64 {
261    let hp = option
262        .heston
263        .expect("heston parameters are required for the Heston model")
264        .with_vol_shift(dvol);
265    let s = option.base.effective_spot() + ds;
266    let k = option.base.strike_price;
267    let r = option.base.risk_free_rate() + dr;
268    let q = option.base.carry_yield();
269    let t = option.time_to_maturity() + dt_shift;
270    let pc = *option.payoff.put_or_call();
271    match option.payoff.payoff_kind() {
272        PayoffType::Vanilla => heston_price(s, k, r, q, t, &hp, pc),
273        PayoffType::Binary => {
274            let payoff = option
275                .payoff
276                .as_any()
277                .downcast_ref::<BinaryPayoff>()
278                .expect("payoff of kind Binary must be a BinaryPayoff");
279            match payoff.binary_type {
280                BinaryType::CashOrNothing => {
281                    heston_binary_cash_price(s, k, r, q, t, &hp, payoff.cash, pc)
282                }
283                BinaryType::AssetOrNothing => heston_binary_asset_price(s, k, r, q, t, &hp, pc),
284            }
285        }
286        _ => panic!(
287            "The Heston analytic pricer supports vanilla and binary payoffs; \
288             use the MonteCarlo engine for path-dependent payoffs"
289        ),
290    }
291}
292
293pub fn analytic_npv(option: &EquityOption) -> f64 {
294    price_with(option, 0.0, 0.0, 0.0, 0.0)
295}
296pub fn analytic_delta(option: &EquityOption) -> f64 {
297    let h = option.base.underlying_price.value() * 1e-4;
298    (price_with(option, h, 0.0, 0.0, 0.0) - price_with(option, -h, 0.0, 0.0, 0.0)) / (2.0 * h)
299}
300pub fn analytic_gamma(option: &EquityOption) -> f64 {
301    let h = option.base.underlying_price.value() * 1e-3;
302    (price_with(option, h, 0.0, 0.0, 0.0) - 2.0 * price_with(option, 0.0, 0.0, 0.0, 0.0)
303        + price_with(option, -h, 0.0, 0.0, 0.0))
304        / (h * h)
305}
306/// Sensitivity to a parallel shift of the instantaneous and long-run vol.
307pub fn analytic_vega(option: &EquityOption) -> f64 {
308    let h = 1e-4;
309    (price_with(option, 0.0, h, 0.0, 0.0) - price_with(option, 0.0, -h, 0.0, 0.0)) / (2.0 * h)
310}
311pub fn analytic_theta(option: &EquityOption) -> f64 {
312    let h = (1.0 / 365.0_f64).min(0.5 * option.time_to_maturity());
313    -(price_with(option, 0.0, 0.0, 0.0, h) - price_with(option, 0.0, 0.0, 0.0, -h)) / (2.0 * h)
314}
315pub fn analytic_rho(option: &EquityOption) -> f64 {
316    let h = 1e-5;
317    (price_with(option, 0.0, 0.0, h, 0.0) - price_with(option, 0.0, 0.0, -h, 0.0)) / (2.0 * h)
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::equity::blackscholes::bs_price;
324
325    fn params() -> HestonParams {
326        HestonParams { v0: 0.09, kappa: 2.0, theta: 0.09, vol_of_vol: 0.4, rho: -0.7 }
327    }
328
329    #[test]
330    fn complex_arithmetic_sanity() {
331        let z = Cpx::new(3.0, 4.0);
332        assert!((z.norm() - 5.0).abs() < 1e-14);
333        let e = Cpx::new(0.0, std::f64::consts::PI).exp();
334        assert!((e.re + 1.0).abs() < 1e-12 && e.im.abs() < 1e-12, "e^{{i pi}} = -1");
335        let s = Cpx::new(-1.0, 0.0).sqrt();
336        assert!(s.re.abs() < 1e-12 && (s.im - 1.0).abs() < 1e-12, "sqrt(-1) = i");
337        let l = z.ln().exp();
338        assert!((l.re - z.re).abs() < 1e-12 && (l.im - z.im).abs() < 1e-12);
339    }
340
341    #[test]
342    fn degenerates_to_black_scholes_when_vol_of_vol_vanishes() {
343        // v0 = theta and vol_of_vol -> 0: variance is constant, so the
344        // price must match Black-Scholes at sigma = sqrt(v0)
345        let hp = HestonParams { v0: 0.09, kappa: 1.0, theta: 0.09, vol_of_vol: 1e-4, rho: 0.0 };
346        for k in [80.0, 100.0, 120.0] {
347            let heston = heston_price(100.0, k, 0.05, 0.02, 1.0, &hp, PutOrCall::Call);
348            let bs = bs_price(100.0, k, 0.05, 0.02, 0.3, 1.0, PutOrCall::Call);
349            assert!((heston - bs).abs() < 1e-4, "K={k}: heston {heston} vs bs {bs}");
350        }
351    }
352
353    #[test]
354    fn put_call_parity() {
355        let hp = params();
356        let (s, k, r, q, t) = (100.0, 95.0, 0.05, 0.02, 1.0);
357        let c = heston_price(s, k, r, q, t, &hp, PutOrCall::Call);
358        let p = heston_price(s, k, r, q, t, &hp, PutOrCall::Put);
359        let parity = s * (-q * t as f64).exp() - k * (-r * t as f64).exp();
360        assert!((c - p - parity).abs() < 1e-10);
361    }
362
363    #[test]
364    fn probabilities_are_probabilities() {
365        let hp = params();
366        for k in [50.0, 100.0, 200.0] {
367            let (p1, p2) = probabilities(100.0, k, 0.05, 0.0, 1.0, &hp);
368            assert!((0.0..=1.0).contains(&p1), "P1 {p1} at K={k}");
369            assert!((0.0..=1.0).contains(&p2), "P2 {p2} at K={k}");
370        }
371        // deep ITM call: both probabilities near 1; deep OTM: near 0
372        let (p1, p2) = probabilities(100.0, 1.0, 0.05, 0.0, 1.0, &hp);
373        assert!(p1 > 0.999 && p2 > 0.999);
374        let (p1, p2) = probabilities(100.0, 10_000.0, 0.05, 0.0, 1.0, &hp);
375        assert!(p1 < 1e-3 && p2 < 1e-3);
376    }
377
378    #[test]
379    fn binaries_replicate_vanilla() {
380        // vanilla call = asset-or-nothing - K * cash-or-nothing, under any
381        // model with these probabilities
382        let hp = params();
383        let (s, k, r, q, t) = (100.0, 100.0, 0.05, 0.02, 1.0);
384        let vanilla = heston_price(s, k, r, q, t, &hp, PutOrCall::Call);
385        let asset = heston_binary_asset_price(s, k, r, q, t, &hp, PutOrCall::Call);
386        let cash = heston_binary_cash_price(s, k, r, q, t, &hp, k, PutOrCall::Call);
387        assert!((vanilla - (asset - cash)).abs() < 1e-10);
388    }
389
390    #[test]
391    fn negative_correlation_creates_skew() {
392        // rho < 0 fattens the left tail: OTM puts gain value relative to
393        // the symmetric case
394        let hp_neg = params();
395        let hp_zero = HestonParams { rho: 0.0, ..params() };
396        let otm_put_neg = heston_price(100.0, 80.0, 0.05, 0.0, 1.0, &hp_neg, PutOrCall::Put);
397        let otm_put_zero = heston_price(100.0, 80.0, 0.05, 0.0, 1.0, &hp_zero, PutOrCall::Put);
398        assert!(otm_put_neg > otm_put_zero);
399    }
400
401    #[test]
402    fn validation_rejects_bad_params() {
403        assert!(HestonParams { v0: -0.1, ..params() }.validate().is_err());
404        assert!(HestonParams { rho: -1.5, ..params() }.validate().is_err());
405        assert!(params().validate().is_ok());
406        assert!(params().feller_condition_holds()); // 2*2*0.09 = 0.36 >= 0.4^2
407        assert!(!HestonParams { vol_of_vol: 0.9, ..params() }.feller_condition_holds());
408    }
409}