Skip to main content

rustyqlib/core/montecarlo/
process.rs

1//! Generic Itô-process abstraction: the SDE's coefficients live in the
2//! process object and the discretization schemes are written **once**
3//! against them — the QuantLib `StochasticProcess` / TF Quant Finance
4//! `GenericItoProcess` pattern.
5//!
6//! ```text
7//! dX = a(t, X) dt + b(t, X) dW
8//! ```
9//!
10//! - [`StochasticProcess1D`] is the scalar contract; Euler and Milstein
11//!   are provided methods over [`drift`](StochasticProcess1D::drift) /
12//!   [`diffusion`](StochasticProcess1D::diffusion), so a new SDE
13//!   (Vasicek / Hull-White, CEV, CIR, ...) only supplies coefficients.
14//! - Processes with a closed-form transition density override
15//!   [`exact_step`](StochasticProcess1D::exact_step) (lognormal for
16//!   Black-Scholes, Gaussian for Ornstein-Uhlenbeck); models whose good
17//!   schemes are genuinely model-specific (Heston full-truncation / QE)
18//!   override [`evolve`](StochasticProcess::evolve) wholesale.
19//! - State constraints belong to the process, not the stepper: a
20//!   lognormal equity floors at zero via
21//!   [`constrain`](StochasticProcess1D::constrain), while a normal-SDE
22//!   short rate legitimately goes negative (the default is the
23//!   identity).
24//! - [`StochasticProcess`] is the N-state / M-factor generalization
25//!   (Heston: 2 states driven by 2 correlated factors, the correlation
26//!   folded into the diffusion matrix rows).
27
28use std::str::FromStr;
29
30/// Time-stepping scheme for path-wise simulation.
31/// `Exact` samples the process's closed-form transition where one exists
32/// (no discretization bias) and degrades to Euler where none does;
33/// Euler and Milstein are the standard approximate schemes.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum DiscretizationScheme {
36    Exact,
37    Euler,
38    Milstein,
39}
40
41impl FromStr for DiscretizationScheme {
42    type Err = String;
43    fn from_str(s: &str) -> Result<Self, Self::Err> {
44        match s.trim().to_lowercase().as_str() {
45            "exact" => Ok(DiscretizationScheme::Exact),
46            "euler" => Ok(DiscretizationScheme::Euler),
47            "milstein" => Ok(DiscretizationScheme::Milstein),
48            other => Err(format!("Invalid discretization scheme '{other}'")),
49        }
50    }
51}
52
53/// Central finite difference of the diffusion coefficient in the state —
54/// the default for [`StochasticProcess1D::diffusion_dx`], callable from
55/// implementations that override it for one branch of their dynamics.
56pub fn numeric_diffusion_dx<P: StochasticProcess1D + ?Sized>(p: &P, t: f64, x: f64) -> f64 {
57    let h = 1e-5 * x.abs().max(1.0);
58    (p.diffusion(t, x + h) - p.diffusion(t, x - h)) / (2.0 * h)
59}
60
61/// A scalar Itô process `dX = a(t, X) dt + b(t, X) dW`.
62///
63/// Implementations are shared across rayon path chunks, hence `Sync`.
64pub trait StochasticProcess1D: Sync {
65    /// Drift coefficient `a(t, x)`.
66    fn drift(&self, t: f64, x: f64) -> f64;
67
68    /// Diffusion coefficient `b(t, x)`.
69    fn diffusion(&self, t: f64, x: f64) -> f64;
70
71    /// `∂b/∂x`, the extra coefficient Milstein needs.
72    fn diffusion_dx(&self, t: f64, x: f64) -> f64 {
73        numeric_diffusion_dx(self, t, x)
74    }
75
76    /// One draw from the closed-form transition `X_{t+dt} | X_t = x`,
77    /// when the process has one. `None` (the default) makes the `Exact`
78    /// scheme fall back to Euler.
79    fn exact_step(&self, _t: f64, _x: f64, _dt: f64, _dw: f64) -> Option<f64> {
80        None
81    }
82
83    /// State constraint applied after every step. Identity by default —
84    /// only processes whose state space is genuinely bounded (lognormal
85    /// equity at zero, truncated variance) should clamp.
86    fn constrain(&self, x: f64) -> f64 {
87        x
88    }
89
90    /// Advance the state by one step of `scheme`: the generic stepping,
91    /// written against `drift`/`diffusion` alone. Override only to
92    /// exploit model structure (e.g. one volatility lookup shared by the
93    /// coefficients, or a scheme the enum cannot express).
94    fn evolve(&self, scheme: DiscretizationScheme, t: f64, x: f64, dt: f64, dw: f64) -> f64 {
95        let next = match scheme {
96            DiscretizationScheme::Exact => self
97                .exact_step(t, x, dt, dw)
98                .unwrap_or_else(|| x + self.drift(t, x) * dt + self.diffusion(t, x) * dw),
99            DiscretizationScheme::Euler => x + self.drift(t, x) * dt + self.diffusion(t, x) * dw,
100            DiscretizationScheme::Milstein => {
101                let b = self.diffusion(t, x);
102                x + self.drift(t, x) * dt
103                    + b * dw
104                    + 0.5 * b * self.diffusion_dx(t, x) * (dw * dw - dt)
105            }
106        };
107        self.constrain(next)
108    }
109}
110
111/// An N-state Itô process driven by M independent Brownian factors:
112/// `dX_i = a_i(t, X) dt + Σ_j b_ij(t, X) dW_j`. Factor correlation is
113/// expressed through the rows of the diffusion matrix (its Cholesky
114/// structure), so `dw` always carries **independent** increments.
115pub trait StochasticProcess: Sync {
116    /// Number of state variables.
117    fn dim(&self) -> usize;
118
119    /// Number of driving Brownian factors (`dw.len()`).
120    fn factors(&self) -> usize;
121
122    /// Drift vector `a(t, x)` into `out` (`dim` long).
123    fn drift(&self, t: f64, x: &[f64], out: &mut [f64]);
124
125    /// Diffusion matrix `b(t, x)` into `out`, row-major `dim × factors`.
126    fn diffusion(&self, t: f64, x: &[f64], out: &mut [f64]);
127
128    /// State constraint applied after every step (identity by default).
129    fn constrain(&self, _x: &mut [f64]) {}
130
131    /// One Euler-Maruyama step from `x` into `out`. The default
132    /// allocates small scratch buffers; hot loops should override with
133    /// model-specific stepping (which is also where non-Euler schemes —
134    /// exact transitions, Heston full-truncation/QE — live, since
135    /// generic multi-factor Milstein would need Lévy areas).
136    fn evolve(&self, t: f64, x: &[f64], dt: f64, dw: &[f64], out: &mut [f64]) {
137        let (dim, factors) = (self.dim(), self.factors());
138        debug_assert_eq!(dw.len(), factors);
139        let mut a = vec![0.0; dim];
140        let mut b = vec![0.0; dim * factors];
141        self.drift(t, x, &mut a);
142        self.diffusion(t, x, &mut b);
143        for i in 0..dim {
144            let shock: f64 =
145                (0..factors).map(|j| b[i * factors + j] * dw[j]).sum();
146            out[i] = x[i] + a[i] * dt + shock;
147        }
148        self.constrain(out);
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    /// Vasicek short rate `dr = kappa (theta - r) dt + sigma dW` — an
157    /// additive, mean-reverting SDE the old GBM-shaped stepper could not
158    /// express: the state may go negative and the exact transition is
159    /// Gaussian, not lognormal.
160    struct Vasicek {
161        kappa: f64,
162        theta: f64,
163        sigma: f64,
164    }
165
166    impl StochasticProcess1D for Vasicek {
167        fn drift(&self, _t: f64, x: f64) -> f64 {
168            self.kappa * (self.theta - x)
169        }
170        fn diffusion(&self, _t: f64, _x: f64) -> f64 {
171            self.sigma
172        }
173        fn exact_step(&self, _t: f64, x: f64, dt: f64, dw: f64) -> Option<f64> {
174            // Ornstein-Uhlenbeck transition: mean-revert the state and
175            // scale the shock to the transition's standard deviation
176            let decay = (-self.kappa * dt).exp();
177            let mean = self.theta + (x - self.theta) * decay;
178            let var = self.sigma * self.sigma * (1.0 - decay * decay) / (2.0 * self.kappa);
179            let z = dw / dt.sqrt();
180            Some(mean + var.sqrt() * z)
181        }
182    }
183
184    #[test]
185    fn euler_matches_hand_computed_step() {
186        let p = Vasicek { kappa: 2.0, theta: 0.03, sigma: 0.01 };
187        let (t, x, dt, dw) = (0.5, 0.05, 0.01, -0.02);
188        let expected = x + 2.0 * (0.03 - 0.05) * dt + 0.01 * dw;
189        let got = p.evolve(DiscretizationScheme::Euler, t, x, dt, dw);
190        assert!((got - expected).abs() < 1e-15);
191    }
192
193    #[test]
194    fn milstein_reduces_to_euler_for_additive_noise() {
195        // constant diffusion => db/dx = 0 => the Milstein correction
196        // vanishes (the numeric default derivative must see that)
197        let p = Vasicek { kappa: 2.0, theta: 0.03, sigma: 0.01 };
198        let (t, x, dt, dw) = (0.5, 0.05, 0.01, 0.03);
199        let euler = p.evolve(DiscretizationScheme::Euler, t, x, dt, dw);
200        let milstein = p.evolve(DiscretizationScheme::Milstein, t, x, dt, dw);
201        assert!((euler - milstein).abs() < 1e-12);
202    }
203
204    #[test]
205    fn state_can_go_negative_without_a_gbm_floor() {
206        // a large negative shock takes the rate below zero — legitimate
207        // for a normal SDE, and the default constrain must not clamp it
208        let p = Vasicek { kappa: 0.5, theta: 0.01, sigma: 0.02 };
209        let next = p.evolve(DiscretizationScheme::Euler, 0.0, 0.001, 0.01, -0.5);
210        assert!(next < 0.0);
211    }
212
213    #[test]
214    fn exact_step_hits_the_ou_transition_mean() {
215        // dw = 0 => the exact step lands exactly on the conditional mean
216        let p = Vasicek { kappa: 2.0, theta: 0.03, sigma: 0.01 };
217        let next = p.evolve(DiscretizationScheme::Exact, 0.0, 0.05, 0.25, 0.0);
218        let mean = 0.03 + (0.05 - 0.03) * (-2.0_f64 * 0.25).exp();
219        assert!((next - mean).abs() < 1e-15);
220    }
221
222    #[test]
223    fn numeric_diffusion_dx_recovers_multiplicative_slope() {
224        // b(x) = sigma * x => db/dx = sigma
225        struct Gbmish;
226        impl StochasticProcess1D for Gbmish {
227            fn drift(&self, _t: f64, x: f64) -> f64 {
228                0.05 * x
229            }
230            fn diffusion(&self, _t: f64, x: f64) -> f64 {
231                0.2 * x
232            }
233        }
234        let d = numeric_diffusion_dx(&Gbmish, 0.0, 100.0);
235        assert!((d - 0.2).abs() < 1e-8, "{d}");
236    }
237
238    #[test]
239    fn multi_dim_default_euler() {
240        // 2-state, 2-factor linear process with a non-diagonal diffusion
241        struct TwoDim;
242        impl StochasticProcess for TwoDim {
243            fn dim(&self) -> usize {
244                2
245            }
246            fn factors(&self) -> usize {
247                2
248            }
249            fn drift(&self, _t: f64, x: &[f64], out: &mut [f64]) {
250                out[0] = 0.1 * x[0];
251                out[1] = -0.2 * x[1];
252            }
253            fn diffusion(&self, _t: f64, _x: &[f64], out: &mut [f64]) {
254                out.copy_from_slice(&[0.3, 0.0, 0.1, 0.2]);
255            }
256        }
257        let (x, dt, dw) = ([1.0, 2.0], 0.01, [0.05, -0.03]);
258        let mut out = [0.0; 2];
259        TwoDim.evolve(0.0, &x, dt, &dw, &mut out);
260        assert!((out[0] - (1.0 + 0.1 * 1.0 * dt + 0.3 * 0.05)).abs() < 1e-15);
261        assert!((out[1] - (2.0 - 0.2 * 2.0 * dt + 0.1 * 0.05 - 0.2 * 0.03)).abs() < 1e-15);
262    }
263}