1use serde::{Deserialize, Serialize};
21
22use crate::core::trade::PutOrCall;
23
24#[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#[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 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 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
120fn 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 let a = rho_eps_iu.sub(kappa);
132 let d = a.mul(a).add(iu.add(u.mul(u)).scale(eps2)).sqrt();
133 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 let a_term = iu.scale(s.ln() + (r - q) * t);
141 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 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
158fn 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 let integrand = |u: f64, shifted: bool| -> f64 {
165 let uc = Cpx::real(u);
166 let phi = if shifted {
167 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#[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 PutOrCall::Put => call - s * (-q * t).exp() + k * (-r * t).exp(),
209 }
210}
211
212#[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
233pub 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
252use crate::equity::utils::PayoffType;
255use crate::equity::vanila_option::{BinaryPayoff, BinaryType, EquityOption};
256
257fn 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}
306pub 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 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 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 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 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()); assert!(!HestonParams { vol_of_vol: 0.9, ..params() }.feller_condition_holds());
408 }
409}