Skip to main content

optionstratlib_simulation/simulation/
ou.rs

1//! The Ornstein-Uhlenbeck path generator.
2//!
3//! A mean-reverting path is a simulation kernel, not a volatility model: it
4//! reports [`crate::error::SimulationError`] and names nothing from the
5//! pricing layer. `volatility::generate_ou_process` re-exports it, so the
6//! 0.21 path is unchanged.
7
8use crate::error::SimulationError;
9use crate::simulation::path_buffer;
10use optionstratlib_core::model::Positive;
11use optionstratlib_core::model::decimal::{d_add, d_mul, decimal_normal_sample_with, p_sqrt};
12use optionstratlib_core::model::utils::sub_floor_zero;
13use rand::Rng;
14use rust_decimal::Decimal;
15
16/// Generates a mean-reverting Ornstein-Uhlenbeck process time series
17///
18/// This function simulates a discrete-time Ornstein-Uhlenbeck stochastic process, which is
19/// commonly used in financial mathematics to model mean-reverting processes such as interest rates,
20/// volatility, or commodity prices. The process follows the stochastic differential equation:
21///
22/// dX_t = θ(μ - X_t)dt + σdW_t
23///
24/// Where:
25/// - θ (theta) is the speed of reversion to the mean
26/// - μ (mu) is the long-term mean level
27/// - σ (sigma) is the volatility or intensity of random fluctuations
28/// - W_t is a Wiener process (standard Brownian motion)
29///
30/// # Arguments
31/// * `x0` - Initial value of the process
32/// * `mu` - Long-term mean the process reverts to
33/// * `theta` - Speed of mean reversion (higher values cause faster reversion)
34/// * `sigma` - Volatility parameter controlling the intensity of random fluctuations
35/// * `dt` - Time step size for the simulation
36/// * `steps` - Number of time steps to simulate
37///
38/// # Returns
39/// A vector containing the simulated values of the Ornstein-Uhlenbeck process at each time step
40///
41/// # Randomness
42///
43/// The normal increments come from the thread RNG, so two calls differ. A
44/// reproducible OU path is a [`crate::simulation::WalkType::MeanReverting`]
45/// walk with [`crate::simulation::WalkParams::seed`] set.
46///
47/// # Errors
48///
49/// Returns [`SimulationError::PositiveError`] when `sqrt(dt)` or the mean
50/// reversion term `theta * (mu - x) * dt` leaves the representable `Positive`
51/// range, and [`SimulationError::Decimal`] when the diffusion term or the
52/// accumulated level leaves the representable `Decimal` range.
53/// Returns [`SimulationError::InvalidParameters`] when a path of `steps`
54/// points cannot be allocated.
55///
56/// There is no limit to return in their place: the process level is the state
57/// being simulated, so a step that cannot be represented ends the path rather
58/// than continuing from a stand-in value.
59///
60/// # Examples
61///
62/// ```rust
63/// use rust_decimal_macros::dec;
64/// use optionstratlib_core::model::Positive;
65/// use optionstratlib_core::pos_or_panic;
66/// use optionstratlib_simulation::simulation::ou::generate_ou_process;
67///
68/// # fn main() -> Result<(), optionstratlib_simulation::error::SimulationError> {
69/// // Simulate an OU process with initial value 1.0, mean 1.5,
70/// // reversion speed 0.1, volatility 0.2, time step 0.01, for 1000 steps
71/// let process = generate_ou_process(
72///     Positive::ONE,       // initial value
73///     pos_or_panic!(1.5),       // long-term mean
74///     pos_or_panic!(0.1),       // speed of reversion
75///     pos_or_panic!(0.2),       // volatility
76///     pos_or_panic!(0.01),      // time step
77///     1000             // number of steps
78/// )?;
79/// # Ok(())
80/// # }
81/// ```
82pub fn generate_ou_process(
83    x0: Positive,
84    mu: Positive,
85    theta: Positive,
86    volatility: Positive,
87    dt: Positive,
88    steps: usize,
89) -> Result<Vec<Positive>, SimulationError> {
90    ou_path(x0, mu, theta, volatility, dt, steps, &mut rand::rng())
91}
92
93/// Body of [`generate_ou_process`], drawing its normal samples from `rng`.
94///
95/// The seeded walk kernels (`MeanReverting`, and the volatility path of
96/// `Custom`) call it with the walk's generator so the whole path comes from
97/// one stream.
98///
99/// # Errors
100///
101/// Same as [`generate_ou_process`].
102pub(crate) fn ou_path<R: Rng + ?Sized>(
103    x0: Positive,
104    mu: Positive,
105    theta: Positive,
106    volatility: Positive,
107    dt: Positive,
108    steps: usize,
109    rng: &mut R,
110) -> Result<Vec<Positive>, SimulationError> {
111    let sqrt_dt = p_sqrt(&dt, "volatility::utils::generate_ou_process")?;
112    let mut x = x0.to_dec();
113    let mut result = path_buffer(steps, 0)?;
114    result.push(Positive::new_decimal(x).unwrap_or(Positive::ZERO));
115
116    for _ in 1..steps {
117        // Z√dt
118        let dw = d_mul(
119            decimal_normal_sample_with(rng),
120            sqrt_dt.to_dec(),
121            "volatility::ou::dw",
122        )?;
123        // θ(μ−x)dt
124        let drift = theta
125            .checked_mul(&sub_floor_zero(mu, &x))?
126            .checked_mul(&dt)?
127            .to_dec();
128        // σ·Z√dt (Decimal)
129        let diffusion = d_mul(dw, volatility.to_dec(), "volatility::ou::diffusion")?;
130        // OU process step
131        x = d_add(
132            x,
133            d_add(drift, diffusion, "volatility::ou::step")?,
134            "volatility::ou::step",
135        )?;
136        x = x.max(Decimal::ZERO); // no negative values
137        result.push(Positive::new_decimal(x).unwrap_or(Positive::ZERO));
138    }
139
140    Ok(result)
141}
142
143#[cfg(test)]
144mod tests_generate_ou_process {
145    use super::*;
146    use optionstratlib_core::pos_or_panic;
147    use optionstratlib_core::utils::deterministic_rng;
148    use rust_decimal_macros::dec;
149
150    /// Seed of the simulated paths, so every run draws the same stream.
151    const SEED: u64 = 685;
152
153    #[test]
154    fn test_process_length() {
155        let steps = 500;
156        // Unseeded on purpose: covers the thread-RNG entry point, whose length is stream-independent.
157        let process = generate_ou_process(
158            Positive::ONE,
159            pos_or_panic!(1.5),
160            pos_or_panic!(0.1),
161            pos_or_panic!(0.2),
162            pos_or_panic!(0.01),
163            steps,
164        )
165        .unwrap();
166        assert_eq!(process.len(), steps);
167    }
168
169    #[test]
170    fn test_all_values_positive() {
171        let process = ou_path(
172            Positive::ONE,
173            pos_or_panic!(1.5),
174            pos_or_panic!(0.2),
175            pos_or_panic!(0.3),
176            pos_or_panic!(0.01),
177            1000,
178            &mut deterministic_rng(SEED),
179        )
180        .unwrap();
181
182        for value in process {
183            assert!(
184                value >= Positive::ZERO,
185                "Found non-positive value: {value:?}"
186            );
187        }
188    }
189
190    #[test]
191    fn test_mean_reversion_tendency() {
192        let process = ou_path(
193            pos_or_panic!(0.1),
194            Positive::ONE,
195            Positive::ONE,       // high theta for fast reversion
196            pos_or_panic!(0.01), // low volatility
197            pos_or_panic!(0.01),
198            1000,
199            &mut deterministic_rng(SEED),
200        )
201        .unwrap();
202
203        let last = process.last().unwrap().to_dec();
204        let diff = (last - dec!(1.0)).abs();
205        assert!(diff < dec!(0.1), "Final value too far from mean: {last}");
206    }
207}