Skip to main content

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 &params.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 &params.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 &params.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(&params)?;
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(&params)?;
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(&params)?;
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(&params)?;
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(&params)?;
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(&params)?;
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(&params)?;
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(&params)?;
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(&params)?;
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(&params)?;
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(&params)?
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(&params)?;
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(&params).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(&params) {
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(&params) {
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(&params)
2128            .expect("deterministic walker succeeds");
2129        let cloned_out = cloned.brownian(&params).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(&params);
2237                assert!(
2238                    matches!(result, Err(SimulationError::InvalidParameters { .. })),
2239                    "size {size}, {walk_type:?}: {result:?}"
2240                );
2241            }
2242        }
2243    }
2244}