optionstratlib_simulation/simulation/traits.rs
1use crate::error::SimulationError;
2use crate::simulation::model::WalkPath;
3use crate::simulation::ou::ou_path;
4use crate::simulation::{WalkParams, WalkType, path_buffer};
5use optionstratlib_core::model::Positive;
6use optionstratlib_core::model::decimal::{
7 d_add, d_div, d_exp, d_exp_f64, d_mul, d_sqrt, d_sqrt_f64, d_sub, decimal_normal_sample_with,
8 decimal_to_f64, decimal_uniform_sample_with, finite_decimal, p_sqrt,
9};
10use optionstratlib_core::utils::deterministic_rng;
11use rand::Rng;
12use rust_decimal::Decimal;
13use std::convert::TryInto;
14use std::fmt::{Debug, Display};
15use std::ops::AddAssign;
16
17/// Runs a built-in walk kernel on the random stream `params.seed` selects.
18///
19/// `Some(seed)` draws from a fresh [`deterministic_rng`]`(seed)`, one per
20/// generated path, so a seeded path is reproducible bit for bit. `None`
21/// draws from the thread RNG, exactly the stream the unseeded
22/// `decimal_normal_sample()` reads: the same generator, advanced by the same
23/// draws in the same order. Each kernel is monomorphised for both
24/// generators, so the inner loop makes no dynamic call.
25macro_rules! with_walk_rng {
26 ($params:expr, $kernel:ident) => {
27 match $params.seed {
28 Some(seed) => $kernel($params, &mut deterministic_rng(seed)),
29 None => $kernel($params, &mut rand::rng()),
30 }
31 };
32}
33
34/// Built-in GARCH(1,1) walk kernel: simulates the price path together with
35/// the volatility path that drove it.
36///
37/// This is the shared implementation behind the default
38/// [`WalkTypeAble::garch`] and [`WalkTypeAble::garch_with_vol`] methods, exposed
39/// publicly so custom walkers overriding one of them can compose or wrap
40/// the built-in dynamics instead of reimplementing them.
41///
42/// # Errors
43///
44/// Same as [`WalkTypeAble::garch`].
45pub fn garch_walk<X, Y>(params: &WalkParams<X, Y>) -> Result<WalkPath, SimulationError>
46where
47 X: Copy + TryInto<Positive> + AddAssign + Display,
48 Y: TryInto<Positive> + Display + Clone,
49{
50 with_walk_rng!(params, garch_path)
51}
52
53/// Body of [`garch_walk`], drawing its normal samples from `rng`.
54fn garch_path<X, Y, R>(params: &WalkParams<X, Y>, rng: &mut R) -> Result<WalkPath, SimulationError>
55where
56 X: Copy + TryInto<Positive> + AddAssign + Display,
57 Y: TryInto<Positive> + Display + Clone,
58 R: Rng + ?Sized,
59{
60 match params.walk_type {
61 WalkType::Garch {
62 dt,
63 drift,
64 volatility,
65 alpha,
66 beta,
67 } => {
68 // `alpha + beta` is the stationarity test itself, so it has to be
69 // formed before it can be compared; at `alpha = beta = MAX` the
70 // raw operator aborts instead of reporting the violation.
71 let persistence = alpha.checked_add(&beta)?;
72 if persistence >= Decimal::ONE {
73 return Err(SimulationError::GarchStationarity { alpha, beta });
74 }
75
76 let mut path = path_buffer(params.size, 1)?;
77 let mut vols = path_buffer(params.size, 1)?;
78 let mut price = params.ystep_as_positive()?.to_dec();
79 path.push(Positive::new_decimal(price).unwrap_or(Positive::ZERO));
80 vols.push(volatility);
81
82 // --- initial conditional variance (annualised) ---
83 let mut var = volatility.checked_mul(&volatility)?; // σ₀²
84 let mut prev_eps2 = Decimal::ZERO;
85 // `1 - alpha - beta` is strictly positive by the stationarity
86 // check above; the subtraction order is preserved so the
87 // long-run variance is bit-identical to the previous form.
88 let stationary_weight = d_sub(
89 d_sub(Decimal::ONE, alpha.to_dec(), "simulation::garch::omega")?,
90 beta.to_dec(),
91 "simulation::garch::omega",
92 )?;
93 let omega = volatility
94 .checked_powu(2)?
95 .checked_mul_dec(stationary_weight)?; // 0.002
96
97 // pre-compute √dt
98 let sqrt_dt = decimal_to_f64(dt.to_dec())?.sqrt(); // scan-banned: allow -- f64 `sqrt`: returns NaN for negative input, it does not abort; the non-finite value is rejected at the `Decimal` boundary
99 let sqrt_dt_dec = finite_decimal(sqrt_dt).ok_or_else(|| {
100 SimulationError::non_finite("simulation::garch::sqrt_dt", sqrt_dt)
101 })?;
102
103 for _ in 1..params.size {
104 // 1) update variance
105 var = omega
106 .checked_add(&alpha.checked_mul_dec(prev_eps2)?)?
107 .checked_add(&beta.checked_mul(&var)?)?;
108
109 // 2) shock with the right scale σ√dt·Z
110 let z = decimal_normal_sample_with(rng);
111 // `var` is kept in annualized-squared units, so sqrt is
112 // the annualized conditional volatility at this step; it
113 // feeds both the shock and the reported vol path.
114 // `f64` square root (#860): correctly rounded on every platform.
115 let var_sqrt = Positive::new_decimal(d_sqrt_f64(
116 var.to_dec(),
117 "simulation::traits::garch_walk",
118 )?)?;
119 let eps = d_mul(
120 d_mul(z, var_sqrt.to_dec(), "simulation::garch::eps")?,
121 sqrt_dt_dec,
122 "simulation::garch::eps",
123 )?; // εₜ
124
125 // 3) drift (use μ dt, or μ dt − ½σ² dt if μ is arithmetic)
126 let ret = d_add(
127 d_mul(drift, dt.to_dec(), "simulation::garch::ret")?,
128 eps,
129 "simulation::garch::ret",
130 )?;
131
132 // 4) price update
133 price = d_mul(
134 price,
135 d_exp_f64(ret, "simulation::garch::price")?,
136 "simulation::garch::price",
137 )?;
138 path.push(Positive::new_decimal(price).unwrap_or(Positive::ZERO));
139 vols.push(var_sqrt);
140
141 // 5) store ε² (`powu(2)` is exactly `eps * eps`)
142 prev_eps2 = d_mul(eps, eps, "simulation::garch::eps_squared")?; // εₜ²
143 }
144 Ok(WalkPath {
145 prices: path,
146 vols: Some(vols),
147 })
148 }
149 _ => Err(SimulationError::InvalidWalkType { expected: "GARCH" }),
150 }
151}
152
153/// Built-in Heston walk kernel: simulates the price path together with
154/// the volatility path that drove it.
155///
156/// This is the shared implementation behind the default
157/// [`WalkTypeAble::heston`] and [`WalkTypeAble::heston_with_vol`] methods, exposed
158/// publicly so custom walkers overriding one of them can compose or wrap
159/// the built-in dynamics instead of reimplementing them.
160///
161/// # Errors
162///
163/// Same as [`WalkTypeAble::heston`].
164pub fn heston_walk<X, Y>(params: &WalkParams<X, Y>) -> Result<WalkPath, SimulationError>
165where
166 X: Copy + TryInto<Positive> + AddAssign + Display,
167 Y: TryInto<Positive> + Display + Clone,
168{
169 with_walk_rng!(params, heston_path)
170}
171
172/// Body of [`heston_walk`], drawing its normal samples from `rng`.
173fn heston_path<X, Y, R>(params: &WalkParams<X, Y>, rng: &mut R) -> Result<WalkPath, SimulationError>
174where
175 X: Copy + TryInto<Positive> + AddAssign + Display,
176 Y: TryInto<Positive> + Display + Clone,
177 R: Rng + ?Sized,
178{
179 match params.walk_type {
180 WalkType::Heston {
181 dt,
182 drift,
183 volatility,
184 kappa,
185 theta,
186 xi,
187 rho,
188 } => {
189 // Validate parameters
190 if rho < -Decimal::ONE || rho > Decimal::ONE {
191 return Err(SimulationError::InvalidCorrelation { rho });
192 }
193
194 let mut values = path_buffer(params.size, 0)?;
195 let mut vols = path_buffer(params.size, 0)?;
196 let mut price: Positive = params.ystep_as_positive()?;
197
198 // Initial variance is the square of initial volatility
199 let mut variance = d_mul(
200 volatility.to_dec(),
201 volatility.to_dec(),
202 "simulation::heston::variance0",
203 )?;
204
205 values.push(price); // Add initial value
206 vols.push(volatility);
207
208 let dt_sqrt = d_sqrt(dt.to_dec(), "simulation::heston::sqrt_dt")
209 .map_err(|_| SimulationError::walk_error("Heston: sqrt(dt) failed (overflow)"))?;
210 // sqrt(1 - rho^2) depends only on `rho`, hoist out of the
211 // hot loop so we don't recompute it per step.
212 let one_minus_rho_sq = d_sub(
213 Decimal::ONE,
214 d_mul(rho, rho, "simulation::heston::one_minus_rho_sq")?,
215 "simulation::heston::one_minus_rho_sq",
216 )?;
217 let one_minus_rho_sq_sqrt = d_sqrt(
218 one_minus_rho_sq,
219 "simulation::heston::one_minus_rho_sq_sqrt",
220 )
221 .map_err(|_| {
222 SimulationError::walk_error(
223 "Heston: sqrt(1 - rho^2) failed (rho out of range or overflow)",
224 )
225 })?;
226 // A zero-step walk has nothing to simulate past the seeded
227 // initial value; `size - 1` underflows there and, in release,
228 // turns the loop bound into `usize::MAX`. `size` is a step count,
229 // not a financial value, so the range below is the loop bound
230 // rather than a hidden clamp on a price.
231 // `1..size` is empty for a zero size and has `size - 1` items otherwise.
232 for _ in 1..params.size {
233 // Generate correlated random numbers
234 let z1 = decimal_normal_sample_with(rng);
235 let z2 = d_add(
236 d_mul(rho, z1, "simulation::heston::z2")?,
237 d_mul(
238 one_minus_rho_sq_sqrt,
239 decimal_normal_sample_with(rng),
240 "simulation::heston::z2",
241 )?,
242 "simulation::heston::z2",
243 )?;
244
245 // Ensure variance stays positive (modified Euler scheme with truncation)
246 let variance_sqrt = d_sqrt_f64(variance, "simulation::heston::variance_sqrt")
247 .map_err(|_| {
248 SimulationError::walk_error("Heston: sqrt(variance) failed (overflow)")
249 })?;
250 let variance_drift = d_mul(
251 d_mul(
252 kappa.to_dec(),
253 d_sub(theta.to_dec(), variance, "simulation::heston::variance_gap")?,
254 "simulation::heston::variance_drift",
255 )?,
256 dt.to_dec(),
257 "simulation::heston::variance_drift",
258 )?;
259 let variance_diffusion = d_mul(
260 d_mul(
261 d_mul(
262 xi.to_dec(),
263 variance_sqrt,
264 "simulation::heston::variance_diffusion",
265 )?,
266 z2,
267 "simulation::heston::variance_diffusion",
268 )?,
269 dt_sqrt,
270 "simulation::heston::variance_diffusion",
271 )?;
272 let variance_new = d_add(
273 d_add(variance, variance_drift, "simulation::heston::variance_new")?,
274 variance_diffusion,
275 "simulation::heston::variance_new",
276 )?
277 .max(Decimal::ZERO);
278
279 // Update price using the average variance over the step
280 let avg_variance = d_div(
281 d_add(variance, variance_new, "simulation::heston::avg_variance")?,
282 Decimal::TWO,
283 "simulation::heston::avg_variance",
284 )?;
285 let avg_variance_sqrt = d_sqrt_f64(
286 avg_variance,
287 "simulation::heston::avg_variance_sqrt",
288 )
289 .map_err(|_| {
290 SimulationError::walk_error("Heston: sqrt(avg_variance) failed (overflow)")
291 })?;
292 let price_change = d_add(
293 d_mul(drift, dt.to_dec(), "simulation::heston::price_change")?,
294 d_mul(
295 d_mul(avg_variance_sqrt, z1, "simulation::heston::price_change")?,
296 dt_sqrt,
297 "simulation::heston::price_change",
298 )?,
299 "simulation::heston::price_change",
300 )?;
301
302 price =
303 price.checked_mul_dec(d_exp_f64(price_change, "simulation::heston::price")?)?;
304 variance = variance_new;
305
306 values.push(price);
307 // Instantaneous annualized volatility after the step;
308 // CIR variance is truncated at zero so sqrt always exists.
309 let vol_step =
310 d_sqrt_f64(variance, "simulation::heston::vol_step").map_err(|_| {
311 SimulationError::walk_error("Heston: sqrt(variance) failed (overflow)")
312 })?;
313 vols.push(Positive::new_decimal(vol_step).unwrap_or(Positive::ZERO));
314 }
315
316 Ok(WalkPath {
317 prices: values,
318 vols: Some(vols),
319 })
320 }
321 _ => Err(SimulationError::InvalidWalkType { expected: "Heston" }),
322 }
323}
324
325/// Built-in Custom (mean-reverting volatility) walk kernel: simulates the price path together with
326/// the volatility path that drove it.
327///
328/// This is the shared implementation behind the default
329/// [`WalkTypeAble::custom`] and [`WalkTypeAble::custom_with_vol`] methods, exposed
330/// publicly so custom walkers overriding one of them can compose or wrap
331/// the built-in dynamics instead of reimplementing them.
332///
333/// # Errors
334///
335/// Same as [`WalkTypeAble::custom`].
336pub fn custom_walk<X, Y>(params: &WalkParams<X, Y>) -> Result<WalkPath, SimulationError>
337where
338 X: Copy + TryInto<Positive> + AddAssign + Display,
339 Y: TryInto<Positive> + Display + Clone,
340{
341 with_walk_rng!(params, custom_path)
342}
343
344/// Body of [`custom_walk`], drawing its normal samples from `rng`.
345fn custom_path<X, Y, R>(params: &WalkParams<X, Y>, rng: &mut R) -> Result<WalkPath, SimulationError>
346where
347 X: Copy + TryInto<Positive> + AddAssign + Display,
348 Y: TryInto<Positive> + Display + Clone,
349 R: Rng + ?Sized,
350{
351 match params.walk_type {
352 WalkType::Custom {
353 dt,
354 drift,
355 volatility,
356 vov,
357 vol_speed,
358 vol_mean,
359 } => {
360 let vols = ou_path(volatility, vol_mean, vol_speed, vov, dt, params.size, rng)?;
361
362 let sqrt_dt = p_sqrt(&dt, "simulation::traits::custom_walk")?;
363 let mut price = params.ystep_as_positive()?.to_dec();
364 let mut path = path_buffer(params.size, 1)?;
365 let mut vols_out = path_buffer(params.size, 1)?;
366 path.push(Positive::new_decimal(price).unwrap_or(Positive::ZERO));
367 vols_out.push(volatility);
368
369 // A zero-step walk has nothing to simulate past the seeded
370 // initial value; `size - 1` underflows there. `size` is a step
371 // count, not a financial value.
372 for (&vol, _) in vols.iter().zip(1..params.size) {
373 let z = decimal_normal_sample_with(rng);
374 let sigma_abs = d_mul(vol.to_dec(), price, "simulation::custom::sigma_abs")?;
375 let random_step = d_mul(
376 d_mul(z, sigma_abs, "simulation::custom::random_step")?,
377 sqrt_dt.to_dec(),
378 "simulation::custom::random_step",
379 )?;
380
381 price = d_add(
382 price,
383 d_add(
384 d_mul(drift, dt.to_dec(), "simulation::custom::price")?,
385 random_step,
386 "simulation::custom::price",
387 )?,
388 "simulation::custom::price",
389 )?;
390 path.push(
391 Positive::new_decimal(price.max(Decimal::ZERO)).unwrap_or(Positive::ZERO),
392 );
393 // The OU volatility that generated this step.
394 vols_out.push(vol);
395 }
396
397 Ok(WalkPath {
398 prices: path,
399 vols: Some(vols_out),
400 })
401 }
402 _ => Err(SimulationError::InvalidWalkType { expected: "Custom" }),
403 }
404}
405
406/// Built-in Telegraph (two-state regime switching) walk kernel: simulates the price path together with
407/// the volatility path that drove it.
408///
409/// This is the shared implementation behind the default
410/// [`WalkTypeAble::telegraph`] and [`WalkTypeAble::telegraph_with_vol`] methods, exposed
411/// publicly so custom walkers overriding one of them can compose or wrap
412/// the built-in dynamics instead of reimplementing them.
413///
414/// # Errors
415///
416/// Same as [`WalkTypeAble::telegraph`].
417pub fn telegraph_walk<X, Y>(params: &WalkParams<X, Y>) -> Result<WalkPath, SimulationError>
418where
419 X: Copy + TryInto<Positive> + AddAssign + Display,
420 Y: TryInto<Positive> + Display + Clone,
421{
422 with_walk_rng!(params, telegraph_path)
423}
424
425/// Body of [`telegraph_walk`], drawing its normal and uniform samples from
426/// `rng`.
427///
428/// The initial regime takes the sign of one normal draw; each step then
429/// draws the uniform of the regime-switch trial, then the price normal.
430fn telegraph_path<X, Y, R>(
431 params: &WalkParams<X, Y>,
432 rng: &mut R,
433) -> Result<WalkPath, SimulationError>
434where
435 X: Copy + TryInto<Positive> + AddAssign + Display,
436 Y: TryInto<Positive> + Display + Clone,
437 R: Rng + ?Sized,
438{
439 match params.walk_type {
440 WalkType::Telegraph {
441 dt,
442 drift,
443 volatility,
444 lambda_up,
445 lambda_down,
446 vol_multiplier_up,
447 vol_multiplier_down,
448 } => {
449 let mut values = path_buffer(params.size, 0)?;
450 let mut vols = path_buffer(params.size, 0)?;
451 let mut price = params.ystep_as_positive()?.to_dec();
452 values.push(Positive::new_decimal(price).unwrap_or(Positive::ZERO));
453 vols.push(volatility);
454
455 // Initialize telegraph state randomly
456 // The sign of the draw, read on the `Decimal` itself (#828): no
457 // conversion, so no failed conversion to read as zero.
458 let mut state: i8 = if decimal_normal_sample_with(rng) < Decimal::ZERO {
459 1
460 } else {
461 -1
462 };
463
464 let sqrt_dt = p_sqrt(&dt, "simulation::traits::telegraph_walk")?;
465 let vol_mult_up = vol_multiplier_up.unwrap_or(Positive::ONE);
466 let vol_mult_down = vol_multiplier_down.unwrap_or(Positive::ONE);
467
468 // `lambda` is non-negative, so the exponent is non-positive; an
469 // arbitrarily fast transition rate flushes `e^x` to zero, which
470 // is the limit `transition_prob -> 1`. It depends on the state
471 // only, so both are computed once per path instead of every step
472 // (#860), with the same operations.
473 let transition_probability = |lambda: Decimal| {
474 d_sub(
475 Decimal::ONE,
476 d_exp(
477 d_mul(-lambda, dt.to_dec(), "simulation::telegraph::transition")?,
478 "simulation::telegraph::transition",
479 )?,
480 "simulation::telegraph::transition",
481 )
482 };
483 let leave_up = transition_probability(lambda_down.to_dec())?;
484 let leave_down = transition_probability(lambda_up.to_dec())?;
485
486 for _ in 1..params.size {
487 let transition_prob = if state == 1 { leave_up } else { leave_down };
488
489 // Bernoulli trial on a genuine U(0,1) draw (#683):
490 // `P(switch) = 1 - e^(-λ·dt)`.
491 if decimal_uniform_sample_with(rng) < transition_prob {
492 state *= -1;
493 }
494
495 // Apply volatility multiplier based on current state
496 let current_vol = if state == 1 {
497 volatility.checked_mul(&vol_mult_up)?
498 } else {
499 volatility.checked_mul(&vol_mult_down)?
500 };
501
502 // Generate price change
503 let z = decimal_normal_sample_with(rng);
504 let diffusion = d_mul(
505 d_mul(
506 current_vol.to_dec(),
507 sqrt_dt.to_dec(),
508 "simulation::telegraph::diffusion",
509 )?,
510 z,
511 "simulation::telegraph::diffusion",
512 )?;
513 let drift_term = d_mul(drift, dt.to_dec(), "simulation::telegraph::drift")?;
514
515 // Update price using geometric Brownian motion with regime-dependent volatility
516 let price_change = d_add(drift_term, diffusion, "simulation::telegraph::price")?;
517 price = d_mul(
518 price,
519 d_exp_f64(price_change, "simulation::telegraph::price")?,
520 "simulation::telegraph::price",
521 )?;
522
523 values.push(Positive::new_decimal(price).unwrap_or(Positive::ZERO));
524 // Regime volatility that generated this step.
525 vols.push(current_vol);
526 }
527
528 Ok(WalkPath {
529 prices: values,
530 vols: Some(vols),
531 })
532 }
533 _ => Err(SimulationError::InvalidWalkType {
534 expected: "Telegraph",
535 }),
536 }
537}
538
539/// Built-in Brownian kernel, drawing its normal samples from `rng`.
540fn brownian_path<X, Y, R>(
541 params: &WalkParams<X, Y>,
542 rng: &mut R,
543) -> Result<Vec<Positive>, SimulationError>
544where
545 X: Copy + TryInto<Positive> + AddAssign + Display,
546 Y: TryInto<Positive> + Display + Clone,
547 R: Rng + ?Sized,
548{
549 match params.walk_type {
550 WalkType::Brownian {
551 dt,
552 drift,
553 volatility,
554 } => {
555 let mut values = path_buffer(params.size, 1)?;
556 let start: Positive = params.ystep_as_positive()?;
557 values.push(start);
558 let mut x: Decimal = start.to_dec();
559 let sigma_abs = volatility.checked_mul(&start)?.to_dec();
560 let sqrt_dt = decimal_to_f64(dt.to_dec())?.sqrt(); // scan-banned: allow -- f64 `sqrt`: returns NaN for negative input, it does not abort; the non-finite value is rejected at the `Decimal` boundary
561 let sqrt_dt_dec = finite_decimal(sqrt_dt).ok_or_else(|| {
562 SimulationError::non_finite("simulation::brownian::sqrt_dt", sqrt_dt)
563 })?;
564
565 for _ in 1..params.size {
566 let z = decimal_normal_sample_with(rng);
567 let diffusion = d_mul(
568 d_mul(sigma_abs, sqrt_dt_dec, "simulation::brownian::diffusion")?,
569 z,
570 "simulation::brownian::diffusion",
571 )?;
572 let drift_term = d_mul(drift, dt.to_dec(), "simulation::brownian::drift")?;
573 x = d_add(
574 x,
575 d_add(drift_term, diffusion, "simulation::brownian::step")?,
576 "simulation::brownian::step",
577 )?;
578 values.push(Positive::new_decimal(x.max(Decimal::ZERO)).unwrap_or(Positive::ZERO));
579 }
580
581 Ok(values)
582 }
583 _ => Err(SimulationError::InvalidWalkType {
584 expected: "Brownian",
585 }),
586 }
587}
588
589/// Built-in geometric Brownian kernel, drawing its normal samples from `rng`.
590fn geometric_brownian_path<X, Y, R>(
591 params: &WalkParams<X, Y>,
592 rng: &mut R,
593) -> Result<Vec<Positive>, SimulationError>
594where
595 X: Copy + TryInto<Positive> + AddAssign + Display,
596 Y: TryInto<Positive> + Display + Clone,
597 R: Rng + ?Sized,
598{
599 match params.walk_type {
600 WalkType::GeometricBrownian {
601 dt,
602 drift,
603 volatility,
604 } => {
605 let mut values = path_buffer(params.size, 0)?;
606 let mut current_value: Positive = params.ystep_as_positive()?;
607 values.push(current_value);
608 let sqrt_dt = p_sqrt(&dt, "simulation::traits::geometric_brownian")?;
609
610 for _ in 1..params.size {
611 // σ * √dt * Z
612 let diffusion = d_mul(
613 d_mul(
614 decimal_normal_sample_with(rng),
615 volatility.to_dec(),
616 "simulation::gbm::diffusion",
617 )?,
618 sqrt_dt.to_dec(),
619 "simulation::gbm::diffusion",
620 )?;
621 // μ * dt
622 let drift_term = d_add(
623 d_mul(drift, dt.to_dec(), "simulation::gbm::drift")?,
624 diffusion,
625 "simulation::gbm::drift",
626 )?;
627 // `f64` exponential (#860), about 1e-16 relative.
628 current_value = current_value
629 .checked_mul_dec(d_exp_f64(drift_term, "simulation::gbm::price")?)?;
630 values.push(current_value);
631 }
632 Ok(values)
633 }
634 _ => Err(SimulationError::InvalidWalkType {
635 expected: "GeometricBrownian",
636 }),
637 }
638}
639
640/// Built-in log-returns kernel, drawing its normal samples from `rng`.
641fn log_returns_path<X, Y, R>(
642 params: &WalkParams<X, Y>,
643 rng: &mut R,
644) -> Result<Vec<Positive>, SimulationError>
645where
646 X: Copy + TryInto<Positive> + AddAssign + Display,
647 Y: TryInto<Positive> + Display + Clone,
648 R: Rng + ?Sized,
649{
650 match params.walk_type {
651 WalkType::LogReturns {
652 dt,
653 expected_return,
654 volatility,
655 autocorrelation,
656 } => {
657 let mut values = path_buffer(params.size, 1)?;
658 let mut price: Positive = params.ystep_as_positive()?;
659 values.push(price);
660
661 let sqrt_dt = decimal_to_f64(dt.to_dec())?.sqrt(); // scan-banned: allow -- f64 `sqrt`: returns NaN for negative input, it does not abort; the non-finite value is rejected at the `Decimal` boundary
662 let sqrt_dt_dec = finite_decimal(sqrt_dt).ok_or_else(|| {
663 SimulationError::non_finite("simulation::log_returns::sqrt_dt", sqrt_dt)
664 })?;
665 let mut prev_log_ret = Decimal::ZERO;
666
667 for _ in 1..params.size {
668 let z = decimal_normal_sample_with(rng);
669 let diffusion = d_mul(
670 d_mul(z, volatility.to_dec(), "simulation::log_returns::diffusion")?,
671 sqrt_dt_dec,
672 "simulation::log_returns::diffusion",
673 )?;
674 let mut log_ret = d_add(
675 d_mul(
676 expected_return,
677 dt.to_dec(),
678 "simulation::log_returns::log_ret",
679 )?,
680 diffusion,
681 "simulation::log_returns::log_ret",
682 )?;
683
684 if let Some(ac) = autocorrelation {
685 if !(-Decimal::ONE..=Decimal::ONE).contains(&ac) {
686 return Err(SimulationError::InvalidAutocorrelation { value: ac });
687 }
688 log_ret = d_add(
689 log_ret,
690 d_mul(ac, prev_log_ret, "simulation::log_returns::autocorrelation")?,
691 "simulation::log_returns::autocorrelation",
692 )?;
693 }
694
695 // actualizar precio
696 price =
697 price.checked_mul_dec(d_exp_f64(log_ret, "simulation::log_returns::price")?)?;
698 values.push(price);
699
700 prev_log_ret = log_ret;
701 }
702 Ok(values)
703 }
704 _ => Err(SimulationError::InvalidWalkType {
705 expected: "LogReturns",
706 }),
707 }
708}
709
710/// Built-in mean-reverting kernel, drawing its normal samples from `rng`.
711fn mean_reverting_path<X, Y, R>(
712 params: &WalkParams<X, Y>,
713 rng: &mut R,
714) -> Result<Vec<Positive>, SimulationError>
715where
716 X: Copy + TryInto<Positive> + AddAssign + Display,
717 Y: TryInto<Positive> + Display + Clone,
718 R: Rng + ?Sized,
719{
720 match params.walk_type {
721 WalkType::MeanReverting {
722 dt,
723 volatility,
724 speed,
725 mean, // mean level or initial value
726 } => {
727 let sigma_abs = volatility.checked_mul(&mean)?;
728 ou_path(
729 params.ystep_as_positive()?,
730 mean,
731 speed,
732 sigma_abs,
733 dt,
734 params.size,
735 rng,
736 )
737 }
738
739 _ => Err(SimulationError::InvalidWalkType {
740 expected: "MeanReverting",
741 }),
742 }
743}
744
745/// Built-in jump-diffusion kernel, drawing its normal and uniform samples
746/// from `rng`.
747///
748/// Each step draws the diffusion normal, then the uniform of the jump
749/// trial, then, only when the trial fires, the normal of the jump size.
750fn jump_diffusion_path<X, Y, R>(
751 params: &WalkParams<X, Y>,
752 rng: &mut R,
753) -> Result<Vec<Positive>, SimulationError>
754where
755 X: Copy + TryInto<Positive> + AddAssign + Display,
756 Y: TryInto<Positive> + Display + Clone,
757 R: Rng + ?Sized,
758{
759 match params.walk_type {
760 WalkType::JumpDiffusion {
761 dt,
762 drift,
763 volatility,
764 intensity,
765 jump_mean,
766 jump_volatility,
767 } => {
768 let mut values = path_buffer(params.size, 1)?;
769 let mut x: Decimal = params.ystep_as_positive()?.to_dec();
770 values.push(Positive::new_decimal(x).unwrap_or(Positive::ZERO));
771
772 let sqrt_dt = p_sqrt(&dt, "simulation::traits::jump_diffusion")?;
773 let lambda_dt = intensity.checked_mul(&dt)?;
774
775 for _ in 1..params.size {
776 let z = decimal_normal_sample_with(rng);
777 let sigma_abs = d_mul(volatility.to_dec(), x, "simulation::jump::sigma_abs")?;
778 let diffusion = d_mul(
779 d_mul(sigma_abs, sqrt_dt.to_dec(), "simulation::jump::diffusion")?,
780 z,
781 "simulation::jump::diffusion",
782 )?;
783
784 let drift_term = d_mul(drift, dt.to_dec(), "simulation::jump::drift")?;
785 // Bernoulli(λdt) trial on a genuine U(0,1) draw (#684):
786 // `P(u < λdt) = λdt`, and a step with `λdt >= 1` always jumps.
787 let jump = if decimal_uniform_sample_with(rng) < lambda_dt.to_dec() {
788 d_add(
789 jump_mean,
790 d_mul(
791 decimal_normal_sample_with(rng),
792 jump_volatility.to_dec(),
793 "simulation::jump::size",
794 )?,
795 "simulation::jump::size",
796 )?
797 } else {
798 Decimal::ZERO
799 };
800
801 x = d_add(
802 x,
803 d_add(
804 d_add(drift_term, diffusion, "simulation::jump::step")?,
805 jump,
806 "simulation::jump::step",
807 )?,
808 "simulation::jump::step",
809 )?;
810 x = x.max(Decimal::ZERO);
811 values.push(Positive::new_decimal(x).unwrap_or(Positive::ZERO));
812 }
813
814 Ok(values)
815 }
816 _ => Err(SimulationError::InvalidWalkType {
817 expected: "JumpDiffusion",
818 }),
819 }
820}
821/// Object-safe helper trait that exposes a `Clone`-compatible operation for
822/// [`WalkTypeAble`] trait objects.
823///
824/// `Clone::clone` requires `Self: Sized`, so it cannot be invoked through
825/// `dyn WalkTypeAble<X, Y>` directly. `WalkTypeAbleClone::clone_box` returns an
826/// owned `Box<dyn WalkTypeAble<X, Y>>` instead, which lets the generic
827/// `Clone for Box<dyn WalkTypeAble<X, Y>>` implementation work uniformly for
828/// any concrete walker that is `Clone + 'static`.
829///
830/// You should never implement this trait by hand: a blanket impl covers every
831/// `T: WalkTypeAble<X, Y> + Clone + 'static`. Just `#[derive(Clone)]` on your
832/// concrete walker and the trait object gains `Clone` for free.
833pub trait WalkTypeAbleClone<X, Y>
834where
835 X: Copy + TryInto<Positive> + AddAssign + Display,
836 Y: TryInto<Positive> + Display + Clone,
837{
838 /// Returns a boxed clone of this walker as a trait object.
839 #[must_use]
840 fn clone_box(&self) -> Box<dyn WalkTypeAble<X, Y>>;
841}
842
843impl<T, X, Y> WalkTypeAbleClone<X, Y> for T
844where
845 T: 'static + WalkTypeAble<X, Y> + Clone,
846 X: Copy + TryInto<Positive> + AddAssign + Display,
847 Y: TryInto<Positive> + Display + Clone,
848{
849 #[inline]
850 fn clone_box(&self) -> Box<dyn WalkTypeAble<X, Y>> {
851 Box::new(self.clone())
852 }
853}
854
855/// Trait for implementing various random walk models and stochastic processes.
856///
857/// This trait provides methods to generate different types of stochastic processes commonly
858/// used in financial modeling, time series analysis, and simulation studies. Each method
859/// implements a specific type of random walk based on the parameters provided.
860///
861/// The trait is generic over types `X` and `Y`, which represent the x-axis (typically time)
862/// and y-axis (typically price or value) components respectively.
863///
864/// # Type Parameters
865///
866/// * `X` - The type for the x-axis values (typically time), must be `Copy`, convertible to `Positive`,
867/// implement `AddAssign`, and implement `Display`.
868/// * `Y` - The type for the y-axis values (typically price), must be `Copy`, convertible to `Positive`,
869/// and implement `Display`.
870///
871/// # Methods
872///
873/// The trait provides methods for generating the following stochastic processes:
874/// - Brownian motion (standard random walk)
875/// - Geometric Brownian motion
876/// - Log returns process with optional autocorrelation
877/// - Mean reverting (Ornstein-Uhlenbeck) process
878/// - Jump diffusion process
879/// - GARCH (Generalized Autoregressive Conditional Heteroskedasticity)
880/// - Heston stochastic volatility model
881/// - Custom stochastic process with mean-reverting volatility
882///
883/// # Object safety and cloning
884///
885/// `WalkTypeAble` is object-safe: you can hold it behind `Box<dyn WalkTypeAble<X, Y>>`
886/// (e.g., inside [`WalkParams`]). The super-trait [`WalkTypeAbleClone`] provides an
887/// object-safe `clone_box` hook that `Box<dyn WalkTypeAble<X, Y>>: Clone` delegates
888/// to. Concrete walkers must therefore implement `WalkTypeAbleClone` — a blanket
889/// impl covers every `T: WalkTypeAble<X, Y> + Clone + 'static`, so in practice it
890/// is enough to add `#[derive(Clone)]` to your walker struct and the object-safe
891/// clone path works automatically.
892///
893/// Walkers that cannot be `Clone` may implement `WalkTypeAbleClone::clone_box`
894/// by hand, but doing so is rarely necessary.
895///
896/// # Seeding
897///
898/// Every default walk method (and the public kernels [`garch_walk`],
899/// [`heston_walk`], [`custom_walk`], [`telegraph_walk`]) reads
900/// [`WalkParams::seed`]: `Some(seed)` draws the whole path from
901/// [`optionstratlib_core::utils::deterministic_rng`]`(seed)`, so it is
902/// reproducible bit for bit; `None` draws from the thread RNG. A walker that
903/// overrides a method and draws its own randomness should do the same with
904/// [`optionstratlib_core::model::decimal::decimal_normal_sample_with`] to keep
905/// seeded runs reproducible.
906///
907/// # Thread safety
908///
909/// A walker is `Send + Sync`: [`crate::simulation::simulator::Simulator::new`]
910/// builds its walks on the rayon pool, each walk on a clone of the shared
911/// [`WalkParams`], so the boxed walker crosses threads (#860). Keep any
912/// interior state behind `Arc`, an atomic or a `Mutex`, not `Rc` or `RefCell`.
913///
914/// # Output contract
915///
916/// The walk generators (`generator_optionchain`, `generator_positive`,
917/// `generator_optionseries`) treat the FIRST element of a walker's output as a
918/// duplicate of the initial value and skip it. A walker may return an empty
919/// vector or a single element; the generators then yield only the initial
920/// step. Returning fewer than `params.size` values is allowed and simply
921/// shortens the walk.
922pub trait WalkTypeAble<X, Y>: WalkTypeAbleClone<X, Y> + Send + Sync
923where
924 X: Copy + TryInto<Positive> + AddAssign + Display,
925 Y: TryInto<Positive> + Display + Clone,
926{
927 /// Dispatches to the walk method matching `params.walk_type`.
928 ///
929 /// This is the single authoritative variant→method dispatch: generators
930 /// call `generate` instead of matching on [`WalkType`] themselves, so
931 /// adding a new walk variant only requires extending the enum and this
932 /// trait — no generator changes.
933 ///
934 /// # Parameters
935 ///
936 /// * `params` - Walk parameters; `params.walk_type` selects the process.
937 ///
938 /// # Returns
939 ///
940 /// * `Result<Vec<Positive>, SimulationError>` - The generated path, per
941 /// the contract of the selected walk method.
942 ///
943 /// # Errors
944 ///
945 /// Propagates the error of the dispatched walk method (see each method's
946 /// documentation).
947 fn generate(&self, params: &WalkParams<X, Y>) -> Result<Vec<Positive>, SimulationError> {
948 match ¶ms.walk_type {
949 WalkType::Brownian { .. } => self.brownian(params),
950 WalkType::GeometricBrownian { .. } => self.geometric_brownian(params),
951 WalkType::LogReturns { .. } => self.log_returns(params),
952 WalkType::MeanReverting { .. } => self.mean_reverting(params),
953 WalkType::JumpDiffusion { .. } => self.jump_diffusion(params),
954 WalkType::Garch { .. } => self.garch(params),
955 WalkType::Heston { .. } => self.heston(params),
956 WalkType::Custom { .. } => self.custom(params),
957 WalkType::Telegraph { .. } => self.telegraph(params),
958 WalkType::Historical { .. } => self.historical(params),
959 }
960 }
961
962 /// Like [`WalkTypeAble::generate`], but also exposes the per-step
963 /// volatility path for walk types whose volatility varies over time.
964 ///
965 /// For the stochastic-volatility variants (`Garch`, `Heston`, `Custom`,
966 /// `Telegraph`) the returned [`WalkPath::vols`] carries the ANNUALIZED
967 /// volatility prevailing at each step, aligned index-by-index with
968 /// [`WalkPath::prices`]. For every other variant `vols` is `None` — the
969 /// constant volatility is available via [`WalkType::volatility`], and
970 /// `Historical` per-step estimates are the caller's concern (see
971 /// `simulation::walk_steps`).
972 ///
973 /// # Note for implementors
974 ///
975 /// The price-path methods (`garch`, `heston`, `custom`, `telegraph`)
976 /// and their `*_with_vol` siblings are BOTH backed by the same built-in
977 /// kernels ([`garch_walk`], [`heston_walk`], [`custom_walk`],
978 /// [`telegraph_walk`]) but are independent override points: overriding
979 /// one never changes the other's default. Consumers that need the vol
980 /// path (the walk generators) call `generate_with_vol`, so a walker
981 /// overriding a price-path method MUST override the matching
982 /// `*_with_vol` method too — typically by wrapping its own dynamics, or
983 /// by composing the public kernel — otherwise the generators will keep
984 /// using the built-in dynamics.
985 ///
986 /// # Errors
987 ///
988 /// Propagates the error of the dispatched walk method.
989 fn generate_with_vol(&self, params: &WalkParams<X, Y>) -> Result<WalkPath, SimulationError> {
990 match ¶ms.walk_type {
991 WalkType::Garch { .. } => self.garch_with_vol(params),
992 WalkType::Heston { .. } => self.heston_with_vol(params),
993 WalkType::Custom { .. } => self.custom_with_vol(params),
994 WalkType::Telegraph { .. } => self.telegraph_with_vol(params),
995 _ => Ok(WalkPath {
996 prices: self.generate(params)?,
997 vols: None,
998 }),
999 }
1000 }
1001
1002 /// Generates a Brownian motion (standard random walk) process.
1003 ///
1004 /// Brownian motion is a continuous-time stochastic process where changes
1005 /// are normally distributed with a drift term and volatility.
1006 ///
1007 /// # Parameters
1008 ///
1009 /// * `params` - Walk parameters including initial value, time step, drift, and volatility.
1010 ///
1011 /// # Returns
1012 ///
1013 /// * `Result<Vec<Positive>, SimulationError>` - A vector of positive values representing
1014 /// the generated Brownian motion path, or an error if parameters are invalid.
1015 ///
1016 /// # Errors
1017 ///
1018 /// Returns `SimulationError::InvalidWalkType` when
1019 /// `params.walk_type` is not a `WalkType::Brownian` variant, and
1020 /// propagates `SimulationError::PositiveError` from
1021 /// `params.ystep_as_positive()?` when the initial `y` value
1022 /// violates the `Positive` invariant.
1023 ///
1024 /// Generated samples are clamped with
1025 /// `Positive::new_decimal(x.max(Decimal::ZERO))
1026 /// .unwrap_or(Positive::ZERO)`, so negative realisations do not
1027 /// surface an error - they are replaced by `Positive::ZERO`.
1028 fn brownian(&self, params: &WalkParams<X, Y>) -> Result<Vec<Positive>, SimulationError> {
1029 with_walk_rng!(params, brownian_path)
1030 }
1031
1032 /// Generates a Geometric Brownian motion process.
1033 ///
1034 /// Geometric Brownian motion is a continuous-time stochastic process where the logarithm of the
1035 /// randomly varying quantity follows Brownian motion. It's commonly used to model stock prices
1036 /// in the Black-Scholes options pricing model.
1037 ///
1038 /// # Parameters
1039 ///
1040 /// * `params` - Walk parameters including initial value, time step, drift, and volatility.
1041 ///
1042 /// # Returns
1043 ///
1044 /// * `Result<Vec<Positive>, SimulationError>` - A vector of positive values representing
1045 /// the generated Geometric Brownian motion path, or an error if parameters are invalid.
1046 ///
1047 /// # Errors
1048 ///
1049 /// Returns [`SimulationError::InvalidWalkType`] when
1050 /// `params.walk_type` is not a [`WalkType::GeometricBrownian`]
1051 /// variant, and [`SimulationError::PositiveError`] when a
1052 /// generated sample underflows below zero.
1053 fn geometric_brownian(
1054 &self,
1055 params: &WalkParams<X, Y>,
1056 ) -> Result<Vec<Positive>, SimulationError> {
1057 with_walk_rng!(params, geometric_brownian_path)
1058 }
1059
1060 /// Generates a Log Returns process, potentially with autocorrelation.
1061 ///
1062 /// This process models returns (percentage changes) directly, rather than absolute values,
1063 /// and can include autocorrelation to capture the tendency of returns to be influenced
1064 /// by previous returns.
1065 ///
1066 /// # Parameters
1067 ///
1068 /// * `params` - Walk parameters including initial value, time step, expected return,
1069 /// volatility, and optional autocorrelation coefficient.
1070 ///
1071 /// # Returns
1072 ///
1073 /// * `Result<Vec<Positive>, SimulationError>` - A vector of positive values representing
1074 /// the generated Log Returns path, or an error if parameters are invalid.
1075 ///
1076 /// # Errors
1077 ///
1078 /// Returns [`SimulationError::InvalidWalkType`] when
1079 /// `params.walk_type` is not a [`WalkType::LogReturns`] variant,
1080 /// [`SimulationError::InvalidAutocorrelation`] when the
1081 /// autocorrelation coefficient is outside `[-1, 1]`, and
1082 /// [`SimulationError::PositiveError`] when the exponentiated
1083 /// sample underflows below zero.
1084 fn log_returns(&self, params: &WalkParams<X, Y>) -> Result<Vec<Positive>, SimulationError> {
1085 with_walk_rng!(params, log_returns_path)
1086 }
1087
1088 /// Generates a Mean Reverting (Ornstein-Uhlenbeck) process.
1089 ///
1090 /// The Ornstein-Uhlenbeck process models a value that tends to drift toward a long-term mean,
1091 /// with the strength of the reversion proportional to the distance from the mean.
1092 /// It's commonly used for interest rates, volatility, and other mean-reverting financial variables.
1093 ///
1094 /// # Parameters
1095 ///
1096 /// * `params` - Walk parameters including initial value, mean level, reversion speed,
1097 /// volatility, and time step.
1098 ///
1099 /// # Returns
1100 ///
1101 /// * `Result<Vec<Positive>, SimulationError>` - A vector of positive values representing
1102 /// the generated Mean Reverting path, or an error if parameters are invalid.
1103 ///
1104 /// # Errors
1105 ///
1106 /// Returns [`SimulationError::InvalidWalkType`] when
1107 /// `params.walk_type` is not a [`WalkType::MeanReverting`]
1108 /// variant, and [`SimulationError::PositiveError`] when an
1109 /// intermediate sample breaches the `Positive` invariant.
1110 fn mean_reverting(&self, params: &WalkParams<X, Y>) -> Result<Vec<Positive>, SimulationError> {
1111 with_walk_rng!(params, mean_reverting_path)
1112 }
1113
1114 /// Generates a Jump Diffusion process.
1115 ///
1116 /// Jump Diffusion combines continuous Brownian motion with discrete jumps that occur
1117 /// according to a Poisson process. This model is useful for capturing sudden market
1118 /// movements like crashes or spikes that standard Brownian motion cannot adequately model.
1119 ///
1120 /// # Parameters
1121 ///
1122 /// * `params` - Walk parameters including initial value, drift, volatility, jump intensity,
1123 /// jump mean size, and jump volatility.
1124 ///
1125 /// # Returns
1126 ///
1127 /// * `Result<Vec<Positive>, SimulationError>` - A vector of positive values representing
1128 /// the generated Jump Diffusion path, or an error if parameters are invalid.
1129 ///
1130 /// # Errors
1131 ///
1132 /// Returns [`SimulationError::InvalidWalkType`] when
1133 /// `params.walk_type` is not a [`WalkType::JumpDiffusion`]
1134 /// variant, and [`SimulationError::PositiveError`] when a jumped
1135 /// sample underflows below zero.
1136 fn jump_diffusion(&self, params: &WalkParams<X, Y>) -> Result<Vec<Positive>, SimulationError> {
1137 with_walk_rng!(params, jump_diffusion_path)
1138 }
1139
1140 /// Generates a GARCH (Generalized Autoregressive Conditional Heteroskedasticity) process.
1141 ///
1142 /// GARCH models time-varying volatility clustering, where periods of high volatility
1143 /// tend to be followed by high volatility, and low volatility by low volatility.
1144 ///
1145 /// # Parameters
1146 ///
1147 /// * `params` - Walk parameters for the GARCH process.
1148 ///
1149 /// # Returns
1150 ///
1151 /// * `Result<Vec<Positive>, SimulationError>` - A vector of positive values representing
1152 /// the generated GARCH path, or an error if parameters are invalid.
1153 ///
1154 /// # Note
1155 ///
1156 /// This implementation is currently a placeholder and returns an empty vector.
1157 ///
1158 /// # Errors
1159 ///
1160 /// Returns [`SimulationError::InvalidWalkType`] when
1161 /// `params.walk_type` is not a [`WalkType::Garch`] variant,
1162 /// [`SimulationError::GarchStationarity`] when `alpha + beta ≥ 1`
1163 /// (the recurrence is non-stationary), and
1164 /// [`SimulationError::PositiveError`] when an intermediate
1165 /// variance breaches the `Positive` invariant.
1166 fn garch(&self, params: &WalkParams<X, Y>) -> Result<Vec<Positive>, SimulationError> {
1167 // Standalone: this method never routes through `garch_with_vol`, so
1168 // overriding either method cannot change the other's default.
1169 Ok(garch_walk(params)?.prices)
1170 }
1171
1172 /// GARCH(1,1) walk that also exposes the simulated conditional-volatility
1173 /// path.
1174 ///
1175 /// Same dynamics as [`WalkTypeAble::garch`]; `vols[i]` is the ANNUALIZED
1176 /// conditional volatility `sqrt(var_t)` that generated `prices[i]`
1177 /// (`vols[0]` is the initial `volatility` parameter).
1178 ///
1179 /// # Errors
1180 ///
1181 /// Same as [`WalkTypeAble::garch`].
1182 fn garch_with_vol(&self, params: &WalkParams<X, Y>) -> Result<WalkPath, SimulationError> {
1183 garch_walk(params)
1184 }
1185
1186 /// Generates a Heston stochastic volatility model.
1187 ///
1188 /// The Heston model extends Geometric Brownian Motion by allowing the volatility
1189 /// itself to be a stochastic process, following a mean-reverting square-root process.
1190 ///
1191 /// # Parameters
1192 ///
1193 /// * `params` - Walk parameters for the Heston process.
1194 ///
1195 /// # Returns
1196 ///
1197 /// * `Result<Vec<Positive>, SimulationError>` - A vector of positive values representing
1198 /// the generated Heston model path, or an error if parameters are invalid.
1199 ///
1200 /// # Note
1201 ///
1202 /// This implementation is currently a placeholder and returns an empty vector.
1203 /// Generates a Heston stochastic volatility model.
1204 ///
1205 /// The Heston model extends Geometric Brownian Motion by allowing the volatility
1206 /// itself to be a stochastic process, following a mean-reverting square-root process.
1207 ///
1208 /// # Parameters
1209 ///
1210 /// * `params` - Walk parameters for the Heston process, including:
1211 /// - `dt`: Time step
1212 /// - `drift`: Drift coefficient for the price process
1213 /// - `v0`: Initial variance
1214 /// - `kappa`: Mean reversion speed for variance
1215 /// - `theta`: Long-term variance mean level
1216 /// - `xi`: Volatility of variance
1217 /// - `rho`: Correlation between price and variance Brownian motions
1218 ///
1219 /// # Returns
1220 ///
1221 /// * `Result<Vec<Positive>, SimulationError>` - A vector of positive values representing
1222 /// the generated Heston model path, or an error if parameters are invalid.
1223 ///
1224 /// # Notes
1225 ///
1226 /// The Heston model is described by the following SDEs:
1227 /// dS_t = μS_t dt + √v_t S_t dW^1_t
1228 /// dv_t = κ(θ - v_t) dt + ξ√v_t dW^2_t
1229 /// with dW^1_t dW^2_t = ρ dt
1230 ///
1231 /// # Errors
1232 ///
1233 /// Returns [`SimulationError::InvalidWalkType`] when
1234 /// `params.walk_type` is not a [`WalkType::Heston`] variant,
1235 /// [`SimulationError::InvalidCorrelation`] when `rho` is outside
1236 /// `[-1, 1]`, and [`SimulationError::PositiveError`] when an
1237 /// intermediate price or variance breaches the `Positive`
1238 /// invariant.
1239 fn heston(&self, params: &WalkParams<X, Y>) -> Result<Vec<Positive>, SimulationError> {
1240 // Standalone: this method never routes through `heston_with_vol`, so
1241 // overriding either method cannot change the other's default.
1242 Ok(heston_walk(params)?.prices)
1243 }
1244
1245 /// Heston walk that also exposes the simulated volatility path.
1246 ///
1247 /// Same dynamics as [`WalkTypeAble::heston`]; `vols[i]` is the ANNUALIZED
1248 /// instantaneous volatility `sqrt(v_t)` prevailing after the step that
1249 /// produced `prices[i]` (`vols[0]` is the initial `volatility`
1250 /// parameter).
1251 ///
1252 /// # Errors
1253 ///
1254 /// Same as [`WalkTypeAble::heston`].
1255 fn heston_with_vol(&self, params: &WalkParams<X, Y>) -> Result<WalkPath, SimulationError> {
1256 heston_walk(params)
1257 }
1258
1259 /// Generates a custom stochastic process with mean-reverting volatility.
1260 ///
1261 /// This implements a process where the underlying value follows Brownian motion,
1262 /// but with volatility that follows an Ornstein-Uhlenbeck (mean-reverting) process.
1263 /// This allows for modeling more complex dynamics than standard models.
1264 ///
1265 /// # Parameters
1266 ///
1267 /// * `params` - Walk parameters including drift, initial volatility, volatility of volatility (vov),
1268 /// volatility mean reversion speed, volatility mean level, and time step.
1269 ///
1270 /// # Returns
1271 ///
1272 /// * `Result<Vec<Positive>, SimulationError>` - A vector of positive values representing
1273 /// the generated custom process path, or an error if parameters are invalid.
1274 ///
1275 /// # Errors
1276 ///
1277 /// Returns [`SimulationError::InvalidWalkType`] when
1278 /// `params.walk_type` is not a [`WalkType::Custom`] variant,
1279 /// [`SimulationError::InsufficientHistoricalData`] when the
1280 /// provided calibration sample is too small for the requested
1281 /// process order, and [`SimulationError::PositiveError`] when a
1282 /// generated sample violates the `Positive` invariant.
1283 fn custom(&self, params: &WalkParams<X, Y>) -> Result<Vec<Positive>, SimulationError> {
1284 // Standalone: this method never routes through `custom_with_vol`, so
1285 // overriding either method cannot change the other's default.
1286 Ok(custom_walk(params)?.prices)
1287 }
1288
1289 /// Custom (mean-reverting volatility) walk that also exposes the
1290 /// Ornstein-Uhlenbeck volatility path that drove the prices.
1291 ///
1292 /// Same dynamics as [`WalkTypeAble::custom`]; `vols[i]` is the ANNUALIZED
1293 /// OU volatility used to generate `prices[i]` (`vols[0]` is the initial
1294 /// `volatility` parameter).
1295 ///
1296 /// # Errors
1297 ///
1298 /// Same as [`WalkTypeAble::custom`].
1299 fn custom_with_vol(&self, params: &WalkParams<X, Y>) -> Result<WalkPath, SimulationError> {
1300 custom_walk(params)
1301 }
1302
1303 /// Generates a Telegraph process (two-state regime switching model).
1304 ///
1305 /// The Telegraph process alternates between two states (+1 and -1) with specified transition rates,
1306 /// affecting the volatility of the price path. This model captures regime-switching behavior
1307 /// in financial markets where volatility can suddenly change between high and low regimes.
1308 ///
1309 /// # Parameters
1310 ///
1311 /// * `params` - Walk parameters including initial value, drift, base volatility, transition rates,
1312 /// and optional volatility multipliers for each state.
1313 ///
1314 /// # Returns
1315 ///
1316 /// * `Result<Vec<Positive>, SimulationError>` - A vector of positive values representing
1317 /// the generated Telegraph process path, or an error if parameters are invalid.
1318 ///
1319 /// # Errors
1320 ///
1321 /// Returns [`SimulationError::InvalidWalkType`] when
1322 /// `params.walk_type` is not a [`WalkType::Telegraph`] variant,
1323 /// and [`SimulationError::PositiveError`] when an intermediate
1324 /// sample breaches the `Positive` invariant.
1325 fn telegraph(&self, params: &WalkParams<X, Y>) -> Result<Vec<Positive>, SimulationError> {
1326 // Standalone: this method never routes through `telegraph_with_vol`, so
1327 // overriding either method cannot change the other's default.
1328 Ok(telegraph_walk(params)?.prices)
1329 }
1330
1331 /// Telegraph (two-state regime switching) walk that also exposes the
1332 /// regime-dependent volatility path.
1333 ///
1334 /// Same dynamics as [`WalkTypeAble::telegraph`]; `vols[i]` is the
1335 /// ANNUALIZED regime volatility (base volatility times the active
1336 /// regime's multiplier) used to generate `prices[i]` (`vols[0]` is the
1337 /// base `volatility` parameter).
1338 ///
1339 /// # Errors
1340 ///
1341 /// Same as [`WalkTypeAble::telegraph`].
1342 fn telegraph_with_vol(&self, params: &WalkParams<X, Y>) -> Result<WalkPath, SimulationError> {
1343 telegraph_walk(params)
1344 }
1345
1346 /// Generates a historical walk based on the given parameters.
1347 ///
1348 /// This function processes the historical walk by extracting a specified number of elements
1349 /// from the provided price data (`prices`) based on the `size` defined in `params`.
1350 ///
1351 /// # Parameters
1352 ///
1353 /// * `self`: Reference to the instance of the object.
1354 /// * `params`: A reference to `WalkParams<X, Y>` containing the configuration details for the walk.
1355 /// - Expected to have a `walk_type` of `WalkType::Historical` with associated timeframe and price data.
1356 /// - `params.size` determines the number of historical prices to include in the result.
1357 ///
1358 /// # Returns
1359 ///
1360 /// * `Ok(Vec<Positive>)`: A vector containing the first `params.size` elements from the given price data (`prices`),
1361 /// if there are at least `params.size` elements available.
1362 /// * `Err(Box<dyn Error>)`: If the `walk_type` is not `WalkType::Historical` or if the provided price data
1363 /// does not contain enough elements to fulfill the requested size (`params.size`).
1364 ///
1365 /// # Errors
1366 ///
1367 /// * Returns an error if:
1368 /// - The `walk_type` in `params` is not `WalkType::Historical`.
1369 /// - The `prices` do not contain at least `params.size` elements.
1370 ///
1371 fn historical(&self, params: &WalkParams<X, Y>) -> Result<Vec<Positive>, SimulationError> {
1372 match ¶ms.walk_type {
1373 WalkType::Historical {
1374 timeframe: _timeframe,
1375 prices,
1376 symbol: _symbol,
1377 } => match prices.get(0..params.size) {
1378 Some(window) => Ok(window.to_vec()),
1379 None => Err(SimulationError::InsufficientHistoricalData {
1380 required: params.size,
1381 found: prices.len(),
1382 }),
1383 },
1384 _ => Err(SimulationError::InvalidWalkType {
1385 expected: "Historical",
1386 }),
1387 }
1388 }
1389}
1390
1391impl<X, Y> Debug for Box<dyn WalkTypeAble<X, Y>> {
1392 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1393 write!(f, "WalkTypeAble")
1394 }
1395}
1396
1397impl<X, Y> Clone for Box<dyn WalkTypeAble<X, Y>>
1398where
1399 X: Copy + TryInto<Positive> + AddAssign + Display,
1400 Y: TryInto<Positive> + Display + Clone,
1401{
1402 fn clone(&self) -> Self {
1403 // Delegate to the object-safe `clone_box` hook on `WalkTypeAbleClone`.
1404 // The blanket impl for `T: WalkTypeAble<X, Y> + Clone + 'static`
1405 // forwards to `Clone::clone` on the concrete walker.
1406 self.clone_box()
1407 }
1408}
1409
1410#[cfg(test)]
1411mod tests_walk_type_able {
1412 use super::*;
1413 use optionstratlib_core::model::ExpirationDate;
1414
1415 use crate::simulation::model::WalkType;
1416 use crate::simulation::params::WalkParams;
1417 use crate::simulation::steps::Step;
1418 use crate::simulation::traits::WalkTypeAble;
1419 use optionstratlib_core::pos_or_panic;
1420 use optionstratlib_core::utils::TimeFrame;
1421 use rust_decimal::Decimal;
1422 use std::fmt::Display;
1423 use std::ops::AddAssign;
1424
1425 /// Seed of the simulated walks, so every run draws the same stream.
1426 const SEED: u64 = 685;
1427
1428 #[derive(Debug, Clone)]
1429 struct TestWalker {}
1430
1431 impl<X, Y> WalkTypeAble<X, Y> for TestWalker
1432 where
1433 X: Copy + TryInto<Positive> + AddAssign + Display,
1434 Y: Copy + TryInto<Positive> + Display,
1435 {
1436 }
1437
1438 fn create_test_params<X, Y>(
1439 size: usize,
1440 x_value: X,
1441 y_value: Y,
1442 walk_type: WalkType,
1443 ) -> WalkParams<X, Y>
1444 where
1445 X: Copy + TryInto<Positive> + AddAssign + Display,
1446 Y: Copy + TryInto<Positive> + Display,
1447 {
1448 let init_step = Step::new(
1449 x_value,
1450 TimeFrame::Day,
1451 ExpirationDate::Days(pos_or_panic!(30.0)),
1452 y_value,
1453 );
1454
1455 WalkParams {
1456 size,
1457 init_step,
1458 walk_type,
1459 walker: Box::new(TestWalker {}),
1460 seed: Some(SEED),
1461 }
1462 }
1463
1464 #[test]
1465 fn test_brownian_walk() -> Result<(), SimulationError> {
1466 let params = create_test_params(
1467 5,
1468 10.0,
1469 100.0,
1470 WalkType::Brownian {
1471 dt: Positive::ONE,
1472 drift: Decimal::ZERO,
1473 volatility: pos_or_panic!(0.2),
1474 },
1475 );
1476
1477 let walker = TestWalker {};
1478 let result = walker.brownian(¶ms)?;
1479
1480 assert_eq!(result.len(), 5);
1481 Ok(())
1482 }
1483
1484 #[test]
1485 fn test_geometric_brownian_walk() -> Result<(), SimulationError> {
1486 let params = create_test_params(
1487 5,
1488 10.0,
1489 100.0,
1490 WalkType::GeometricBrownian {
1491 dt: Positive::ONE,
1492 drift: Decimal::new(5, 2), // 0.05
1493 volatility: pos_or_panic!(0.2),
1494 },
1495 );
1496
1497 let walker = TestWalker {};
1498 let result = walker.geometric_brownian(¶ms)?;
1499
1500 assert_eq!(result.len(), 5);
1501 Ok(())
1502 }
1503
1504 #[test]
1505 fn test_log_returns_walk() -> Result<(), SimulationError> {
1506 let params = create_test_params(
1507 5,
1508 10.0,
1509 100.0,
1510 WalkType::LogReturns {
1511 dt: Positive::ONE,
1512 expected_return: Decimal::new(5, 2), // 0.05
1513 volatility: pos_or_panic!(0.2),
1514 autocorrelation: None,
1515 },
1516 );
1517
1518 let walker = TestWalker {};
1519 let result = walker.log_returns(¶ms)?;
1520
1521 assert_eq!(result.len(), 5);
1522 Ok(())
1523 }
1524
1525 #[test]
1526 fn test_mean_reverting_walk() -> Result<(), SimulationError> {
1527 let mean_value = pos_or_panic!(150.0);
1528 let params = create_test_params(
1529 5,
1530 10.0,
1531 100.0,
1532 WalkType::MeanReverting {
1533 dt: Positive::ONE,
1534 volatility: pos_or_panic!(0.2),
1535 speed: pos_or_panic!(0.1),
1536 mean: mean_value,
1537 },
1538 );
1539
1540 let walker = TestWalker {};
1541 let result = walker.mean_reverting(¶ms)?;
1542
1543 assert_eq!(result.len(), 5);
1544 assert_eq!(result[0], Positive::HUNDRED);
1545 Ok(())
1546 }
1547
1548 #[test]
1549 fn test_jump_diffusion_walk() -> Result<(), SimulationError> {
1550 let params = create_test_params(
1551 6,
1552 10.0,
1553 100.0,
1554 WalkType::JumpDiffusion {
1555 dt: Positive::ONE,
1556 drift: Decimal::ZERO,
1557 volatility: pos_or_panic!(0.2),
1558 intensity: pos_or_panic!(0.1),
1559 jump_mean: Decimal::ZERO,
1560 jump_volatility: Positive::ONE,
1561 },
1562 );
1563
1564 let walker = TestWalker {};
1565 let result = walker.jump_diffusion(¶ms)?;
1566
1567 assert_eq!(result.len(), 6);
1568 Ok(())
1569 }
1570
1571 /// Counts the jumps of a seeded jump-diffusion path with no diffusion
1572 /// and no drift, where every jump moves the price by exactly `+1`.
1573 fn count_jumps(
1574 steps: usize,
1575 intensity: Positive,
1576 dt: Positive,
1577 ) -> Result<u64, SimulationError> {
1578 let params = create_test_params(
1579 steps + 1,
1580 1.0,
1581 100.0,
1582 WalkType::JumpDiffusion {
1583 dt,
1584 drift: Decimal::ZERO,
1585 volatility: Positive::ZERO,
1586 intensity,
1587 jump_mean: Decimal::ONE,
1588 jump_volatility: Positive::ZERO,
1589 },
1590 );
1591 let path = TestWalker {}.jump_diffusion(¶ms)?;
1592 assert_eq!(path.len(), steps + 1);
1593 let mut jumps = 0_u64;
1594 for pair in path.windows(2) {
1595 let increment = pair[1].to_dec() - pair[0].to_dec();
1596 if increment == Decimal::ONE {
1597 jumps += 1;
1598 } else {
1599 assert_eq!(increment, Decimal::ZERO, "step moved without a jump");
1600 }
1601 }
1602 Ok(jumps)
1603 }
1604
1605 /// Asserts that `jumps` out of `steps` Bernoulli(`p`) trials lie within
1606 /// five standard errors, `5 * sqrt(steps * p * (1 - p))`, of the mean
1607 /// `steps * p`. A correct kernel misses the band with probability below
1608 /// 6e-7 for any seed.
1609 fn assert_bernoulli_frequency(jumps: u64, steps: usize, p: f64) {
1610 let n = steps as f64;
1611 let mean = n * p;
1612 let bound = 5.0 * (n * p * (1.0 - p)).sqrt();
1613 assert!(
1614 (jumps as f64 - mean).abs() < bound,
1615 "{jumps} jumps in {steps} steps, expected {mean} +- {bound}"
1616 );
1617 }
1618
1619 #[test]
1620 fn test_jump_diffusion_daily_jump_frequency_matches_lambda_dt() -> Result<(), SimulationError> {
1621 // #684: one jump a year on daily steps, `λ·dt = 1 * 0.004 = 0.004`.
1622 // 500_000 steps expect 2_000 jumps, standard error 44.6, so the band
1623 // is 2_000 +- 223. The former normal-draw trial fired on about half
1624 // of the steps, `Φ(0.004) ≈ 0.5016`, some 250_800 jumps.
1625 const STEPS: usize = 500_000;
1626 let jumps = count_jumps(STEPS, Positive::ONE, pos_or_panic!(0.004))?;
1627 assert_bernoulli_frequency(jumps, STEPS, 0.004);
1628 Ok(())
1629 }
1630
1631 #[test]
1632 fn test_jump_diffusion_frequent_jump_frequency_matches_lambda_dt() -> Result<(), SimulationError>
1633 {
1634 // `λ·dt = 25 * 0.004 = 0.1`: 100_000 steps expect 10_000 jumps,
1635 // standard error 94.9, band 10_000 +- 474.
1636 const STEPS: usize = 100_000;
1637 let jumps = count_jumps(STEPS, pos_or_panic!(25.0), pos_or_panic!(0.004))?;
1638 assert_bernoulli_frequency(jumps, STEPS, 0.1);
1639 Ok(())
1640 }
1641
1642 #[test]
1643 fn test_jump_diffusion_lambda_dt_at_least_one_jumps_every_step() -> Result<(), SimulationError>
1644 {
1645 // `λ·dt = 1` is the Bernoulli limit: the uniform trial never exceeds it.
1646 const STEPS: usize = 1_000;
1647 let jumps = count_jumps(STEPS, pos_or_panic!(250.0), pos_or_panic!(0.004))?;
1648 assert_eq!(jumps, STEPS as u64);
1649 Ok(())
1650 }
1651
1652 #[test]
1653 fn test_jump_diffusion_zero_intensity_never_jumps() -> Result<(), SimulationError> {
1654 const STEPS: usize = 1_000;
1655 let jumps = count_jumps(STEPS, Positive::ZERO, pos_or_panic!(0.004))?;
1656 assert_eq!(jumps, 0);
1657 Ok(())
1658 }
1659
1660 #[test]
1661 fn test_garch_walk() -> Result<(), SimulationError> {
1662 let params = create_test_params(
1663 5,
1664 10.0,
1665 100.0,
1666 WalkType::Garch {
1667 dt: Positive::ONE,
1668 drift: Decimal::ZERO,
1669 volatility: pos_or_panic!(0.2),
1670 alpha: pos_or_panic!(0.1),
1671 beta: pos_or_panic!(0.8),
1672 },
1673 );
1674
1675 let walker = TestWalker {};
1676 let result = walker.garch(¶ms)?;
1677
1678 assert_eq!(result.len(), 5);
1679 Ok(())
1680 }
1681
1682 #[test]
1683 fn test_heston_walk() -> Result<(), SimulationError> {
1684 let params = create_test_params(
1685 5,
1686 10.0,
1687 100.0,
1688 WalkType::Heston {
1689 dt: Positive::ONE,
1690 drift: Decimal::ZERO,
1691 volatility: pos_or_panic!(0.2),
1692 kappa: Positive::TWO,
1693 theta: pos_or_panic!(0.04),
1694 xi: pos_or_panic!(0.4),
1695 rho: Decimal::new(-5, 1), // -0.5
1696 },
1697 );
1698
1699 let walker = TestWalker {};
1700 let result = walker.heston(¶ms)?;
1701
1702 assert_eq!(result.len(), 5);
1703 Ok(())
1704 }
1705
1706 #[test]
1707 fn test_custom_walk() -> Result<(), SimulationError> {
1708 let params = create_test_params(
1709 5,
1710 10.0,
1711 100.0,
1712 WalkType::Custom {
1713 dt: Positive::ONE,
1714 drift: Decimal::ZERO,
1715 volatility: pos_or_panic!(0.2),
1716 vov: pos_or_panic!(0.4),
1717 vol_speed: Positive::ONE,
1718 vol_mean: pos_or_panic!(0.2),
1719 },
1720 );
1721
1722 let walker = TestWalker {};
1723 let result = walker.custom(¶ms)?;
1724
1725 assert_eq!(result.len(), 5);
1726 Ok(())
1727 }
1728
1729 #[test]
1730 fn test_telegraph_walk() -> Result<(), SimulationError> {
1731 let params = create_test_params(
1732 5,
1733 10.0,
1734 100.0,
1735 WalkType::Telegraph {
1736 dt: Positive::ONE,
1737 drift: Decimal::new(5, 2), // 0.05
1738 volatility: pos_or_panic!(0.2),
1739 lambda_up: pos_or_panic!(0.5),
1740 lambda_down: pos_or_panic!(0.3),
1741 vol_multiplier_up: Some(pos_or_panic!(1.5)),
1742 vol_multiplier_down: Some(pos_or_panic!(0.8)),
1743 },
1744 );
1745
1746 let walker = TestWalker {};
1747 let result = walker.telegraph(¶ms)?;
1748
1749 assert_eq!(result.len(), 5);
1750 assert_eq!(result[0], Positive::HUNDRED); // Initial value should be preserved
1751 Ok(())
1752 }
1753
1754 /// Regime-switch tallies of a seeded telegraph path, read off its
1755 /// volatility path: `+1` runs at `0.30`, `-1` at `0.10`.
1756 #[derive(Debug, Default)]
1757 struct SwitchTally {
1758 /// Trials made from the `+1` regime, and how many switched.
1759 up_trials: u64,
1760 up_switches: u64,
1761 /// Trials made from the `-1` regime, and how many switched.
1762 down_trials: u64,
1763 down_switches: u64,
1764 }
1765
1766 fn tally_switches(
1767 steps: usize,
1768 lambda_up: Positive,
1769 lambda_down: Positive,
1770 ) -> Result<SwitchTally, SimulationError> {
1771 let params = create_test_params(
1772 steps + 1,
1773 1.0,
1774 100.0,
1775 WalkType::Telegraph {
1776 dt: pos_or_panic!(0.004),
1777 drift: Decimal::ZERO,
1778 volatility: pos_or_panic!(0.2),
1779 lambda_up,
1780 lambda_down,
1781 vol_multiplier_up: Some(pos_or_panic!(1.5)),
1782 vol_multiplier_down: Some(pos_or_panic!(0.5)),
1783 },
1784 );
1785 let vols = telegraph_walk(¶ms)?
1786 .vols
1787 .ok_or_else(|| SimulationError::walk_error("telegraph returned no vols"))?;
1788 assert_eq!(vols.len(), steps + 1);
1789 let up = pos_or_panic!(0.3);
1790 let down = pos_or_panic!(0.1);
1791 let mut tally = SwitchTally::default();
1792 // `vols[0]` is the base volatility, not a regime, so the first trial
1793 // has no known starting regime; every later trial starts from the
1794 // regime of the previous point.
1795 for pair in vols.windows(2).skip(1) {
1796 let (from, to) = (pair[0], pair[1]);
1797 assert!(to == up || to == down, "{to} is not a regime volatility");
1798 if from == up {
1799 tally.up_trials += 1;
1800 tally.up_switches += u64::from(to == down);
1801 } else {
1802 tally.down_trials += 1;
1803 tally.down_switches += u64::from(to == up);
1804 }
1805 }
1806 Ok(tally)
1807 }
1808
1809 /// Asserts that `switches` out of `trials` Bernoulli(`1 - e^(-λ·dt)`)
1810 /// trials lie within five standard errors, `5 * sqrt(n p (1 - p))`, of
1811 /// the mean `n p`. Each trial is independent of the past given its
1812 /// starting regime, so the count is binomial given the number of trials.
1813 fn assert_switch_frequency(switches: u64, trials: u64, lambda_dt: f64) {
1814 assert!(trials > 0, "no trial started from this regime");
1815 let n = trials as f64;
1816 let p = 1.0 - (-lambda_dt).exp();
1817 let mean = n * p;
1818 let bound = 5.0 * (n * p * (1.0 - p)).sqrt();
1819 assert!(
1820 (switches as f64 - mean).abs() < bound,
1821 "{switches} switches in {trials} trials, expected {mean} +- {bound}"
1822 );
1823 }
1824
1825 #[test]
1826 fn test_telegraph_switch_frequency_matches_one_minus_exp_lambda_dt()
1827 -> Result<(), SimulationError> {
1828 // #683: realistic regime rates on daily steps (dt = 0.004). Leaving
1829 // `+1` at λ = 12 a year gives p = 1 - e^(-0.048) = 0.046866, leaving
1830 // `-1` at λ = 4 a year gives p = 1 - e^(-0.016) = 0.015873. Over
1831 // 200_000 steps the chain spends about a quarter of them in `+1`
1832 // (λ_up / (λ_up + λ_down)), some 50_000 trials expecting 2_343
1833 // switches +- 236, and 150_000 in `-1` expecting 2_381 +- 242. The
1834 // former `(|z| + 1) / 2` sample is never below 0.5, so it never
1835 // switched at these rates.
1836 const STEPS: usize = 200_000;
1837 let tally = tally_switches(STEPS, pos_or_panic!(4.0), pos_or_panic!(12.0))?;
1838 assert_eq!(tally.up_trials + tally.down_trials, STEPS as u64 - 1);
1839 assert_switch_frequency(tally.up_switches, tally.up_trials, 12.0 * 0.004);
1840 assert_switch_frequency(tally.down_switches, tally.down_trials, 4.0 * 0.004);
1841 Ok(())
1842 }
1843
1844 #[test]
1845 fn test_telegraph_zero_rates_never_switch() -> Result<(), SimulationError> {
1846 let tally = tally_switches(1_000, Positive::ZERO, Positive::ZERO)?;
1847 assert_eq!(tally.up_switches + tally.down_switches, 0);
1848 Ok(())
1849 }
1850
1851 #[test]
1852 fn test_telegraph_flushed_rate_switches_every_step() -> Result<(), SimulationError> {
1853 // λ·dt = 100: `e^(-100)` is below the 28th decimal place, so the
1854 // switch probability is exactly one.
1855 let tally = tally_switches(1_000, pos_or_panic!(25_000.0), pos_or_panic!(25_000.0))?;
1856 assert_eq!(tally.up_switches, tally.up_trials);
1857 assert_eq!(tally.down_switches, tally.down_trials);
1858 Ok(())
1859 }
1860
1861 #[test]
1862 fn test_with_different_types() -> Result<(), SimulationError> {
1863 #[derive(Debug, Copy, Clone, PartialEq)]
1864 struct XType(f64);
1865
1866 impl From<XType> for Positive {
1867 fn from(val: XType) -> Self {
1868 pos_or_panic!(val.0)
1869 }
1870 }
1871
1872 impl AddAssign for XType {
1873 fn add_assign(&mut self, other: Self) {
1874 self.0 += other.0;
1875 }
1876 }
1877
1878 impl Display for XType {
1879 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1880 write!(f, "{}", self.0)
1881 }
1882 }
1883
1884 #[derive(Debug, Copy, Clone, PartialEq)]
1885 struct YType(f64);
1886
1887 impl From<YType> for Positive {
1888 fn from(val: YType) -> Self {
1889 pos_or_panic!(val.0)
1890 }
1891 }
1892
1893 impl Display for YType {
1894 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1895 write!(f, "{}", self.0)
1896 }
1897 }
1898
1899 let params = create_test_params(
1900 5,
1901 XType(10.0),
1902 YType(100.0),
1903 WalkType::Brownian {
1904 dt: Positive::ONE,
1905 drift: Decimal::ZERO,
1906 volatility: pos_or_panic!(0.2),
1907 },
1908 );
1909
1910 let walker = TestWalker {};
1911 let result = walker.brownian(¶ms)?;
1912
1913 assert_eq!(result.len(), 5);
1914 Ok(())
1915 }
1916
1917 #[test]
1918 fn test_error_handling() {
1919 #[derive(Clone)]
1920 struct ErrorWalker {}
1921
1922 impl<X, Y> WalkTypeAble<X, Y> for ErrorWalker
1923 where
1924 X: Copy + TryInto<Positive> + AddAssign + Display,
1925 Y: Copy + TryInto<Positive> + Display,
1926 {
1927 fn brownian(
1928 &self,
1929 _params: &WalkParams<X, Y>,
1930 ) -> Result<Vec<Positive>, SimulationError> {
1931 Err(SimulationError::walk_error("Error simulado para prueba"))
1932 }
1933
1934 fn geometric_brownian(
1935 &self,
1936 params: &WalkParams<X, Y>,
1937 ) -> Result<Vec<Positive>, SimulationError> {
1938 self.brownian(params)
1939 }
1940
1941 fn log_returns(
1942 &self,
1943 params: &WalkParams<X, Y>,
1944 ) -> Result<Vec<Positive>, SimulationError> {
1945 self.brownian(params)
1946 }
1947
1948 fn mean_reverting(
1949 &self,
1950 params: &WalkParams<X, Y>,
1951 ) -> Result<Vec<Positive>, SimulationError> {
1952 self.brownian(params)
1953 }
1954
1955 fn jump_diffusion(
1956 &self,
1957 params: &WalkParams<X, Y>,
1958 ) -> Result<Vec<Positive>, SimulationError> {
1959 self.brownian(params)
1960 }
1961
1962 fn garch(&self, params: &WalkParams<X, Y>) -> Result<Vec<Positive>, SimulationError> {
1963 self.brownian(params)
1964 }
1965
1966 fn heston(&self, params: &WalkParams<X, Y>) -> Result<Vec<Positive>, SimulationError> {
1967 self.brownian(params)
1968 }
1969
1970 fn custom(&self, params: &WalkParams<X, Y>) -> Result<Vec<Positive>, SimulationError> {
1971 self.brownian(params)
1972 }
1973 }
1974
1975 let params = create_test_params(
1976 5,
1977 10.0,
1978 100.0,
1979 WalkType::Brownian {
1980 dt: Positive::ONE,
1981 drift: Decimal::ZERO,
1982 volatility: pos_or_panic!(0.2),
1983 },
1984 );
1985
1986 let error_walker = ErrorWalker {};
1987 assert!(error_walker.brownian(¶ms).is_err());
1988 }
1989
1990 /// Review regression (#408/#416): the legacy price-path methods stay
1991 /// authoritative override points — overriding `heston` changes
1992 /// `generate`, and overriding `heston_with_vol` changes
1993 /// `generate_with_vol`; neither default silently reroutes through the
1994 /// other.
1995 #[test]
1996 fn test_price_path_and_with_vol_overrides_are_independent() {
1997 use crate::simulation::model::WalkPath;
1998
1999 #[derive(Clone)]
2000 struct RampHestonWalker {}
2001 impl WalkTypeAble<Positive, Positive> for RampHestonWalker {
2002 fn heston(
2003 &self,
2004 params: &WalkParams<Positive, Positive>,
2005 ) -> Result<Vec<Positive>, SimulationError> {
2006 let start = params.ystep_as_positive()?;
2007 Ok((0..params.size).map(|i| start + i as f64).collect())
2008 }
2009 }
2010
2011 let params = create_test_params(
2012 4,
2013 Positive::ONE,
2014 Positive::HUNDRED,
2015 WalkType::Heston {
2016 dt: pos_or_panic!(0.01),
2017 drift: Decimal::ZERO,
2018 volatility: pos_or_panic!(0.2),
2019 kappa: Positive::TWO,
2020 theta: pos_or_panic!(0.04),
2021 xi: pos_or_panic!(0.1),
2022 rho: Decimal::ZERO,
2023 },
2024 );
2025 let params = WalkParams {
2026 walker: Box::new(RampHestonWalker {}),
2027 ..params
2028 };
2029
2030 // generate() dispatches to the OVERRIDDEN heston: deterministic ramp.
2031 let walker = RampHestonWalker {};
2032 let prices = match walker.generate(¶ms) {
2033 Ok(prices) => prices,
2034 Err(e) => panic!("generate failed: {e}"),
2035 };
2036 assert_eq!(
2037 prices,
2038 vec![
2039 Positive::HUNDRED,
2040 pos_or_panic!(101.0),
2041 pos_or_panic!(102.0),
2042 pos_or_panic!(103.0)
2043 ],
2044 "generate must honor the heston override"
2045 );
2046
2047 // Overriding the *_with_vol sibling governs generate_with_vol.
2048 #[derive(Clone)]
2049 struct RampHestonVolWalker {}
2050 impl WalkTypeAble<Positive, Positive> for RampHestonVolWalker {
2051 fn heston_with_vol(
2052 &self,
2053 params: &WalkParams<Positive, Positive>,
2054 ) -> Result<WalkPath, SimulationError> {
2055 let start = params.ystep_as_positive()?;
2056 let prices: Vec<Positive> = (0..params.size).map(|i| start + i as f64).collect();
2057 let vols = vec![pos_or_panic!(0.5); params.size];
2058 Ok(WalkPath {
2059 prices,
2060 vols: Some(vols),
2061 })
2062 }
2063 }
2064 let params = WalkParams {
2065 walker: Box::new(RampHestonVolWalker {}),
2066 ..params
2067 };
2068 let walker = RampHestonVolWalker {};
2069 let path = match walker.generate_with_vol(¶ms) {
2070 Ok(path) => path,
2071 Err(e) => panic!("generate_with_vol failed: {e}"),
2072 };
2073 assert_eq!(path.vols, Some(vec![pos_or_panic!(0.5); 4]));
2074 assert_eq!(path.prices.first(), Some(&Positive::HUNDRED));
2075 }
2076
2077 /// Regression for issue #358: cloning through `Box<dyn WalkTypeAble>`
2078 /// delegates to `clone_box`, which forwards to the concrete walker's
2079 /// `Clone::clone`. Calling `.clone()` must not panic and must produce a
2080 /// walker that produces byte-identical output on the same input.
2081 #[test]
2082 fn test_box_dyn_walktypeable_clone_roundtrip() {
2083 // Deterministic walker so we can compare the full `Vec<Positive>`
2084 // output rather than just its length. `brownian` returns a ramp
2085 // seeded with the struct's `seed` field, which survives `Clone`.
2086 #[derive(Clone, Debug, PartialEq)]
2087 struct CountingWalker {
2088 seed: u32,
2089 }
2090 impl<X, Y> WalkTypeAble<X, Y> for CountingWalker
2091 where
2092 X: Copy + TryInto<Positive> + AddAssign + Display,
2093 Y: Copy + TryInto<Positive> + Display,
2094 {
2095 fn brownian(
2096 &self,
2097 params: &WalkParams<X, Y>,
2098 ) -> Result<Vec<Positive>, SimulationError> {
2099 let start = params.ystep_as_positive()?;
2100 let mut out = path_buffer(params.size, 0)?;
2101 out.push(start);
2102 for i in 1..params.size {
2103 let offset = Positive::new(f64::from(self.seed) + i as f64)?;
2104 out.push(start + offset);
2105 }
2106 Ok(out)
2107 }
2108 }
2109
2110 let original: Box<dyn WalkTypeAble<Positive, Positive>> =
2111 Box::new(CountingWalker { seed: 42 });
2112 // Would previously panic; now forwards through clone_box to
2113 // CountingWalker::clone.
2114 let cloned = original.clone();
2115
2116 let params = create_test_params(
2117 4,
2118 pos_or_panic!(1.0),
2119 Positive::HUNDRED,
2120 WalkType::Brownian {
2121 dt: pos_or_panic!(0.01),
2122 drift: Decimal::ZERO,
2123 volatility: pos_or_panic!(0.2),
2124 },
2125 );
2126 let original_out = original
2127 .brownian(¶ms)
2128 .expect("deterministic walker succeeds");
2129 let cloned_out = cloned.brownian(¶ms).expect("cloned walker succeeds");
2130 assert_eq!(original_out, cloned_out);
2131 }
2132}
2133
2134/// Every walk kernel reserved its path with `Vec::with_capacity(size + 1)`,
2135/// which overflowed at `size = usize::MAX` and hit `capacity overflow` for any
2136/// size whose points do not fit in `isize::MAX` bytes (#788).
2137#[cfg(test)]
2138mod tests_walk_size_panic_freedom {
2139 use super::*;
2140 use crate::simulation::steps::Step;
2141 use optionstratlib_core::model::ExpirationDate;
2142 use optionstratlib_core::utils::TimeFrame;
2143 use rust_decimal_macros::dec;
2144
2145 #[derive(Debug, Clone)]
2146 struct BareWalker;
2147
2148 impl WalkTypeAble<Positive, Positive> for BareWalker {}
2149
2150 fn walk_types() -> Vec<WalkType> {
2151 let dt = Positive::new_decimal(dec!(0.004)).unwrap_or(Positive::ONE);
2152 let volatility = Positive::new_decimal(dec!(0.2)).unwrap_or(Positive::ONE);
2153 vec![
2154 WalkType::Brownian {
2155 dt,
2156 drift: Decimal::ZERO,
2157 volatility,
2158 },
2159 WalkType::GeometricBrownian {
2160 dt,
2161 drift: Decimal::ZERO,
2162 volatility,
2163 },
2164 WalkType::LogReturns {
2165 dt,
2166 expected_return: Decimal::ZERO,
2167 volatility,
2168 autocorrelation: None,
2169 },
2170 WalkType::MeanReverting {
2171 dt,
2172 volatility,
2173 speed: Positive::ONE,
2174 mean: Positive::HUNDRED,
2175 },
2176 WalkType::JumpDiffusion {
2177 dt,
2178 drift: Decimal::ZERO,
2179 volatility,
2180 intensity: Positive::ONE,
2181 jump_mean: Decimal::ZERO,
2182 jump_volatility: volatility,
2183 },
2184 WalkType::Garch {
2185 dt,
2186 drift: Decimal::ZERO,
2187 volatility,
2188 alpha: Positive::new_decimal(dec!(0.1)).unwrap_or(Positive::ZERO),
2189 beta: Positive::new_decimal(dec!(0.8)).unwrap_or(Positive::ZERO),
2190 },
2191 WalkType::Heston {
2192 dt,
2193 drift: Decimal::ZERO,
2194 volatility,
2195 kappa: Positive::ONE,
2196 theta: volatility,
2197 xi: volatility,
2198 rho: Decimal::ZERO,
2199 },
2200 WalkType::Custom {
2201 dt,
2202 drift: Decimal::ZERO,
2203 volatility,
2204 vov: volatility,
2205 vol_speed: Positive::ONE,
2206 vol_mean: volatility,
2207 },
2208 WalkType::Telegraph {
2209 dt,
2210 drift: Decimal::ZERO,
2211 volatility,
2212 lambda_up: Positive::ONE,
2213 lambda_down: Positive::ONE,
2214 vol_multiplier_up: None,
2215 vol_multiplier_down: None,
2216 },
2217 ]
2218 }
2219
2220 #[test]
2221 fn test_walk_kernels_unrepresentable_size_return_error() {
2222 for size in [usize::MAX, usize::MAX / 2] {
2223 for walk_type in walk_types() {
2224 let params = WalkParams {
2225 size,
2226 init_step: Step::new(
2227 Positive::ONE,
2228 TimeFrame::Day,
2229 ExpirationDate::Days(Positive::HUNDRED),
2230 Positive::HUNDRED,
2231 ),
2232 walk_type: walk_type.clone(),
2233 walker: Box::new(BareWalker),
2234 seed: Some(788),
2235 };
2236 let result = BareWalker.generate(¶ms);
2237 assert!(
2238 matches!(result, Err(SimulationError::InvalidParameters { .. })),
2239 "size {size}, {walk_type:?}: {result:?}"
2240 );
2241 }
2242 }
2243 }
2244}