Skip to main content

market_model/
runner.rs

1use crate::simulatable::Simulatable;
2use crate::types::{SimulationConfig, SimulationResult};
3use rand::rngs::StdRng;
4use rand::{Rng, SeedableRng};
5use rand_distr::StandardNormal;
6use rayon::prelude::*;
7
8/// Parallel batch simulation runner.
9///
10/// Generates all random numbers upfront (no RNG in the hot loop),
11/// steps paths in parallel via `rayon`, and stores results in SOA layout.
12///
13/// The model is cloned for each path, so implementations must be `Clone`.
14pub struct SimulationRunner<S: Simulatable> {
15    model: S,
16    config: SimulationConfig,
17}
18
19impl<S: Simulatable> SimulationRunner<S> {
20    pub fn new(model: S, config: SimulationConfig) -> Self {
21        assert!(config.n_paths > 0, "n_paths must be positive");
22        assert!(config.n_steps > 0, "n_steps must be positive");
23        assert!(config.dt > 0.0, "dt must be positive");
24        Self { model, config }
25    }
26
27    /// Run the simulation and return results in SOA layout.
28    pub fn run(&self) -> SimulationResult<S::Output> {
29        let n = self.config.n_paths;
30        let steps = self.config.n_steps;
31        let dt = self.config.dt;
32        let dim = self.model.dim();
33
34        // Pre-generate all random normals: (n_paths * n_steps * dim) values.
35        let total_randoms = n * steps * dim;
36        let mut randoms = Vec::with_capacity(total_randoms);
37        let mut rng = StdRng::seed_from_u64(self.config.seed);
38        for _ in 0..total_randoms {
39            randoms.push(rng.sample::<f64, _>(StandardNormal) * dt.sqrt());
40        }
41
42        // Each path gets its own model clone and RNG.
43        let outputs: Vec<S::Output> = (0..n)
44            .into_par_iter()
45            .flat_map(|path_idx| {
46                let mut model = self.model.clone();
47                let mut path_rng = StdRng::seed_from_u64(self.config.seed + path_idx as u64 + 1);
48                let initial = model.current();
49                let mut path_outputs = Vec::with_capacity(steps);
50                path_outputs.push(initial);
51                for step in 0..steps {
52                    let offset = path_idx * steps * dim + step * dim;
53                    let dw = &randoms[offset..offset + dim];
54                    let out = model.step(dt, dw, &mut path_rng);
55                    path_outputs.push(out);
56                }
57                path_outputs
58            })
59            .collect();
60
61        SimulationResult {
62            outputs,
63            n_paths: n,
64            n_steps: steps,
65        }
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use crate::simulatable::Simulatable;
73
74    /// Minimal model for testing.
75    #[derive(Clone)]
76    struct CountingModel {
77        dim: usize,
78        count: f64,
79    }
80
81    impl Simulatable for CountingModel {
82        type Output = f64;
83
84        fn dim(&self) -> usize {
85            self.dim
86        }
87
88        fn step(&mut self, _dt: f64, _dw: &[f64], _rng: &mut impl Rng) -> f64 {
89            self.count += 1.0;
90            self.count
91        }
92
93        fn current(&self) -> f64 {
94            self.count
95        }
96    }
97
98    #[test]
99    fn test_runner_output_shape() {
100        let model = CountingModel { dim: 1, count: 0.0 };
101        let config = SimulationConfig {
102            n_paths: 10,
103            n_steps: 5,
104            dt: 1.0 / 252.0,
105            seed: 42,
106        };
107        let runner = SimulationRunner::new(model, config);
108        let result = runner.run();
109        assert_eq!(result.n_paths, 10);
110        assert_eq!(result.n_steps, 5);
111        // Each path produces: initial (0) + 5 steps = 6 outputs
112        assert_eq!(result.outputs.len(), 10 * 6);
113    }
114
115    #[test]
116    #[should_panic(expected = "n_paths must be positive")]
117    fn test_runner_rejects_zero_paths() {
118        let model = CountingModel { dim: 1, count: 0.0 };
119        let config = SimulationConfig {
120            n_paths: 0,
121            n_steps: 100,
122            dt: 1.0 / 252.0,
123            seed: 42,
124        };
125        SimulationRunner::new(model, config);
126    }
127
128    #[test]
129    fn test_result_indexing() {
130        let model = CountingModel { dim: 1, count: 0.0 };
131        let config = SimulationConfig {
132            n_paths: 3,
133            n_steps: 2,
134            dt: 0.1,
135            seed: 7,
136        };
137        let runner = SimulationRunner::new(model, config);
138        let result = runner.run();
139
140        // 3 paths x 3 entries each (initial + 2 steps) = 9
141        assert_eq!(result.outputs.len(), 9);
142
143        assert!(result.get(0, 0).is_some());
144        assert!(result.get(2, 1).is_some());
145        assert!(result.get(3, 0).is_none());
146        // n_steps=2, so entries 0,1,2 are valid; entry 3 is out of bounds
147        assert!(result.get(0, 3).is_none());
148
149        let path0 = result.path(0).unwrap();
150        assert_eq!(path0.len(), 3);
151        assert!(result.path(3).is_none());
152    }
153}