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}