Skip to main content

rustyqlib/core/montecarlo/
paths.rs

1//! Public path generation over the stochastic-process traits — the
2//! library's `sample_paths` API (the TF-Quant-Finance idiom): give it a
3//! process, an initial state and a sampling configuration, get back the
4//! simulated paths as a dense matrix, generated in parallel with the
5//! same deterministic draw discipline the pricing engines use.
6//!
7//! Two entry points:
8//! - [`sample_paths_1d`] for scalar processes ([`StochasticProcess1D`]),
9//!   with the full sampler menu: seeded pseudo-random antithetic pairs,
10//!   or a low-discrepancy sequence routed through the Brownian bridge;
11//! - [`sample_paths`] for N-state / M-factor processes
12//!   ([`StochasticProcess`]); the QMC route runs one bridge per factor.
13//!
14//! Paths exclude the initial state (they start at the first step), the
15//! convention every payoff in the library assumes. Draws are per-path
16//! deterministic — path `i` under seed `s` is the same regardless of
17//! thread scheduling or how many other paths are requested alongside it.
18
19use std::str::FromStr;
20
21use rayon::prelude::*;
22
23use super::brownian_bridge::BrownianBridge;
24use super::halton::QmcSequence;
25use super::process::{DiscretizationScheme, StochasticProcess, StochasticProcess1D};
26use super::rng::path_normals;
27
28/// Draw sampler. `Sobol` selects the low-discrepancy family: true Sobol
29/// (van der Corput) in one dimension, a scrambled multi-dimensional
30/// sequence through a Brownian bridge for path-wise simulation.
31/// `PseudoRandom` uses seeded per-path PCG64 streams with antithetic
32/// pairing.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Sampler {
35    Sobol,
36    PseudoRandom,
37}
38
39impl FromStr for Sampler {
40    type Err = String;
41    fn from_str(s: &str) -> Result<Self, Self::Err> {
42        match s.trim().to_lowercase().as_str() {
43            "sobol" | "quasi" => Ok(Sampler::Sobol),
44            "pseudo" | "pseudorandom" | "pseudo_random" => Ok(Sampler::PseudoRandom),
45            other => Err(format!("Invalid sampler '{other}'")),
46        }
47    }
48}
49
50/// Deterministic per-path Brownian increment source. Pseudo-random paths
51/// come in antithetic pairs (2k, 2k+1) from independent per-pair streams;
52/// low-discrepancy paths are sequence points routed through the Brownian
53/// bridge.
54pub enum PathDraws {
55    Pseudo { seed: u64, sqrt_dt: f64 },
56    Qmc { seq: QmcSequence, bridge: BrownianBridge },
57}
58
59impl PathDraws {
60    pub fn new(sampler: Sampler, seed: u64, steps: usize, dt: f64) -> Self {
61        match sampler {
62            Sampler::Sobol => PathDraws::Qmc {
63                seq: QmcSequence::new(steps, seed),
64                bridge: BrownianBridge::new(steps, dt),
65            },
66            Sampler::PseudoRandom => PathDraws::Pseudo { seed, sqrt_dt: dt.sqrt() },
67        }
68    }
69
70    pub fn pseudo(seed: u64, dt: f64) -> Self {
71        PathDraws::Pseudo { seed, sqrt_dt: dt.sqrt() }
72    }
73
74    /// Fill `dw` with the Brownian increments of path `index`; `z` and
75    /// `w` are caller-provided scratch of the same length.
76    pub fn fill(&self, index: usize, z: &mut [f64], w: &mut [f64], dw: &mut [f64]) {
77        match self {
78            PathDraws::Pseudo { seed, sqrt_dt } => {
79                path_normals(*seed, (index / 2) as u64, z);
80                let sign = if index % 2 == 0 { 1.0 } else { -1.0 };
81                for (d, zi) in dw.iter_mut().zip(z.iter()) {
82                    *d = sign * sqrt_dt * zi;
83                }
84            }
85            PathDraws::Qmc { seq, bridge } => {
86                seq.normals(index as u64 + 1, z);
87                bridge.increments(z, w, dw);
88            }
89        }
90    }
91}
92
93/// Sampling configuration shared by both entry points.
94#[derive(Debug, Clone, Copy)]
95pub struct SampleConfig {
96    /// Number of paths.
97    pub paths: usize,
98    /// Time steps per path (the grid is uniform over `horizon`).
99    pub steps: usize,
100    /// Path length in years.
101    pub horizon: f64,
102    pub sampler: Sampler,
103    pub seed: u64,
104}
105
106/// Simulated scalar paths in dense row-major storage: path `i` occupies
107/// `steps` consecutive values, excluding the initial state.
108pub struct Paths {
109    steps: usize,
110    data: Vec<f64>,
111}
112
113impl Paths {
114    pub fn n_paths(&self) -> usize {
115        self.data.len() / self.steps
116    }
117
118    pub fn steps(&self) -> usize {
119        self.steps
120    }
121
122    /// The levels of path `i`, one per step.
123    pub fn path(&self, i: usize) -> &[f64] {
124        &self.data[i * self.steps..(i + 1) * self.steps]
125    }
126
127    pub fn iter(&self) -> impl Iterator<Item = &[f64]> {
128        self.data.chunks(self.steps)
129    }
130}
131
132/// Simulated multi-state paths: path `i`, step `j` is a `dim`-long state
133/// vector at `data[(i * steps + j) * dim ..][..dim]`.
134pub struct MultiPaths {
135    steps: usize,
136    dim: usize,
137    data: Vec<f64>,
138}
139
140impl MultiPaths {
141    pub fn n_paths(&self) -> usize {
142        self.data.len() / (self.steps * self.dim)
143    }
144
145    pub fn steps(&self) -> usize {
146        self.steps
147    }
148
149    pub fn dim(&self) -> usize {
150        self.dim
151    }
152
153    /// Path `i` as `steps` consecutive `dim`-long state vectors.
154    pub fn path(&self, i: usize) -> &[f64] {
155        let stride = self.steps * self.dim;
156        &self.data[i * stride..(i + 1) * stride]
157    }
158
159    /// The state vector of path `i` at step `j`.
160    pub fn state(&self, i: usize, j: usize) -> &[f64] {
161        let at = (i * self.steps + j) * self.dim;
162        &self.data[at..at + self.dim]
163    }
164}
165
166/// Simulate paths of a scalar process from `x0` under the given
167/// discretization scheme.
168pub fn sample_paths_1d<P: StochasticProcess1D>(
169    process: &P,
170    x0: f64,
171    scheme: DiscretizationScheme,
172    cfg: &SampleConfig,
173) -> Paths {
174    let steps = cfg.steps.max(1);
175    let dt = cfg.horizon / steps as f64;
176    let draws = PathDraws::new(cfg.sampler, cfg.seed, steps, dt);
177    let mut data = vec![0.0; cfg.paths * steps];
178    data.par_chunks_mut(steps).enumerate().for_each_init(
179        || (vec![0.0; steps], vec![0.0; steps], vec![0.0; steps]),
180        |(z, w, dw), (i, out)| {
181            draws.fill(i, z, w, dw);
182            let mut x = x0;
183            for (j, d) in dw.iter().enumerate() {
184                x = process.evolve(scheme, j as f64 * dt, x, dt, *d);
185                out[j] = x;
186            }
187        },
188    );
189    Paths { steps, data }
190}
191
192/// Per-path factor draws for multi-state processes, step-major: the
193/// increments of step `j` occupy `dw[j * factors ..][..factors]`.
194/// Public so streaming multi-asset engines can consume draws without
195/// materializing a [`MultiPaths`] matrix.
196pub enum MultiDraws {
197    Pseudo { seed: u64, sqrt_dt: f64 },
198    /// One low-discrepancy point per path over `factors * steps`
199    /// coordinates; each factor's block runs through its own pass of the
200    /// Brownian bridge.
201    Qmc { seq: QmcSequence, bridge: BrownianBridge },
202}
203
204impl MultiDraws {
205    pub fn new(sampler: Sampler, seed: u64, factors: usize, steps: usize, dt: f64) -> Self {
206        match sampler {
207            Sampler::Sobol => MultiDraws::Qmc {
208                seq: QmcSequence::new(factors * steps, seed),
209                bridge: BrownianBridge::new(steps, dt),
210            },
211            Sampler::PseudoRandom => MultiDraws::Pseudo { seed, sqrt_dt: dt.sqrt() },
212        }
213    }
214
215    pub fn fill(
216        &self,
217        index: usize,
218        factors: usize,
219        steps: usize,
220        scratch: &mut FactorScratch,
221        dw: &mut [f64],
222    ) {
223        match self {
224            MultiDraws::Pseudo { seed, sqrt_dt } => {
225                path_normals(*seed, (index / 2) as u64, &mut scratch.z);
226                let sign = if index % 2 == 0 { 1.0 } else { -1.0 };
227                for (d, zi) in dw.iter_mut().zip(scratch.z.iter()) {
228                    *d = sign * sqrt_dt * zi;
229                }
230            }
231            MultiDraws::Qmc { seq, bridge } => {
232                seq.normals(index as u64 + 1, &mut scratch.z);
233                for f in 0..factors {
234                    bridge.increments(
235                        &scratch.z[f * steps..(f + 1) * steps],
236                        &mut scratch.w,
237                        &mut scratch.dwf,
238                    );
239                    for (j, d) in scratch.dwf.iter().enumerate() {
240                        dw[j * factors + f] = *d;
241                    }
242                }
243            }
244        }
245    }
246}
247
248/// Per-thread scratch for multi-factor draw generation.
249pub struct FactorScratch {
250    z: Vec<f64>,
251    w: Vec<f64>,
252    dwf: Vec<f64>,
253}
254
255impl FactorScratch {
256    pub fn new(factors: usize, steps: usize) -> Self {
257        FactorScratch {
258            z: vec![0.0; factors * steps],
259            w: vec![0.0; steps],
260            dwf: vec![0.0; steps],
261        }
262    }
263}
264
265/// Simulate paths of an N-state process from the state `x0`. The
266/// stepping scheme is the process's own [`evolve`](StochasticProcess::evolve)
267/// (multi-factor schemes are process-owned — e.g. Heston full-truncation
268/// or QE); `dw` carries independent increments, any factor correlation
269/// lives inside the process.
270pub fn sample_paths<P: StochasticProcess>(process: &P, x0: &[f64], cfg: &SampleConfig) -> MultiPaths {
271    let (dim, factors) = (process.dim(), process.factors());
272    assert_eq!(x0.len(), dim, "initial state must have process.dim() entries");
273    let steps = cfg.steps.max(1);
274    let dt = cfg.horizon / steps as f64;
275    let draws = MultiDraws::new(cfg.sampler, cfg.seed, factors, steps, dt);
276    let mut data = vec![0.0; cfg.paths * steps * dim];
277    data.par_chunks_mut(steps * dim).enumerate().for_each_init(
278        || {
279            (
280                FactorScratch::new(factors, steps),
281                vec![0.0; factors * steps], // step-major increments
282                vec![0.0; dim],             // x
283                vec![0.0; dim],             // x_next
284            )
285        },
286        |(scratch, dw, x, x_next), (i, out)| {
287            draws.fill(i, factors, steps, scratch, dw);
288            x.copy_from_slice(x0);
289            for j in 0..steps {
290                process.evolve(j as f64 * dt, x, dt, &dw[j * factors..(j + 1) * factors], x_next);
291                x.copy_from_slice(x_next);
292                out[j * dim..(j + 1) * dim].copy_from_slice(x);
293            }
294        },
295    );
296    MultiPaths { steps, dim, data }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    struct Gbm {
304        mu: f64,
305        sigma: f64,
306    }
307
308    impl StochasticProcess1D for Gbm {
309        fn drift(&self, _t: f64, x: f64) -> f64 {
310            self.mu * x
311        }
312        fn diffusion(&self, _t: f64, x: f64) -> f64 {
313            self.sigma * x
314        }
315        fn exact_step(&self, _t: f64, x: f64, dt: f64, dw: f64) -> Option<f64> {
316            Some(x * ((self.mu - 0.5 * self.sigma * self.sigma) * dt + self.sigma * dw).exp())
317        }
318    }
319
320    fn cfg(sampler: Sampler) -> SampleConfig {
321        SampleConfig { paths: 20_000, steps: 12, horizon: 1.0, sampler, seed: 7 }
322    }
323
324    #[test]
325    fn terminal_moments_match_the_lognormal_law() {
326        let p = Gbm { mu: 0.05, sigma: 0.2 };
327        for sampler in [Sampler::Sobol, Sampler::PseudoRandom] {
328            let paths = sample_paths_1d(&p, 100.0, DiscretizationScheme::Exact, &cfg(sampler));
329            let n = paths.n_paths() as f64;
330            let mean: f64 = paths.iter().map(|path| path[path.len() - 1]).sum::<f64>() / n;
331            let log_var: f64 = paths
332                .iter()
333                .map(|path| {
334                    let l = (path[path.len() - 1] / 100.0).ln();
335                    (l - (0.05 - 0.02)) * (l - (0.05 - 0.02))
336                })
337                .sum::<f64>()
338                / n;
339            let target = 100.0 * (0.05_f64).exp();
340            assert!((mean - target).abs() / target < 0.01, "{sampler:?}: mean {mean}");
341            assert!((log_var - 0.04).abs() / 0.04 < 0.05, "{sampler:?}: log-var {log_var}");
342        }
343    }
344
345    #[test]
346    fn pseudo_paths_come_in_antithetic_pairs() {
347        // under the exact GBM step, mirrored increments mirror the log
348        // path around the deterministic drift
349        let p = Gbm { mu: 0.05, sigma: 0.2 };
350        let paths = sample_paths_1d(&p, 100.0, DiscretizationScheme::Exact, &cfg(Sampler::PseudoRandom));
351        let dt = 1.0 / 12.0;
352        for j in 0..paths.steps() {
353            let drift = (0.05 - 0.02) * dt * (j + 1) as f64;
354            let sum_logs = (paths.path(0)[j] / 100.0).ln() + (paths.path(1)[j] / 100.0).ln();
355            assert!((sum_logs - 2.0 * drift).abs() < 1e-12, "step {j}");
356        }
357    }
358
359    #[test]
360    fn same_seed_reproduces_and_different_seed_differs() {
361        let p = Gbm { mu: 0.02, sigma: 0.3 };
362        let a = sample_paths_1d(&p, 50.0, DiscretizationScheme::Exact, &cfg(Sampler::Sobol));
363        let b = sample_paths_1d(&p, 50.0, DiscretizationScheme::Exact, &cfg(Sampler::Sobol));
364        assert_eq!(a.path(123), b.path(123));
365        let other = SampleConfig { seed: 8, ..cfg(Sampler::Sobol) };
366        let c = sample_paths_1d(&p, 50.0, DiscretizationScheme::Exact, &other);
367        assert_ne!(a.path(123), c.path(123));
368    }
369
370    /// Two independent log-normal assets as one 2-state, 2-factor process.
371    struct TwoGbm;
372
373    impl StochasticProcess for TwoGbm {
374        fn dim(&self) -> usize {
375            2
376        }
377        fn factors(&self) -> usize {
378            2
379        }
380        fn drift(&self, _t: f64, x: &[f64], out: &mut [f64]) {
381            out[0] = 0.05 * x[0];
382            out[1] = 0.01 * x[1];
383        }
384        fn diffusion(&self, _t: f64, x: &[f64], out: &mut [f64]) {
385            out.copy_from_slice(&[0.2 * x[0], 0.0, 0.0, 0.3 * x[1]]);
386        }
387    }
388
389    #[test]
390    fn multi_state_terminal_means_track_their_drifts() {
391        for sampler in [Sampler::Sobol, Sampler::PseudoRandom] {
392            let cfg = SampleConfig { paths: 40_000, steps: 50, horizon: 1.0, sampler, seed: 3 };
393            let paths = sample_paths(&TwoGbm, &[100.0, 200.0], &cfg);
394            assert_eq!((paths.n_paths(), paths.steps(), paths.dim()), (40_000, 50, 2));
395            let n = paths.n_paths() as f64;
396            let (mut m0, mut m1) = (0.0, 0.0);
397            for i in 0..paths.n_paths() {
398                let last = paths.state(i, paths.steps() - 1);
399                m0 += last[0];
400                m1 += last[1];
401            }
402            let (t0, t1) = (100.0 * (0.05_f64).exp(), 200.0 * (0.01_f64).exp());
403            // Euler at 50 steps: discretization bias well under the noise floor
404            assert!((m0 / n - t0).abs() / t0 < 0.01, "{sampler:?}: {m0}");
405            assert!((m1 / n - t1).abs() / t1 < 0.01, "{sampler:?}: {m1}");
406        }
407    }
408}