Skip to main content

rustyqlib/equity/
processes.rs

1//! Equity model dynamics as [`StochasticProcess1D`] / [`StochasticProcess`]
2//! implementations — the bridge between the model layer (GBM, Dupire
3//! local vol, Heston) and the generic stepping in
4//! [`core::montecarlo::process`](crate::core::montecarlo::process).
5//!
6//! The Monte Carlo engine consumes these; the same objects can feed any
7//! future coefficient-driven method (finite difference via the
8//! Feynman-Kac link, other samplers) without touching the models.
9
10use 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
18// ── Black-Scholes / local volatility ────────────────────────────────────
19
20/// Volatility dynamics along a path: constant (GBM) or Dupire local vol.
21pub enum VolDynamics<'a> {
22    Const(f64),
23    Local(LocalVol<'a>),
24}
25
26/// Risk-neutral lognormal dynamics `dS = (r - q) S dt + sigma(S, t) S dW`
27/// with constant or local volatility.
28///
29/// `evolve` is overridden so each step costs one volatility lookup shared
30/// by drift and diffusion (a local-vol lookup is several surface
31/// interpolations); the trait's coefficient methods expose the same
32/// dynamics to generic consumers.
33pub struct BlackScholesProcess<'a> {
34    /// Risk-neutral drift rate `r - q` (of the *rate*, not `a(t,x)`).
35    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    /// The volatility used to diffuse at level `s`, time `t`.
45    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    /// One step with an externally supplied volatility — for callers that
53    /// already looked it up for their own purposes (the barrier engine
54    /// needs `sigma` for the Brownian-bridge crossing probability).
55    ///
56    /// `Exact` is the closed-form lognormal transition under constant
57    /// vol; under local vol it is the standard frozen-coefficient
58    /// log-Euler step (not exact, but positivity-preserving and the
59    /// usual choice). Milstein's `∂b/∂x` keeps the `S ∂sigma/∂S` term
60    /// under local vol via the numeric derivative.
61    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                // b = sigma(S,t) S, so b' = sigma under constant vol and
78                // sigma + S dsigma/dS under local vol
79                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    /// A lognormal spot cannot go negative; approximate schemes can.
109    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// ── Heston stochastic volatility ────────────────────────────────────────
120
121/// Heston-specific stepping schemes — model-owned, because good variance
122/// stepping is genuinely model-specific and no generic Euler/Milstein
123/// switch covers it (the QuantLib / TF Quant Finance pattern).
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum HestonScheme {
126    /// Euler with the variance floored at zero inside the coefficients
127    /// while the state keeps its (possibly negative) excursion; log-Euler
128    /// spot stepping. Simple, O(dt) biased.
129    FullTruncation,
130    /// Andersen (2008) Quadratic-Exponential with martingale correction
131    /// (QE-M): the variance transition is moment-matched to the exact
132    /// non-central chi-squared law — a squared Gaussian where the
133    /// distribution is peaked (`psi <= 1.5`), a mass-at-zero /
134    /// exponential-tail mixture where it is not — and the spot step's
135    /// constant is chosen so `E[S_{t+dt} | S_t, v_t]` is exact. Near
136    /// bias-free even with coarse time grids.
137    QuadraticExponential,
138}
139
140/// Andersen's switching threshold between the quadratic and exponential
141/// variance samplers (any value in [1, 2] is valid; 1.5 is his choice).
142const QE_PSI_SWITCH: f64 = 1.5;
143
144/// Which QE sampler fired, with the parameters the martingale correction
145/// needs.
146enum QeBranch {
147    /// `v_next = a (b + Z)^2`.
148    Quadratic { a: f64, b2: f64 },
149    /// Mass at zero with probability `p`, exponential tail of rate `beta`.
150    Exponential { p: f64, beta: f64 },
151}
152
153/// One Andersen QE draw of the CIR variance transition, plus the branch
154/// bookkeeping.
155fn 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    // conditional mean and variance of v_{t+dt} | v_t (exact CIR moments)
158    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        // squared-Gaussian branch: v_next = a (b + Z)^2 matching (m, s2)
166        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        // mass-at-zero / exponential-tail branch, driven through the
173        // normal's uniform so the caller's draw pipeline is unchanged
174        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
182/// One Andersen QE draw of the CIR variance transition
183/// `v_{t+dt} | v_t = v` from a standard normal `z_v` — the sampler
184/// matches the exact conditional mean and variance of the square-root
185/// process, switching between a squared-Gaussian and a
186/// mass-at-zero/exponential form. Public for consumers that step the
187/// variance leg on its own (the SLV engine pairs it with a
188/// leverage-adjusted spot step).
189pub 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
193/// Heston dynamics as a 2-state, 2-factor process, state `[S, v]`:
194///
195/// ```text
196/// dS = (r - q) S dt + sqrt(v) S dW_s
197/// dv = kappa (theta - v) dt + vol_of_vol sqrt(v) dW_v
198/// ```
199///
200/// `dw` carries **independent** increments; the spot/variance correlation
201/// is applied inside — through the Cholesky rows of the diffusion matrix
202/// for full truncation, analytically through the `K1`/`K2` coefficients
203/// for QE. `evolve` overrides the generic Euler with the selected
204/// [`HestonScheme`].
205pub struct HestonProcess {
206    /// Risk-neutral drift rate `r - q`.
207    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        // row-major 2x2: spot row, then variance row (correlation folded
231        // into the Cholesky structure)
232        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    /// One Andersen QE-M step. `dw[0]` drives the spot, `dw[1]` the
258    /// variance; both are plain Brownian increments, converted back to
259    /// standard normals internally (the exponential branch further maps
260    /// its normal to a uniform through the Gaussian CDF, so the caller's
261    /// draw pipeline — antithetics included — is unchanged).
262    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        // spot-step coefficients, central discretization (gamma1 = gamma2 = 1/2)
273        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        // exponent in the martingale correction E[exp(a_mc * v_next)]
278        let a_mc = k2 + 0.5 * k4;
279        // plain-QE constant, the fallback where the correction's
280        // moment-generating function does not exist
281        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
305// ── Correlated multi-asset lognormal dynamics ───────────────────────────
306
307/// N correlated lognormal assets as one N-state, N-factor process:
308///
309/// ```text
310/// dS_i = (r - q_i) S_i dt + sigma_i S_i dW_i,   d<W_i, W_j> = rho_ij dt
311/// ```
312///
313/// The correlation enters through the rows of the lower-triangular
314/// Cholesky factor, so `dw` carries **independent** increments (the
315/// [`StochasticProcess`] contract). `evolve` overrides the generic Euler
316/// with the exact per-asset lognormal transition — under constant
317/// coefficients the joint law is exact at any step size, so coarse
318/// grids only cost monitoring resolution, never bias.
319pub struct MultiAssetGbmProcess {
320    /// Per-asset risk-neutral drift rates `r - q_i`.
321    pub drift_rates: Vec<f64>,
322    pub vols: Vec<f64>,
323    /// Lower-triangular Cholesky factor of the asset correlation matrix.
324    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            // correlated Brownian increment of asset i
355            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        // a catastrophic Euler shock cannot take a lognormal spot negative
396        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]; // negative variance excursion
410        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        // v_pos = 0: the spot diffuses at zero vol, the variance keeps
414        // its negative state and mean-reverts from the floored value
415        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        // spot row: [sqrt(v) S, 0]; variance row: xi sqrt(v) [rho, sqrt(1-rho^2)]
430        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        // row 2 has squared norm (xi sqrt(v))^2 — the Cholesky property
435        assert!((b[2] * b[2] + b[3] * b[3] - xi_sv * xi_sv).abs() < 1e-12);
436    }
437
438    // ── Andersen QE ─────────────────────────────────────────────────────
439
440    /// Sample one QE step's variance from `v` many times and return the
441    /// sample (mean, variance) of `v_next` plus `E[S_next] / S0`.
442    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            // independent per-sample streams: the spot and variance
455            // normals must not be antithetic partners of each other
456            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    /// Exact CIR conditional moments of `v_{t+dt} | v_t`.
468    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        // small psi => squared-Gaussian sampler
480        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        // near-zero variance, high vol-of-vol, coarse step => large psi
492        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        // the martingale correction makes E[S_next | S, v] = S e^{mu dt}
504        // exact; with mu = 0 the discounted spot ratio must be 1 up to
505        // sampling noise
506        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    // ── Multi-asset GBM ─────────────────────────────────────────────────
534
535    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        // sample correlation of the log-returns (means are O(dt), ignorable)
570        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}