1use serde::{Deserialize, Serialize};
23
24use crate::core::trade::PutOrCall;
25
26#[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#[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 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 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 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 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#[derive(Debug, Clone, Copy)]
150pub struct HestonQuote {
151 pub strike: f64,
152 pub maturity: f64,
154 pub price: f64,
156 pub put_or_call: PutOrCall,
157}
158
159#[derive(Debug, Clone)]
161pub struct HestonFit {
162 pub params: HestonParams,
163 pub rmse: f64,
165 pub iterations: usize,
166 pub converged: bool,
167}
168
169pub 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 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
224pub(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 let a = rho_eps_iu.sub(kappa);
236 let d = a.mul(a).add(iu.add(u.mul(u)).scale(eps2)).sqrt();
237 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 let a_term = iu.scale(s.ln() + (r - q) * t);
245 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 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
262fn 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
269pub(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 let integrand = |u: f64, shifted: bool| -> f64 {
280 let uc = Cpx::real(u);
281 let phi = if shifted {
282 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 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
321pub 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#[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 PutOrCall::Put => call - s * (-q * t).exp() + k * (-r * t).exp(),
362 }
363}
364
365#[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
386pub 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
405use crate::equity::utils::PayoffType;
408use crate::equity::vanilla_option::{BinaryPayoff, BinaryType, EquityOption};
409use crate::core::errors::RustyQLibError;
410
411pub(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}
447pub(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 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, "es, &start);
504
505 assert!(fit.rmse < 1e-3, "price rmse {} too large: {:?}", fit.rmse, fit.params);
506 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 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 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 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 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()); assert!(!HestonParams { vol_of_vol: 0.9, ..params() }.feller_condition_holds());
591 }
592}