Skip to main content

numra_sde/
wiener.rs

1//! Wiener process (Brownian motion) generator.
2//!
3//! Author: Moussa Leblouba
4//! Date: 3 February 2026
5//! Modified: 2 May 2026
6
7use numra_core::Scalar;
8use rand::prelude::*;
9use rand_distr::StandardNormal;
10
11/// Wiener process increment ΔW ~ N(0, Δt).
12#[derive(Clone, Debug)]
13pub struct WienerIncrement<S: Scalar> {
14    /// The increment values (one per Wiener process)
15    pub dw: Vec<S>,
16    /// The time step
17    pub dt: S,
18}
19
20impl<S: Scalar> WienerIncrement<S> {
21    /// Create a new increment with given values.
22    pub fn new(dw: Vec<S>, dt: S) -> Self {
23        Self { dw, dt }
24    }
25
26    /// Create zero increment.
27    pub fn zeros(n_wiener: usize, dt: S) -> Self {
28        Self {
29            dw: vec![S::ZERO; n_wiener],
30            dt,
31        }
32    }
33}
34
35/// Wiener process generator for SDE simulation.
36///
37/// Generates increments ΔW(t) ~ N(0, Δt) for standard Brownian motion.
38pub struct WienerProcess<R: Rng> {
39    rng: R,
40    n_wiener: usize,
41}
42
43impl<R: Rng> WienerProcess<R> {
44    /// Create a new Wiener process with the given RNG.
45    pub fn new(rng: R, n_wiener: usize) -> Self {
46        Self { rng, n_wiener }
47    }
48
49    /// Generate a single increment ΔW ~ N(0, dt).
50    pub fn increment<S: Scalar>(&mut self, dt: S) -> WienerIncrement<S> {
51        let sqrt_dt = dt.sqrt();
52        let dw: Vec<S> = (0..self.n_wiener)
53            .map(|_| {
54                let z: f64 = self.rng.sample(StandardNormal);
55                S::from_f64(z) * sqrt_dt
56            })
57            .collect();
58        WienerIncrement::new(dw, dt)
59    }
60
61    /// Generate multiple increments for a path from t0 to tf with step dt.
62    pub fn path<S: Scalar>(&mut self, t0: S, tf: S, dt: S) -> Vec<WienerIncrement<S>> {
63        let mut increments = Vec::new();
64        let mut t = t0;
65        while t < tf {
66            let step = dt.min(tf - t);
67            increments.push(self.increment(step));
68            t += step;
69        }
70        increments
71    }
72
73    /// Generate correlated increments given a correlation matrix.
74    ///
75    /// The correlation matrix L should be the Cholesky factor of the
76    /// correlation matrix: Corr = L * L^T.
77    pub fn correlated_increment<S: Scalar>(
78        &mut self,
79        dt: S,
80        cholesky: &[S], // n_wiener x n_wiener, row-major
81    ) -> WienerIncrement<S> {
82        let sqrt_dt = dt.sqrt();
83        let n = self.n_wiener;
84
85        // Generate independent standard normals
86        let z: Vec<f64> = (0..n).map(|_| self.rng.sample(StandardNormal)).collect();
87
88        // Apply Cholesky factor: dW = L * Z * sqrt(dt)
89        let mut dw = vec![S::ZERO; n];
90        for i in 0..n {
91            for j in 0..=i {
92                dw[i] += cholesky[i * n + j] * S::from_f64(z[j]);
93            }
94            dw[i] *= sqrt_dt;
95        }
96
97        WienerIncrement::new(dw, dt)
98    }
99}
100
101impl WienerProcess<StdRng> {
102    /// Create a Wiener process with a seed for reproducibility.
103    pub fn with_seed(seed: u64, n_wiener: usize) -> Self {
104        Self::new(StdRng::seed_from_u64(seed), n_wiener)
105    }
106
107    /// Create a Wiener process with random seed from system entropy.
108    pub fn from_entropy(n_wiener: usize) -> Self {
109        Self::new(StdRng::from_entropy(), n_wiener)
110    }
111}
112
113/// Create a Wiener process with optional seed.
114pub fn create_wiener(n_wiener: usize, seed: Option<u64>) -> WienerProcess<StdRng> {
115    match seed {
116        Some(s) => WienerProcess::with_seed(s, n_wiener),
117        None => WienerProcess::from_entropy(n_wiener),
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn test_wiener_increment() {
127        let mut wp = WienerProcess::with_seed(42, 2);
128        let inc = wp.increment::<f64>(0.01);
129
130        assert_eq!(inc.dw.len(), 2);
131        assert!((inc.dt - 0.01).abs() < 1e-10);
132        // Increments should be non-zero (with high probability)
133        assert!(inc.dw[0].abs() > 0.0 || inc.dw[1].abs() > 0.0);
134    }
135
136    #[test]
137    fn test_wiener_path() {
138        let mut wp = WienerProcess::with_seed(42, 1);
139        let path = wp.path::<f64>(0.0, 1.0, 0.1);
140
141        // Should have approximately 10 increments (may vary by ±1 due to floating point)
142        assert!(path.len() >= 10 && path.len() <= 11);
143
144        // Most increments should be close to dt
145        let full_steps = path
146            .iter()
147            .filter(|inc| (inc.dt - 0.1).abs() < 0.01)
148            .count();
149        assert!(full_steps >= 9);
150    }
151
152    #[test]
153    fn test_wiener_variance() {
154        // Check that variance of increments ≈ dt
155        let mut wp = WienerProcess::with_seed(123, 1);
156        let n = 10000;
157        let dt = 0.01_f64;
158
159        let increments: Vec<f64> = (0..n).map(|_| wp.increment::<f64>(dt).dw[0]).collect();
160
161        let mean: f64 = increments.iter().sum::<f64>() / n as f64;
162        let variance: f64 = increments.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n as f64;
163
164        // Mean should be close to 0
165        assert!(mean.abs() < 0.01);
166        // Variance should be close to dt
167        assert!((variance - dt).abs() < 0.001);
168    }
169
170    #[test]
171    fn test_correlated_wiener() {
172        // 2x2 correlation with ρ = 0.5
173        // L = [[1, 0], [0.5, sqrt(0.75)]]
174        let mut wp = WienerProcess::with_seed(42, 2);
175        let cholesky = [1.0, 0.0, 0.5, 0.75_f64.sqrt()];
176        let inc = wp.correlated_increment::<f64>(0.01, &cholesky);
177
178        assert_eq!(inc.dw.len(), 2);
179    }
180}