1use libm::exp;
11
12use crate::core::montecarlo::process::{
13 numeric_diffusion_dx, DiscretizationScheme, StochasticProcess, StochasticProcess1D,
14};
15use super::heston::HestonParams;
16use super::local_vol::LocalVol;
17
18pub enum VolDynamics<'a> {
22 Const(f64),
23 Local(LocalVol<'a>),
24}
25
26pub struct BlackScholesProcess<'a> {
34 drift_rate: f64,
36 vol: VolDynamics<'a>,
37}
38
39impl<'a> BlackScholesProcess<'a> {
40 pub fn new(drift_rate: f64, vol: VolDynamics<'a>) -> Self {
41 BlackScholesProcess { drift_rate, vol }
42 }
43
44 pub fn vol(&self, s: f64, t: f64) -> f64 {
46 match &self.vol {
47 VolDynamics::Const(v) => *v,
48 VolDynamics::Local(lv) => lv.vol(s, t),
49 }
50 }
51
52 pub(crate) fn step_with_vol(
62 &self,
63 scheme: DiscretizationScheme,
64 t: f64,
65 s: f64,
66 dt: f64,
67 dw: f64,
68 sigma: f64,
69 ) -> f64 {
70 let mu = self.drift_rate;
71 let next = match scheme {
72 DiscretizationScheme::Exact => {
73 s * exp((mu - 0.5 * sigma * sigma) * dt + sigma * dw)
74 }
75 DiscretizationScheme::Euler => s * (1.0 + mu * dt + sigma * dw),
76 DiscretizationScheme::Milstein => {
77 let b_dx = self.diffusion_dx(t, s);
80 s * (1.0 + mu * dt + sigma * dw + 0.5 * sigma * b_dx * (dw * dw - dt))
81 }
82 };
83 next.max(0.0)
84 }
85}
86
87impl StochasticProcess1D for BlackScholesProcess<'_> {
88 fn drift(&self, _t: f64, x: f64) -> f64 {
89 self.drift_rate * x
90 }
91
92 fn diffusion(&self, t: f64, x: f64) -> f64 {
93 self.vol(x, t) * x
94 }
95
96 fn diffusion_dx(&self, t: f64, x: f64) -> f64 {
97 match &self.vol {
98 VolDynamics::Const(sigma) => *sigma,
99 VolDynamics::Local(_) => numeric_diffusion_dx(self, t, x),
100 }
101 }
102
103 fn exact_step(&self, t: f64, x: f64, dt: f64, dw: f64) -> Option<f64> {
104 let sigma = self.vol(x, t);
105 Some(x * exp((self.drift_rate - 0.5 * sigma * sigma) * dt + sigma * dw))
106 }
107
108 fn constrain(&self, x: f64) -> f64 {
110 x.max(0.0)
111 }
112
113 fn evolve(&self, scheme: DiscretizationScheme, t: f64, x: f64, dt: f64, dw: f64) -> f64 {
114 let sigma = self.vol(x, t);
115 self.step_with_vol(scheme, t, x, dt, dw, sigma)
116 }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum HestonScheme {
126 FullTruncation,
130 QuadraticExponential,
138}
139
140const QE_PSI_SWITCH: f64 = 1.5;
143
144enum QeBranch {
147 Quadratic { a: f64, b2: f64 },
149 Exponential { p: f64, beta: f64 },
151}
152
153fn qe_variance_draw(hp: &HestonParams, v: f64, dt: f64, z_v: f64) -> (f64, QeBranch) {
156 let (kappa, theta, xi) = (hp.kappa, hp.theta, hp.vol_of_vol);
157 let e = (-kappa * dt).exp();
159 let m = theta + (v - theta) * e;
160 let s2 = v * xi * xi * e * (1.0 - e) / kappa
161 + theta * xi * xi * (1.0 - e) * (1.0 - e) / (2.0 * kappa);
162 let psi = s2 / (m * m);
163
164 if psi <= QE_PSI_SWITCH {
165 let inv = 2.0 / psi;
167 let b2 = inv - 1.0 + inv.sqrt() * (inv - 1.0).sqrt();
168 let a = m / (1.0 + b2);
169 let bz = b2.sqrt() + z_v;
170 (a * bz * bz, QeBranch::Quadratic { a, b2 })
171 } else {
172 let p = (psi - 1.0) / (psi + 1.0);
175 let beta = (1.0 - p) / m;
176 let u = crate::core::utils::norm_cdf(z_v);
177 let v_next = if u <= p { 0.0 } else { ((1.0 - p) / (1.0 - u)).ln() / beta };
178 (v_next, QeBranch::Exponential { p, beta })
179 }
180}
181
182pub fn qe_variance_step(hp: &HestonParams, v: f64, dt: f64, z_v: f64) -> f64 {
190 qe_variance_draw(hp, v.max(0.0), dt, z_v).0
191}
192
193pub struct HestonProcess {
206 pub drift_rate: f64,
208 pub params: HestonParams,
209 pub scheme: HestonScheme,
210}
211
212impl StochasticProcess for HestonProcess {
213 fn dim(&self) -> usize {
214 2
215 }
216
217 fn factors(&self) -> usize {
218 2
219 }
220
221 fn drift(&self, _t: f64, x: &[f64], out: &mut [f64]) {
222 let v_pos = x[1].max(0.0);
223 out[0] = self.drift_rate * x[0];
224 out[1] = self.params.kappa * (self.params.theta - v_pos);
225 }
226
227 fn diffusion(&self, _t: f64, x: &[f64], out: &mut [f64]) {
228 let sqrt_v = x[1].max(0.0).sqrt();
229 let rho = self.params.rho;
230 out[0] = sqrt_v * x[0];
233 out[1] = 0.0;
234 out[2] = self.params.vol_of_vol * sqrt_v * rho;
235 out[3] = self.params.vol_of_vol * sqrt_v * (1.0 - rho * rho).sqrt();
236 }
237
238 fn evolve(&self, _t: f64, x: &[f64], dt: f64, dw: &[f64], out: &mut [f64]) {
239 match self.scheme {
240 HestonScheme::FullTruncation => self.step_full_truncation(x, dt, dw, out),
241 HestonScheme::QuadraticExponential => self.step_qe(x, dt, dw, out),
242 }
243 }
244}
245
246impl HestonProcess {
247 fn step_full_truncation(&self, x: &[f64], dt: f64, dw: &[f64], out: &mut [f64]) {
248 let hp = &self.params;
249 let rho_perp = (1.0 - hp.rho * hp.rho).sqrt();
250 let v_pos = x[1].max(0.0);
251 let sqrt_v = v_pos.sqrt();
252 let dw_v = hp.rho * dw[0] + rho_perp * dw[1];
253 out[0] = x[0] * exp((self.drift_rate - 0.5 * v_pos) * dt + sqrt_v * dw[0]);
254 out[1] = x[1] + hp.kappa * (hp.theta - v_pos) * dt + hp.vol_of_vol * sqrt_v * dw_v;
255 }
256
257 fn step_qe(&self, x: &[f64], dt: f64, dw: &[f64], out: &mut [f64]) {
263 let hp = &self.params;
264 let (kappa, theta, xi, rho) = (hp.kappa, hp.theta, hp.vol_of_vol, hp.rho);
265 let v = x[1].max(0.0);
266 let sqrt_dt = dt.sqrt();
267 let z_s = dw[0] / sqrt_dt;
268 let z_v = dw[1] / sqrt_dt;
269
270 let (v_next, branch) = qe_variance_draw(hp, v, dt, z_v);
271
272 let k1 = 0.5 * dt * (kappa * rho / xi - 0.5) - rho / xi;
274 let k2 = 0.5 * dt * (kappa * rho / xi - 0.5) + rho / xi;
275 let k3 = 0.5 * dt * (1.0 - rho * rho);
276 let k4 = k3;
277 let a_mc = k2 + 0.5 * k4;
279 let k0_plain = -rho * kappa * theta * dt / xi;
282
283 let k0 = match branch {
284 QeBranch::Quadratic { a, b2 } if 2.0 * a_mc * a < 1.0 => {
285 -a_mc * b2 * a / (1.0 - 2.0 * a_mc * a)
286 + 0.5 * (1.0 - 2.0 * a_mc * a).ln()
287 - (k1 + 0.5 * k3) * v
288 }
289 QeBranch::Exponential { p, beta } if a_mc < beta => {
290 -(p + beta * (1.0 - p) / (beta - a_mc)).ln() - (k1 + 0.5 * k3) * v
291 }
292 _ => k0_plain,
293 };
294
295 out[0] = x[0]
296 * exp(self.drift_rate * dt
297 + k0
298 + k1 * v
299 + k2 * v_next
300 + (k3 * v + k4 * v_next).sqrt() * z_s);
301 out[1] = v_next;
302 }
303}
304
305pub struct MultiAssetGbmProcess {
320 pub drift_rates: Vec<f64>,
322 pub vols: Vec<f64>,
323 pub chol: Vec<Vec<f64>>,
325}
326
327impl StochasticProcess for MultiAssetGbmProcess {
328 fn dim(&self) -> usize {
329 self.vols.len()
330 }
331
332 fn factors(&self) -> usize {
333 self.vols.len()
334 }
335
336 fn drift(&self, _t: f64, x: &[f64], out: &mut [f64]) {
337 for i in 0..self.dim() {
338 out[i] = self.drift_rates[i] * x[i];
339 }
340 }
341
342 fn diffusion(&self, _t: f64, x: &[f64], out: &mut [f64]) {
343 let n = self.dim();
344 for i in 0..n {
345 for j in 0..n {
346 out[i * n + j] =
347 if j <= i { self.vols[i] * x[i] * self.chol[i][j] } else { 0.0 };
348 }
349 }
350 }
351
352 fn evolve(&self, _t: f64, x: &[f64], dt: f64, dw: &[f64], out: &mut [f64]) {
353 for i in 0..self.dim() {
354 let dwi: f64 = (0..=i).map(|j| self.chol[i][j] * dw[j]).sum();
356 let sigma = self.vols[i];
357 out[i] =
358 x[i] * exp((self.drift_rates[i] - 0.5 * sigma * sigma) * dt + sigma * dwi);
359 }
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 fn gbm() -> BlackScholesProcess<'static> {
368 BlackScholesProcess::new(0.03, VolDynamics::Const(0.2))
369 }
370
371 #[test]
372 fn coefficients_are_multiplicative() {
373 let p = gbm();
374 assert!((StochasticProcess1D::drift(&p, 0.0, 100.0) - 3.0).abs() < 1e-14);
375 assert!((StochasticProcess1D::diffusion(&p, 0.0, 100.0) - 20.0).abs() < 1e-14);
376 assert!((p.diffusion_dx(0.0, 100.0) - 0.2).abs() < 1e-14);
377 }
378
379 #[test]
380 fn schemes_match_the_classical_gbm_formulas() {
381 let p = gbm();
382 let (s, dt, dw) = (100.0, 0.01, 0.05);
383 let exact = p.evolve(DiscretizationScheme::Exact, 0.0, s, dt, dw);
384 assert!((exact - s * ((0.03 - 0.5 * 0.04) * dt + 0.2 * dw).exp()).abs() < 1e-10);
385 let euler = p.evolve(DiscretizationScheme::Euler, 0.0, s, dt, dw);
386 assert!((euler - s * (1.0 + 0.03 * dt + 0.2 * dw)).abs() < 1e-12);
387 let milstein = p.evolve(DiscretizationScheme::Milstein, 0.0, s, dt, dw);
388 let expect =
389 s * (1.0 + 0.03 * dt + 0.2 * dw + 0.5 * 0.04 * (dw * dw - dt));
390 assert!((milstein - expect).abs() < 1e-12);
391 }
392
393 #[test]
394 fn spot_is_floored_at_zero() {
395 let p = gbm();
397 let next = p.evolve(DiscretizationScheme::Euler, 0.0, 100.0, 0.01, -10.0);
398 assert_eq!(next, 0.0);
399 }
400
401 #[test]
402 fn heston_evolve_matches_full_truncation_euler() {
403 let hp = HestonParams { v0: 0.04, kappa: 1.5, theta: 0.05, vol_of_vol: 0.5, rho: -0.7 };
404 let p = HestonProcess {
405 drift_rate: 0.02,
406 params: hp,
407 scheme: HestonScheme::FullTruncation,
408 };
409 let x = [100.0, -0.01]; let (dt, dw) = (0.004, [0.03, -0.02]);
411 let mut out = [0.0; 2];
412 p.evolve(0.0, &x, dt, &dw, &mut out);
413 assert!((out[0] - 100.0 * (0.02_f64 * dt).exp()).abs() < 1e-10);
416 assert!((out[1] - (-0.01 + 1.5 * 0.05 * dt)).abs() < 1e-12);
417 }
418
419 #[test]
420 fn heston_diffusion_matrix_encodes_the_correlation() {
421 let hp = HestonParams { v0: 0.04, kappa: 1.5, theta: 0.05, vol_of_vol: 0.5, rho: -0.7 };
422 let p = HestonProcess {
423 drift_rate: 0.0,
424 params: hp,
425 scheme: HestonScheme::FullTruncation,
426 };
427 let mut b = [0.0; 4];
428 StochasticProcess::diffusion(&p, 0.0, &[100.0, 0.04], &mut b);
429 assert!((b[0] - 20.0).abs() < 1e-12 && b[1] == 0.0);
431 let xi_sv = 0.5 * 0.2;
432 assert!((b[2] - xi_sv * -0.7).abs() < 1e-12);
433 assert!((b[3] - xi_sv * (1.0_f64 - 0.49).sqrt()).abs() < 1e-12);
434 assert!((b[2] * b[2] + b[3] * b[3] - xi_sv * xi_sv).abs() < 1e-12);
436 }
437
438 fn qe_sample(hp: HestonParams, drift: f64, v: f64, dt: f64, n: usize) -> (f64, f64, f64) {
443 use crate::core::montecarlo::path_normals;
444 let p = HestonProcess {
445 drift_rate: drift,
446 params: hp,
447 scheme: HestonScheme::QuadraticExponential,
448 };
449 let sqrt_dt = dt.sqrt();
450 let (mut sum_v, mut sum_v2, mut sum_s) = (0.0, 0.0, 0.0);
451 let mut out = [0.0; 2];
452 let mut z = [0.0; 2];
453 for i in 0..n {
454 path_normals(7, i as u64, &mut z);
457 let dw = [sqrt_dt * z[0], sqrt_dt * z[1]];
458 p.evolve(0.0, &[100.0, v], dt, &dw, &mut out);
459 sum_v += out[1];
460 sum_v2 += out[1] * out[1];
461 sum_s += out[0];
462 }
463 let mean = sum_v / n as f64;
464 (mean, sum_v2 / n as f64 - mean * mean, sum_s / n as f64 / 100.0)
465 }
466
467 fn cir_moments(hp: &HestonParams, v: f64, dt: f64) -> (f64, f64) {
469 let e = (-hp.kappa * dt).exp();
470 let xi2 = hp.vol_of_vol * hp.vol_of_vol;
471 let m = hp.theta + (v - hp.theta) * e;
472 let s2 = v * xi2 * e * (1.0 - e) / hp.kappa
473 + hp.theta * xi2 * (1.0 - e) * (1.0 - e) / (2.0 * hp.kappa);
474 (m, s2)
475 }
476
477 #[test]
478 fn qe_quadratic_branch_matches_the_cir_moments() {
479 let hp = HestonParams { v0: 0.09, kappa: 2.0, theta: 0.09, vol_of_vol: 0.4, rho: -0.7 };
481 let (v, dt) = (0.09, 0.01);
482 let (m, s2) = cir_moments(&hp, v, dt);
483 assert!(s2 / (m * m) <= QE_PSI_SWITCH, "test must hit the quadratic branch");
484 let (mean, var, _) = qe_sample(hp, 0.0, v, dt, 200_000);
485 assert!((mean - m).abs() / m < 0.01, "mean {mean} vs {m}");
486 assert!((var - s2).abs() / s2 < 0.02, "var {var} vs {s2}");
487 }
488
489 #[test]
490 fn qe_exponential_branch_matches_the_cir_moments() {
491 let hp = HestonParams { v0: 0.001, kappa: 0.5, theta: 0.04, vol_of_vol: 1.0, rho: -0.7 };
493 let (v, dt) = (0.001, 1.0);
494 let (m, s2) = cir_moments(&hp, v, dt);
495 assert!(s2 / (m * m) > QE_PSI_SWITCH, "test must hit the exponential branch");
496 let (mean, var, _) = qe_sample(hp, 0.0, v, dt, 200_000);
497 assert!((mean - m).abs() / m < 0.02, "mean {mean} vs {m}");
498 assert!((var - s2).abs() / s2 < 0.03, "var {var} vs {s2}");
499 }
500
501 #[test]
502 fn qe_spot_step_is_a_martingale_in_both_branches() {
503 let quad = HestonParams { v0: 0.09, kappa: 2.0, theta: 0.09, vol_of_vol: 0.4, rho: -0.7 };
507 let (_, _, ratio) = qe_sample(quad, 0.0, 0.09, 0.05, 400_000);
508 assert!((ratio - 1.0).abs() < 2e-3, "quadratic branch ratio {ratio}");
509
510 let expo = HestonParams { v0: 0.001, kappa: 0.5, theta: 0.04, vol_of_vol: 1.0, rho: -0.7 };
511 let (_, _, ratio) = qe_sample(expo, 0.0, 0.001, 1.0, 400_000);
512 assert!((ratio - 1.0).abs() < 5e-3, "exponential branch ratio {ratio}");
513 }
514
515 #[test]
516 fn qe_variance_is_never_negative() {
517 let hp = HestonParams { v0: 0.02, kappa: 1.0, theta: 0.03, vol_of_vol: 0.9, rho: -0.5 };
518 let p = HestonProcess {
519 drift_rate: 0.0,
520 params: hp,
521 scheme: HestonScheme::QuadraticExponential,
522 };
523 let mut out = [0.0; 2];
524 for z in [-4.0, -1.0, 0.0, 1.0, 4.0] {
525 let dt: f64 = 0.02;
526 let dw = [0.0, dt.sqrt() * z];
527 p.evolve(0.0, &[100.0, 0.0001], dt, &dw, &mut out);
528 assert!(out[1] >= 0.0, "z={z}: v_next={}", out[1]);
529 assert!(out[0] > 0.0);
530 }
531 }
532
533 fn two_asset_gbm(rho: f64) -> MultiAssetGbmProcess {
536 MultiAssetGbmProcess {
537 drift_rates: vec![0.03, 0.01],
538 vols: vec![0.2, 0.3],
539 chol: vec![vec![1.0, 0.0], vec![rho, (1.0 - rho * rho).sqrt()]],
540 }
541 }
542
543 #[test]
544 fn multi_gbm_recovers_forwards_and_correlation() {
545 use crate::core::montecarlo::path_normals;
546 let rho = -0.6;
547 let p = two_asset_gbm(rho);
548 let (dt, n) = (0.02_f64, 200_000);
549 let mut z = [0.0; 2];
550 let mut out = [0.0; 2];
551 let (mut m0, mut m1) = (0.0, 0.0);
552 let (mut c00, mut c11, mut c01) = (0.0, 0.0, 0.0);
553 let sqrt_dt = dt.sqrt();
554 for i in 0..n {
555 path_normals(11, i as u64, &mut z);
556 let dw = [sqrt_dt * z[0], sqrt_dt * z[1]];
557 p.evolve(0.0, &[100.0, 50.0], dt, &dw, &mut out);
558 m0 += out[0];
559 m1 += out[1];
560 let (l0, l1) = ((out[0] / 100.0).ln(), (out[1] / 50.0).ln());
561 c00 += l0 * l0;
562 c11 += l1 * l1;
563 c01 += l0 * l1;
564 }
565 let nf = n as f64;
566 let (t0, t1) = (100.0 * (0.03_f64 * dt).exp(), 50.0 * (0.01_f64 * dt).exp());
567 assert!((m0 / nf - t0).abs() / t0 < 1e-3, "asset 0 forward {}", m0 / nf);
568 assert!((m1 / nf - t1).abs() / t1 < 1e-3, "asset 1 forward {}", m1 / nf);
569 let corr = c01 / (c00 * c11).sqrt();
571 assert!((corr - rho).abs() < 0.01, "log-return correlation {corr} vs {rho}");
572 }
573
574 #[test]
575 fn multi_gbm_diffusion_matrix_is_the_scaled_cholesky() {
576 let p = two_asset_gbm(0.5);
577 let mut b = [0.0; 4];
578 StochasticProcess::diffusion(&p, 0.0, &[100.0, 50.0], &mut b);
579 assert!((b[0] - 0.2 * 100.0).abs() < 1e-12 && b[1] == 0.0);
580 assert!((b[2] - 0.3 * 50.0 * 0.5).abs() < 1e-12);
581 assert!((b[3] - 0.3 * 50.0 * 0.75_f64.sqrt()).abs() < 1e-12);
582 }
583
584 #[test]
585 fn perfectly_correlated_identical_assets_move_in_lockstep() {
586 let p = MultiAssetGbmProcess {
587 drift_rates: vec![0.02, 0.02],
588 vols: vec![0.25, 0.25],
589 chol: vec![vec![1.0, 0.0], vec![1.0, 0.0]],
590 };
591 let mut out = [0.0; 2];
592 p.evolve(0.0, &[80.0, 80.0], 0.01, &[0.03, -0.4], &mut out);
593 assert!((out[0] - out[1]).abs() < 1e-12);
594 }
595}