Skip to main content

optionstratlib_simulation/simulation/
walk_driver.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 12/7/26
5******************************************************************************/
6//! Generic walk driver shared by every step generator.
7//!
8//! The three public generators (`generator_positive` here, plus
9//! `optionstratlib_market::chains::generator_optionchain` and `optionstratlib_market::series::generator_optionseries`,
10//! both behind the `synthetic` feature) used to duplicate the same
11//! algorithm: dispatch on [`WalkType`], extract the walk volatility, skip the
12//! first walker value, advance the x-step until expiration, build the next
13//! y-value and cap the result at `WalkParams::size`. [`walk_steps`] is the
14//! single implementation of that algorithm; each generator only supplies the
15//! closure that builds its `Y` value from the new price.
16
17use crate::error::SimulationError;
18use crate::simulation::steps::{Step, Xstep, Ystep};
19use crate::simulation::{WalkParams, WalkType};
20use optionstratlib_core::model::Positive;
21use optionstratlib_core::model::decimal::d_sqrt;
22use optionstratlib_core::utils::TimeFrame;
23use optionstratlib_core::utils::numeric::calculate_log_returns;
24use optionstratlib_core::utils::time::ReferenceCarrier;
25use optionstratlib_pricing::volatility::{adjust_volatility, constant_volatility};
26use rust_decimal::Decimal;
27use std::convert::TryInto;
28use std::fmt::Display;
29use tracing::debug;
30
31/// Annualized volatility driving the walk.
32///
33/// For the nine synthetic walk types this is the variant's `volatility`
34/// parameter (see [`WalkType::volatility`]). For [`WalkType::Historical`] it
35/// is the constant volatility estimated from the log returns of the full
36/// price history, annualized from the history's timeframe.
37///
38/// # Errors
39///
40/// Propagates errors from the historical helpers (`calculate_log_returns`,
41/// `constant_volatility`, `adjust_volatility`) as [`SimulationError`], so
42/// the driver stays free of chain-layer error types.
43fn walk_volatility<Y>(
44    walk_params: &WalkParams<Positive, Y>,
45) -> Result<Option<Positive>, SimulationError>
46where
47    Y: TryInto<Positive> + Display + Clone,
48{
49    match &walk_params.walk_type {
50        WalkType::Historical {
51            timeframe, prices, ..
52        } => {
53            let log_returns: Vec<Decimal> = calculate_log_returns(prices)?;
54            let constant_volatility = constant_volatility(&log_returns)?;
55            let implied_volatility =
56                adjust_volatility(constant_volatility, *timeframe, TimeFrame::Year)?;
57            Ok(Some(implied_volatility))
58        }
59        walk_type => Ok(walk_type.volatility()),
60    }
61}
62
63/// Per-step expanding-window volatility estimates for a price path, free of
64/// look-ahead bias: the estimate at index `i` uses only the log returns of
65/// `prices[..=i]`.
66///
67/// This is the estimator [`walk_steps`] and [`walk_steps_par`] use for
68/// [`WalkType::Historical`], whose volatility is not a model parameter and has
69/// to be estimated from the series ([`WalkType::volatility`] returns `None` for
70/// it). It is public so that a consumer pricing a historical walk outside the
71/// driver — generating the path with
72/// [`crate::simulation::WalkTypeAble::generate_with_vol`] and building its own
73/// chains, say — uses the same numbers rather than a second copy of the
74/// mathematics that can drift release to release.
75///
76/// Uses the same sample-variance convention as
77/// [`optionstratlib_pricing::volatility::constant_volatility`], computed incrementally with
78/// checked arithmetic, and annualizes each estimate from `timeframe`.
79///
80/// # Contract
81///
82/// * The result has exactly `prices.len()` elements, so it indexes in step with
83///   the price path.
84/// * Element `i` is a function of `prices[..=i]` only. Appending prices never
85///   changes an earlier element.
86/// * Indices with fewer than two returns (0 and 1) cannot carry a sample
87///   variance and are backfilled with the first computable estimate, which
88///   keeps the series usable from step 0 without inventing a number from the
89///   future.
90/// * `Ok(None)` means no index had enough data — fewer than three prices.
91///
92/// # Errors
93///
94/// Propagates errors from [`optionstratlib_core::utils::numeric::calculate_log_returns`] /
95/// [`optionstratlib_pricing::volatility::adjust_volatility`] and surfaces arithmetic overflow as
96/// [`SimulationError`], keeping the driver free of chain-layer error types.
97///
98/// # Examples
99///
100/// ```
101/// use optionstratlib_simulation::simulation::expanding_window_vols;
102/// use optionstratlib_core::utils::TimeFrame;
103/// use optionstratlib_core::model::Positive;
104/// use optionstratlib_core::pos_or_panic;
105///
106/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
107/// // A flat prefix followed by a jump: the early estimates cannot see it.
108/// let mut prices = vec![Positive::HUNDRED; 6];
109/// prices.push(pos_or_panic!(200.0));
110///
111/// let vols = expanding_window_vols(&prices, TimeFrame::Day)?
112///     .expect("seven prices are enough for a sample variance");
113///
114/// assert_eq!(vols.len(), prices.len());
115/// assert_eq!(vols[4], Positive::ZERO); // flat window so far
116/// assert!(vols[6] > Positive::ZERO); // the jump is now in the window
117/// # Ok(())
118/// # }
119/// ```
120pub fn expanding_window_vols(
121    prices: &[Positive],
122    timeframe: TimeFrame,
123) -> Result<Option<Vec<Positive>>, SimulationError> {
124    let n = prices.len();
125    if n < 3 {
126        // Fewer than two returns anywhere: no sample variance is computable.
127        return Ok(None);
128    }
129    let log_returns: Vec<Decimal> = calculate_log_returns(prices)?;
130
131    let overflow =
132        |what: &str| SimulationError::walk_error(&format!("expanding-window {what} overflowed"));
133
134    let mut sum = Decimal::ZERO;
135    let mut sq_sum = Decimal::ZERO;
136    let mut raw: Vec<Option<Positive>> = Vec::with_capacity(n);
137    raw.push(None); // index 0 has no returns yet
138
139    for (i, r) in log_returns.iter().copied().enumerate() {
140        sum = sum
141            .checked_add(r)
142            .ok_or_else(|| overflow("running sum of log returns"))?;
143        let r_sq = r
144            .checked_mul(r)
145            .ok_or_else(|| overflow("squared log return"))?;
146        sq_sum = sq_sum
147            .checked_add(r_sq)
148            .ok_or_else(|| overflow("running sum of squared log returns"))?;
149
150        // `r` is the return between prices[i] and prices[i + 1]; after
151        // consuming it, `count` returns are available at price index i + 1.
152        let count = i.checked_add(1).ok_or_else(|| overflow("return count"))?;
153        if count < 2 {
154            raw.push(None);
155            continue;
156        }
157        let count_dec = Decimal::from(count as u64);
158        let denom = count_dec
159            .checked_sub(Decimal::ONE)
160            .ok_or_else(|| overflow("sample size minus one"))?;
161        // Sample variance via prefix sums: (Σr² − (Σr)²/n) / (n − 1),
162        // algebraically identical to the two-pass form in
163        // `constant_volatility`.
164        let sum_sq = sum
165            .checked_mul(sum)
166            .ok_or_else(|| overflow("squared running sum"))?;
167        let mean_sq_total = sum_sq
168            .checked_div(count_dec)
169            .ok_or_else(|| overflow("mean projection"))?;
170        let variance = sq_sum
171            .checked_sub(mean_sq_total)
172            .ok_or_else(|| overflow("variance numerator"))?
173            .checked_div(denom)
174            .ok_or_else(|| overflow("variance"))?
175            .max(Decimal::ZERO);
176        let std_dev = d_sqrt(variance, "simulation::walk_driver::volatility_sqrt")
177            .map_err(|_| overflow("volatility sqrt"))?;
178        let std_dev = Positive::new_decimal(std_dev).unwrap_or(Positive::ZERO);
179        let annualized = adjust_volatility(std_dev, timeframe, TimeFrame::Year)?;
180        raw.push(Some(annualized));
181    }
182
183    let first_computable = raw.iter().flatten().next().copied();
184    match first_computable {
185        None => Ok(None),
186        Some(fill) => Ok(Some(
187            raw.into_iter().map(|vol| vol.unwrap_or(fill)).collect(),
188        )),
189    }
190}
191
192/// Runs a walk and materializes it as a vector of [`Step`]s, delegating the
193/// construction of each y-value to `next_y`.
194///
195/// This is the shared driver behind [`generator_positive`] and, behind the
196/// `synthetic` feature, `optionstratlib_market::chains::generator_optionchain` and
197/// `optionstratlib_market::series::generator_optionseries`.
198///
199/// # Contract
200///
201/// * The returned vector always starts with `walk_params.init_step`.
202/// * If the walker yields no values beyond the initial one (e.g. a size-1
203///   walk), only the initial step is returned.
204/// * The walk is truncated when the x-step reaches expiration
205///   (`SimulationError::ExpirationReached`) or when `next_y` returns
206///   `Ok(None)`; any other step-advance error is propagated.
207/// * The result is truncated to at most `walk_params.size` steps.
208/// * With `walk_params.seed` set, the path, and therefore the result for a
209///   pure `next_y`, is identical on every run (see
210///   [`WalkParams`]); [`walk_steps_par`] returns the same steps.
211///
212/// # Parameters
213///
214/// * `walk_params` - The walk configuration; `walk_params.walk_type` selects
215///   the stochastic process via
216///   [`crate::simulation::WalkTypeAble::generate_with_vol`].
217/// * `next_y` - Builds the y-value for one step from `(new_price,
218///   step_volatility, advanced_x_step)`. Returning `Ok(None)` ends the walk
219///   gracefully at the previous step.
220///
221/// # Volatility
222///
223/// The volatility passed to `next_y` is per-step wherever the model provides
224/// one: the simulated vol path for stochastic-volatility walk types
225/// (`Garch`, `Heston`, `Custom`, `Telegraph`), an expanding-window estimate
226/// free of look-ahead bias for `Historical` walks, and the walk type's
227/// constant `volatility` parameter otherwise.
228///
229/// # Returns
230///
231/// * `Ok(Vec<Step<Positive, Y>>)` - The simulated walk.
232/// * `Err(E)` - If the walker, the volatility estimation, the x-step
233///   advance, or `next_y` fail.
234///
235/// # Errors
236///
237/// The driver is generic over its error type: every internal failure is a
238/// [`SimulationError`] lifted into `E` via `From`, so the simulation layer
239/// carries no chain-specific error types — the chain and series generators
240/// simply instantiate `E = ChainError`. Walker errors include
241/// `SimulationError::InsufficientHistoricalData` when a `Historical` walk
242/// has fewer prices than `walk_params.size`; `next_y` errors are returned
243/// verbatim.
244pub fn walk_steps<Y, E, F>(
245    walk_params: &WalkParams<Positive, Y>,
246    mut next_y: F,
247) -> Result<Vec<Step<Positive, Y>>, E>
248where
249    Y: TryInto<Positive> + Display + Clone,
250    E: From<SimulationError>,
251    F: FnMut(&Positive, Option<Positive>, &Xstep<Positive>) -> Result<Option<Y>, E>,
252{
253    debug!("{}", walk_params);
254    let path = walk_params
255        .walker
256        .generate_with_vol(walk_params)
257        .map_err(E::from)?;
258    let y_steps = path.prices;
259    if y_steps.len() <= 1 {
260        // Preserve the init-step invariant when the walker produces no
261        // values beyond the initial one; downstream consumers expect at
262        // least the initial step to be present.
263        return Ok(vec![walk_params.init_step.clone()]);
264    }
265
266    // Per-step volatilities: from the walker when the model simulates a vol
267    // path (Garch/Heston/Custom/Telegraph); for Historical walks, an
268    // expanding-window estimate over the walked prices that uses no future
269    // data. Otherwise a single constant from the walk type.
270    let step_vols: Option<Vec<Positive>> = match path.vols {
271        Some(vols) => Some(vols),
272        None => match &walk_params.walk_type {
273            WalkType::Historical { timeframe, .. } => {
274                expanding_window_vols(&y_steps, *timeframe).map_err(E::from)?
275            }
276            _ => None,
277        },
278    };
279    let constant_vol = if step_vols.is_some() {
280        None
281    } else {
282        walk_volatility(walk_params).map_err(E::from)?
283    };
284
285    let mut steps: Vec<Step<Positive, Y>> = vec![walk_params.init_step.clone()];
286    let mut previous_x_step = walk_params.init_step.x;
287    let mut y_index = *walk_params.ystep_ref().index();
288
289    // The first walker value duplicates the init step, so skip it.
290    for (i, y_step) in y_steps.iter().enumerate().skip(1) {
291        previous_x_step = match previous_x_step.next() {
292            Ok(x_step) => x_step,
293            // Reaching expiration is the normal end of a walk: truncate.
294            Err(SimulationError::ExpirationReached) => break,
295            // Any other step-advance failure is a real error: propagate.
296            Err(e) => return Err(E::from(e)),
297        };
298        let volatility = step_vols
299            .as_ref()
300            .and_then(|vols| vols.get(i).copied())
301            .or(constant_vol);
302        let Some(y_value) = next_y(y_step, volatility, &previous_x_step)? else {
303            break;
304        };
305        // The initial index is caller-supplied, so a walk seeded at
306        // `i32::MAX` overflows on its first step.
307        y_index = y_index.checked_add(1).ok_or_else(|| {
308            E::from(SimulationError::step_error(
309                "step index overflowed advancing the walk",
310            ))
311        })?;
312        steps.push(Step {
313            x: previous_x_step,
314            y: Ystep::new(y_index, y_value),
315        });
316    }
317
318    if steps.len() > walk_params.size {
319        debug!(
320            "walk produced {} steps, truncating to configured size {}",
321            steps.len(),
322            walk_params.size
323        );
324        steps.truncate(walk_params.size);
325    }
326    Ok(steps)
327}
328
329/// Parallel variant of [`walk_steps`]: identical contract and output, with
330/// the per-step `next_y` calls executed on the rayon thread pool.
331///
332/// The walk itself (walker output, per-step volatilities, x-step sequence)
333/// is computed serially and is identical to [`walk_steps`]; only the y-value
334/// construction — the dominant cost when `next_y` rebuilds an option chain
335/// or series — is fanned out. Output is deterministic and equal to the
336/// serial driver's for the same inputs because every per-step input is
337/// precomputed and order is preserved on collection.
338///
339/// # Sequential/parallel equivalence
340///
341/// * The price path is drawn once, serially, from a single generator before
342///   anything is fanned out: `deterministic_rng(seed)` when
343///   [`WalkParams::seed`] is `Some(seed)`, the thread RNG otherwise. No
344///   random number is drawn on a rayon worker, so the thread count and the
345///   scheduling cannot reach the random stream.
346/// * Nothing is reduced across steps in parallel; the y-values are assembled
347///   in step order, so no `Decimal` or `f64` result depends on the order in
348///   which workers finish.
349/// * Hence, for a seeded walk and a `next_y` that is a pure function of its
350///   arguments, `walk_steps_par(p, f)` equals `walk_steps(p, f)` bit for bit,
351///   on every run.
352/// * An unseeded walk draws a new path on every call, serial or parallel, so
353///   two calls agree only in distribution.
354///
355/// Semantics notes versus the serial driver:
356///
357/// * `next_y` must be `Fn + Sync` (stateless or internally synchronized)
358///   instead of `FnMut`.
359/// * `Ok(None)` still ends the walk at the previous step; steps past the
360///   first `None` are computed speculatively and their results — values AND
361///   errors — are discarded, exactly as the serial driver never evaluates
362///   them.
363/// * On failure, the first error in step order that precedes any stop
364///   signal is returned.
365///
366/// # Errors
367///
368/// Same as [`walk_steps`].
369pub fn walk_steps_par<Y, E, F>(
370    walk_params: &WalkParams<Positive, Y>,
371    next_y: F,
372) -> Result<Vec<Step<Positive, Y>>, E>
373where
374    Y: TryInto<Positive> + Display + Clone + Send,
375    E: From<SimulationError> + Send,
376    F: Fn(&Positive, Option<Positive>, &Xstep<Positive>) -> Result<Option<Y>, E> + Sync,
377{
378    use rayon::prelude::*;
379
380    debug!("{}", walk_params);
381    let path = walk_params
382        .walker
383        .generate_with_vol(walk_params)
384        .map_err(E::from)?;
385    let y_steps = path.prices;
386    if y_steps.len() <= 1 {
387        // Preserve the init-step invariant when the walker produces no
388        // values beyond the initial one; downstream consumers expect at
389        // least the initial step to be present.
390        return Ok(vec![walk_params.init_step.clone()]);
391    }
392
393    // Per-step volatilities: same policy as the serial driver.
394    let step_vols: Option<Vec<Positive>> = match path.vols {
395        Some(vols) => Some(vols),
396        None => match &walk_params.walk_type {
397            WalkType::Historical { timeframe, .. } => {
398                expanding_window_vols(&y_steps, *timeframe).map_err(E::from)?
399            }
400            _ => None,
401        },
402    };
403    let constant_vol = if step_vols.is_some() {
404        None
405    } else {
406        walk_volatility(walk_params).map_err(E::from)?
407    };
408
409    // Precompute the x-step sequence serially, honoring the
410    // expiration-truncation contract; Xstep::next is deterministic.
411    let mut x_steps: Vec<Xstep<Positive>> = Vec::with_capacity(y_steps.iter().skip(1).len());
412    let mut current_x = walk_params.init_step.x;
413    for _ in 1..y_steps.len() {
414        current_x = match current_x.next() {
415            Ok(x_step) => x_step,
416            // Reaching expiration is the normal end of a walk: truncate.
417            Err(SimulationError::ExpirationReached) => break,
418            // Any other step-advance failure is a real error: propagate.
419            Err(e) => return Err(E::from(e)),
420        };
421        x_steps.push(current_x);
422    }
423
424    // Every step's inputs are known up front: build the y-values in
425    // parallel, preserving order. Collect WITHOUT short-circuiting so a
426    // speculative error past a stop signal cannot mask the stop: the
427    // ordered assembly below decides what the serial driver would have
428    // observed.
429    let carrier = ReferenceCarrier::capture();
430    let built: Vec<Result<Option<Y>, E>> = x_steps
431        .par_iter()
432        .enumerate()
433        .map(|(offset, x_step)| {
434            let price_index = offset
435                .checked_add(1)
436                .ok_or_else(|| E::from(SimulationError::walk_error("x-step offset overflowed")))?;
437            let y_step = y_steps.get(price_index).copied().ok_or_else(|| {
438                E::from(SimulationError::walk_error(
439                    "walker path shorter than x-steps",
440                ))
441            })?;
442            let volatility = step_vols
443                .as_ref()
444                .and_then(|vols| vols.get(price_index).copied())
445                .or(constant_vol);
446            carrier.run(|| next_y(&y_step, volatility, x_step))
447        })
448        .collect();
449
450    // Assemble serially in step order, mirroring the serial driver exactly:
451    // an `Err` is propagated only if it occurs before any `Ok(None)` stop
452    // signal; results past the stop are speculative and discarded.
453    let mut steps: Vec<Step<Positive, Y>> = vec![walk_params.init_step.clone()];
454    let mut y_index = *walk_params.ystep_ref().index();
455    for (x_step, y_result) in x_steps.into_iter().zip(built) {
456        let y_value = match y_result {
457            Ok(Some(y_value)) => y_value,
458            // The serial driver would have stopped here and never evaluated
459            // the remaining steps.
460            Ok(None) => break,
461            Err(e) => return Err(e),
462        };
463        // The initial index is caller-supplied, so a walk seeded at
464        // `i32::MAX` overflows on its first step.
465        y_index = y_index.checked_add(1).ok_or_else(|| {
466            E::from(SimulationError::step_error(
467                "step index overflowed advancing the walk",
468            ))
469        })?;
470        steps.push(Step {
471            x: x_step,
472            y: Ystep::new(y_index, y_value),
473        });
474    }
475
476    if steps.len() > walk_params.size {
477        debug!(
478            "walk produced {} steps, truncating to configured size {}",
479            steps.len(),
480            walk_params.size
481        );
482        steps.truncate(walk_params.size);
483    }
484    Ok(steps)
485}
486
487/// Generates a vector of `Step`s containing `Positive` x-values and `Positive` y-values.
488///
489/// Simulates the stochastic process selected by `walk_params.walk_type` for a plain
490/// positive value (typically an underlying price) without rebuilding option chains.
491///
492/// # Contract (shared with `optionstratlib_market::chains::generator_optionchain` and
493/// `optionstratlib_market::series::generator_optionseries`, both behind the `synthetic` feature)
494///
495/// * The returned vector always starts with `walk_params.init_step`.
496/// * If the walker yields no values beyond the initial one (e.g. a size-1 walk),
497///   only the initial step is returned.
498/// * The walk is truncated when the x-step reaches expiration
499///   (`SimulationError::ExpirationReached`); any other step-advance error is propagated.
500/// * The result is truncated to at most `walk_params.size` steps.
501///
502/// # Arguments
503///
504/// * `walk_params` - A reference to the `WalkParams` struct containing the walk parameters.
505///
506/// # Returns
507///
508/// * `Ok(Vec<Step<Positive, Positive>>)` - A vector of `Step`s representing the simulated walk.
509/// * `Err(ChainError)` - If the underlying simulator fails for any reason.
510///
511/// # Errors
512///
513/// Returns [`SimulationError`] if the random-walk generator fails — including
514/// `SimulationError::InsufficientHistoricalData` when a `Historical` walk has fewer
515/// prices than `walk_params.size`.
516pub fn generator_positive(
517    walk_params: &WalkParams<Positive, Positive>,
518) -> Result<Vec<Step<Positive, Positive>>, SimulationError> {
519    // The generator reports the error of its own layer (#511); a market
520    // market caller maps it into its own error where it uses it.
521    walk_steps(walk_params, |new_price, _volatility, _x_step| {
522        Ok(Some(*new_price))
523    })
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use crate::simulation::WalkTypeAble;
530    use crate::simulation::randomwalk::RandomWalk;
531    use optionstratlib_core::model::ExpirationDate;
532    use optionstratlib_core::pos_or_panic;
533    use optionstratlib_core::utils::time::convert_time_frame;
534    use optionstratlib_core::utils::{Len, TimeFrame};
535    use rust_decimal_macros::dec;
536
537    /// Seed of the simulated walks, so every run draws the same stream.
538    const SEED: u64 = 685;
539
540    #[derive(Clone)]
541    struct Walker {}
542    impl Walker {
543        fn new() -> Self {
544            Walker {}
545        }
546    }
547    impl WalkTypeAble<Positive, Positive> for Walker {}
548
549    #[test]
550    fn test_generator_positive() -> Result<(), Box<dyn std::error::Error>> {
551        let n_steps = 100;
552        let initial_price = Positive::HUNDRED;
553        let std_dev = pos_or_panic!(20.0);
554        let walker = Box::new(Walker::new());
555        let days = pos_or_panic!(30.0);
556
557        let walk_params = WalkParams {
558            size: n_steps,
559            init_step: Step {
560                x: Xstep::new(Positive::ONE, TimeFrame::Minute, ExpirationDate::Days(days)),
561                y: Ystep::new(0, initial_price),
562            },
563            walk_type: WalkType::GeometricBrownian {
564                dt: convert_time_frame(Positive::ONE / days, &TimeFrame::Minute, &TimeFrame::Day)?,
565                drift: dec!(0.0),
566                volatility: std_dev,
567            },
568            walker,
569            seed: Some(SEED),
570        };
571        let random_walk =
572            RandomWalk::new("Random Walk".to_string(), &walk_params, generator_positive)
573                .expect("random walk construction");
574        assert_eq!(random_walk.len(), n_steps);
575        Ok(())
576    }
577
578    #[test]
579    fn test_generator_positive_early_return() {
580        let initial_price = Positive::HUNDRED;
581        let walker = Box::new(Walker::new());
582
583        let walk_params = WalkParams {
584            size: 1, // Just one step to trigger early return
585            init_step: Step {
586                x: Xstep::new(
587                    Positive::ONE,
588                    TimeFrame::Minute,
589                    ExpirationDate::Days(pos_or_panic!(30.0)),
590                ),
591                y: Ystep::new(0, initial_price),
592            },
593            walk_type: WalkType::GeometricBrownian {
594                dt: pos_or_panic!(0.01),
595                drift: dec!(0.0),
596                volatility: pos_or_panic!(0.2),
597            },
598            walker,
599            seed: Some(SEED),
600        };
601
602        let steps = match generator_positive(&walk_params) {
603            Ok(steps) => steps,
604            Err(e) => panic!("generator_positive failed: {e}"),
605        };
606
607        // We should just get the initial step back
608        assert_eq!(steps.len(), 1);
609    }
610
611    /// A walker that returns an empty path; the driver must fall back to the
612    /// init-only walk instead of panicking or erroring.
613    #[test]
614    fn test_walk_steps_empty_walker_output() {
615        #[derive(Clone)]
616        struct EmptyWalker {}
617        impl WalkTypeAble<Positive, Positive> for EmptyWalker {
618            fn brownian(
619                &self,
620                _params: &WalkParams<Positive, Positive>,
621            ) -> Result<Vec<Positive>, SimulationError> {
622                Ok(Vec::new())
623            }
624        }
625
626        let walk_params = WalkParams {
627            size: 5,
628            init_step: Step {
629                x: Xstep::new(
630                    Positive::ONE,
631                    TimeFrame::Day,
632                    ExpirationDate::Days(pos_or_panic!(30.0)),
633                ),
634                y: Ystep::new(0, Positive::HUNDRED),
635            },
636            walk_type: WalkType::Brownian {
637                dt: pos_or_panic!(0.01),
638                drift: dec!(0.0),
639                volatility: pos_or_panic!(0.2),
640            },
641            walker: Box::new(EmptyWalker {}),
642            seed: None,
643        };
644
645        let steps = match generator_positive(&walk_params) {
646            Ok(steps) => steps,
647            Err(e) => panic!("empty walker output must not error: {e}"),
648        };
649        assert_eq!(steps.len(), 1, "empty walker output yields init-only walk");
650    }
651
652    /// Stochastic-vol walkers must expose a vol path aligned with prices
653    /// and actually varying when vol-of-vol is high.
654    #[test]
655    fn test_heston_with_vol_exposes_varying_vols() {
656        use rust_decimal_macros::dec;
657        let walker = Walker::new();
658        let params: WalkParams<Positive, Positive> = WalkParams {
659            size: 50,
660            init_step: Step {
661                x: Xstep::new(
662                    Positive::ONE,
663                    TimeFrame::Day,
664                    ExpirationDate::Days(pos_or_panic!(365.0)),
665                ),
666                y: Ystep::new(0, Positive::HUNDRED),
667            },
668            walk_type: WalkType::Heston {
669                dt: pos_or_panic!(0.004),
670                drift: dec!(0.0),
671                volatility: pos_or_panic!(0.2),
672                kappa: Positive::TWO,
673                theta: pos_or_panic!(0.04),
674                xi: Positive::TWO, // high vol-of-vol so the path must move
675                rho: dec!(-0.5),
676            },
677            walker: Box::new(Walker::new()),
678            seed: Some(SEED),
679        };
680        let path = match walker.heston_with_vol(&params) {
681            Ok(path) => path,
682            Err(e) => panic!("heston_with_vol failed: {e}"),
683        };
684        let vols = match path.vols {
685            Some(vols) => vols,
686            None => panic!("heston must expose a vol path"),
687        };
688        assert_eq!(vols.len(), path.prices.len());
689        assert_eq!(vols.first(), Some(&pos_or_panic!(0.2)));
690        let varying = vols.windows(2).any(|w| w[0] != w[1]);
691        assert!(varying, "high vol-of-vol Heston vol path cannot be flat");
692    }
693
694    /// Constant-vol walk types expose no vol path.
695    #[test]
696    fn test_generate_with_vol_none_for_gbm() {
697        use rust_decimal_macros::dec;
698        let walker = Walker::new();
699        let params: WalkParams<Positive, Positive> = WalkParams {
700            size: 10,
701            init_step: Step {
702                x: Xstep::new(
703                    Positive::ONE,
704                    TimeFrame::Day,
705                    ExpirationDate::Days(pos_or_panic!(30.0)),
706                ),
707                y: Ystep::new(0, Positive::HUNDRED),
708            },
709            walk_type: WalkType::GeometricBrownian {
710                dt: pos_or_panic!(0.01),
711                drift: dec!(0.0),
712                volatility: pos_or_panic!(0.2),
713            },
714            walker: Box::new(Walker::new()),
715            seed: Some(SEED),
716        };
717        let path = match walker.generate_with_vol(&params) {
718            Ok(path) => path,
719            Err(e) => panic!("generate_with_vol failed: {e}"),
720        };
721        assert!(path.vols.is_none());
722        assert_eq!(path.prices.len(), 10);
723    }
724
725    /// The expanding-window Historical estimator must not see future data:
726    /// with a flat prefix followed by a jump, the early estimates are zero
727    /// while the post-jump estimates are strictly positive.
728    #[test]
729    fn test_expanding_window_vols_no_look_ahead() {
730        let mut prices = vec![Positive::HUNDRED; 8];
731        prices.push(pos_or_panic!(200.0));
732        prices.push(pos_or_panic!(150.0));
733
734        let vols = match expanding_window_vols(&prices, TimeFrame::Day) {
735            Ok(Some(vols)) => vols,
736            Ok(None) => panic!("estimator returned no vols"),
737            Err(e) => panic!("estimator failed: {e}"),
738        };
739        assert_eq!(vols.len(), prices.len());
740        // Index 5 sees only the flat prefix: zero variance, zero vol.
741        assert_eq!(vols.get(5), Some(&Positive::ZERO));
742        // The last index has seen the jump: strictly positive vol.
743        match vols.last() {
744            Some(last) => assert!(*last > Positive::ZERO),
745            None => panic!("empty vols"),
746        }
747    }
748
749    /// The public contract of the estimator: element `i` depends on
750    /// `prices[..=i]` only, so extending the series must leave every earlier
751    /// estimate byte-identical. This is the property a consumer pricing a
752    /// historical walk outside the driver relies on (#423).
753    #[test]
754    fn test_expanding_window_vols_prefix_is_stable() {
755        let prices = vec![
756            Positive::HUNDRED,
757            pos_or_panic!(101.5),
758            pos_or_panic!(99.25),
759            pos_or_panic!(104.0),
760            pos_or_panic!(97.5),
761            pos_or_panic!(110.0),
762            pos_or_panic!(108.75),
763        ];
764
765        let full = match expanding_window_vols(&prices, TimeFrame::Day) {
766            Ok(Some(vols)) => vols,
767            Ok(None) => panic!("estimator returned no vols"),
768            Err(e) => panic!("estimator failed: {e}"),
769        };
770
771        // Every prefix of length >= 3 must reproduce the head of `full`.
772        for len in 3..=prices.len() {
773            let prefix = match prices.get(..len) {
774                Some(prefix) => prefix,
775                None => panic!("prefix of len {len} missing"),
776            };
777            let vols = match expanding_window_vols(prefix, TimeFrame::Day) {
778                Ok(Some(vols)) => vols,
779                Ok(None) => panic!("prefix of len {len} returned no vols"),
780                Err(e) => panic!("estimator failed on prefix {len}: {e}"),
781            };
782            assert_eq!(vols.len(), len, "one estimate per price");
783            match full.get(..len) {
784                Some(head) => assert_eq!(vols, head, "prefix of len {len} drifted"),
785                None => panic!("full series shorter than {len}"),
786            }
787        }
788    }
789
790    /// Fewer than three prices cannot carry a sample variance, and the
791    /// estimator says so instead of inventing one.
792    #[test]
793    fn test_expanding_window_vols_needs_three_prices() {
794        for len in 0..3 {
795            let prices = vec![Positive::HUNDRED; len];
796            match expanding_window_vols(&prices, TimeFrame::Day) {
797                Ok(None) => {}
798                Ok(Some(vols)) => panic!("len {len} unexpectedly produced {vols:?}"),
799                Err(e) => panic!("estimator failed on len {len}: {e}"),
800            }
801        }
802    }
803
804    /// The final estimate must equal the whole-series constant volatility,
805    /// annualized the same way: by the last index the expanding window is the
806    /// full window, so the incremental prefix-sum form and the two-pass form in
807    /// `constant_volatility` have to agree.
808    #[test]
809    fn test_expanding_window_vols_last_matches_constant_volatility() {
810        let prices = vec![
811            Positive::HUNDRED,
812            pos_or_panic!(102.0),
813            pos_or_panic!(98.5),
814            pos_or_panic!(103.25),
815            pos_or_panic!(101.0),
816        ];
817
818        let vols = match expanding_window_vols(&prices, TimeFrame::Day) {
819            Ok(Some(vols)) => vols,
820            Ok(None) => panic!("estimator returned no vols"),
821            Err(e) => panic!("estimator failed: {e}"),
822        };
823        let last = match vols.last() {
824            Some(last) => *last,
825            None => panic!("empty vols"),
826        };
827
828        let log_returns = match calculate_log_returns(&prices) {
829            Ok(returns) => returns,
830            Err(e) => panic!("log returns failed: {e}"),
831        };
832        let expected = match constant_volatility(&log_returns)
833            .and_then(|vol| adjust_volatility(vol, TimeFrame::Day, TimeFrame::Year))
834        {
835            Ok(expected) => expected,
836            Err(e) => panic!("reference volatility failed: {e}"),
837        };
838
839        let diff = (last.to_dec() - expected.to_dec()).abs();
840        assert!(
841            diff < dec!(0.000_000_1),
842            "expanding window {last} != constant volatility {expected}"
843        );
844    }
845
846    /// `next_y` returning `Ok(None)` must end the walk gracefully.
847    #[test]
848    fn test_walk_steps_next_y_none_stops_walk() {
849        let walker = Box::new(Walker::new());
850        let walk_params = WalkParams {
851            size: 10,
852            init_step: Step {
853                x: Xstep::new(
854                    Positive::ONE,
855                    TimeFrame::Day,
856                    ExpirationDate::Days(pos_or_panic!(30.0)),
857                ),
858                y: Ystep::new(0, Positive::HUNDRED),
859            },
860            walk_type: WalkType::GeometricBrownian {
861                dt: pos_or_panic!(0.01),
862                drift: dec!(0.0),
863                volatility: pos_or_panic!(0.2),
864            },
865            walker,
866            seed: Some(SEED),
867        };
868
869        let mut calls = 0;
870        let steps = match walk_steps(
871            &walk_params,
872            |price, _vol, _x| -> Result<Option<Positive>, SimulationError> {
873                calls += 1;
874                if calls > 3 {
875                    Ok(None)
876                } else {
877                    Ok(Some(*price))
878                }
879            },
880        ) {
881            Ok(steps) => steps,
882            Err(e) => panic!("walk_steps failed: {e}"),
883        };
884        // init + 3 accepted steps
885        assert_eq!(steps.len(), 4);
886    }
887
888    /// Multi-step behavior: prices, indices and time-to-expiry must all be
889    /// exact under a deterministic ramp walker.
890    #[test]
891    fn test_generator_positive_multi_step_behavior() {
892        use crate::simulation::walk_test_support::RampWalker;
893        use rust_decimal_macros::dec;
894
895        let size = 5;
896        let walk_params = WalkParams {
897            size,
898            init_step: Step {
899                x: Xstep::new(
900                    Positive::ONE,
901                    TimeFrame::Day,
902                    ExpirationDate::Days(pos_or_panic!(30.0)),
903                ),
904                y: Ystep::new(0, Positive::HUNDRED),
905            },
906            walk_type: WalkType::GeometricBrownian {
907                dt: pos_or_panic!(0.01),
908                drift: dec!(0.0),
909                volatility: pos_or_panic!(0.2),
910            },
911            walker: Box::new(RampWalker {
912                delta: Positive::TWO,
913            }),
914            seed: None,
915        };
916
917        let steps = match generator_positive(&walk_params) {
918            Ok(steps) => steps,
919            Err(e) => panic!("generator_positive failed: {e}"),
920        };
921        assert_eq!(steps.len(), size);
922        for (i, step) in steps.iter().enumerate() {
923            // Price follows the ramp exactly.
924            let expected_price = Positive::HUNDRED + Positive::TWO * i as f64;
925            assert_eq!(*step.y.value(), expected_price, "price at step {i}");
926            // Y index increments from the init step's index.
927            assert_eq!(*step.y.index(), i as i32, "y index at step {i}");
928            // Time-to-expiry decreases by exactly one day per step.
929            let days = match step.x.days_left() {
930                Ok(days) => days,
931                Err(e) => panic!("days_left failed at step {i}: {e}"),
932            };
933            assert_eq!(days, pos_or_panic!(30.0 - i as f64), "days at step {i}");
934        }
935    }
936
937    /// The walk must truncate exactly at expiration: with a 3-day expiry and
938    /// daily steps, a size-10 request yields init + 3 steps.
939    #[test]
940    fn test_generator_positive_truncates_at_expiration() {
941        use crate::simulation::walk_test_support::RampWalker;
942        use rust_decimal_macros::dec;
943
944        let walk_params = WalkParams {
945            size: 10,
946            init_step: Step {
947                x: Xstep::new(
948                    Positive::ONE,
949                    TimeFrame::Day,
950                    ExpirationDate::Days(pos_or_panic!(3.0)),
951                ),
952                y: Ystep::new(0, Positive::HUNDRED),
953            },
954            walk_type: WalkType::GeometricBrownian {
955                dt: pos_or_panic!(0.01),
956                drift: dec!(0.0),
957                volatility: pos_or_panic!(0.2),
958            },
959            walker: Box::new(RampWalker {
960                delta: Positive::ONE,
961            }),
962            seed: None,
963        };
964
965        let steps = match generator_positive(&walk_params) {
966            Ok(steps) => steps,
967            Err(e) => panic!("generator_positive failed: {e}"),
968        };
969        // init(3d) -> 2d -> 1d -> 0d, then ExpirationReached breaks.
970        assert_eq!(steps.len(), 4);
971        match steps.last() {
972            Some(last) => match last.x.days_left() {
973                Ok(days) => assert_eq!(days, Positive::ZERO),
974                Err(e) => panic!("days_left failed: {e}"),
975            },
976            None => panic!("empty steps"),
977        }
978    }
979
980    /// size = 0 yields the init-only walk from the shared driver.
981    #[test]
982    fn test_generator_positive_size_zero() {
983        use crate::simulation::walk_test_support::RampWalker;
984        use rust_decimal_macros::dec;
985
986        let walk_params = WalkParams {
987            size: 0,
988            init_step: Step {
989                x: Xstep::new(
990                    Positive::ONE,
991                    TimeFrame::Day,
992                    ExpirationDate::Days(pos_or_panic!(30.0)),
993                ),
994                y: Ystep::new(0, Positive::HUNDRED),
995            },
996            walk_type: WalkType::GeometricBrownian {
997                dt: pos_or_panic!(0.01),
998                drift: dec!(0.0),
999                volatility: pos_or_panic!(0.2),
1000            },
1001            walker: Box::new(RampWalker {
1002                delta: Positive::ONE,
1003            }),
1004            seed: None,
1005        };
1006
1007        let steps = match generator_positive(&walk_params) {
1008            Ok(steps) => steps,
1009            Err(e) => panic!("size-0 walk must not error: {e}"),
1010        };
1011        assert_eq!(steps.len(), 1);
1012    }
1013
1014    /// The parallel driver must produce exactly the serial driver's output
1015    /// for identical inputs.
1016    #[test]
1017    fn test_walk_steps_par_matches_serial() {
1018        use crate::simulation::walk_test_support::RampWalker;
1019        use rust_decimal_macros::dec;
1020
1021        let walk_params = WalkParams {
1022            size: 8,
1023            init_step: Step {
1024                x: Xstep::new(
1025                    Positive::ONE,
1026                    TimeFrame::Day,
1027                    ExpirationDate::Days(pos_or_panic!(30.0)),
1028                ),
1029                y: Ystep::new(0, Positive::HUNDRED),
1030            },
1031            walk_type: WalkType::GeometricBrownian {
1032                dt: pos_or_panic!(0.01),
1033                drift: dec!(0.0),
1034                volatility: pos_or_panic!(0.2),
1035            },
1036            walker: Box::new(RampWalker {
1037                delta: Positive::TWO,
1038            }),
1039            seed: None,
1040        };
1041
1042        let double =
1043            |price: &Positive,
1044             _vol: Option<Positive>,
1045             _x: &Xstep<Positive>|
1046             -> Result<Option<Positive>, SimulationError> { Ok(Some(*price * 2.0)) };
1047
1048        let serial = match walk_steps(&walk_params, double) {
1049            Ok(steps) => steps,
1050            Err(e) => panic!("serial driver failed: {e}"),
1051        };
1052        let parallel = match walk_steps_par(&walk_params, double) {
1053            Ok(steps) => steps,
1054            Err(e) => panic!("parallel driver failed: {e}"),
1055        };
1056
1057        assert_eq!(serial.len(), parallel.len());
1058        for (i, (s, p)) in serial.iter().zip(parallel.iter()).enumerate() {
1059            assert_eq!(s.y.value(), p.y.value(), "y value differs at step {i}");
1060            assert_eq!(s.y.index(), p.y.index(), "y index differs at step {i}");
1061            let (sd, pd) = match (s.x.days_left(), p.x.days_left()) {
1062                (Ok(sd), Ok(pd)) => (sd, pd),
1063                _ => panic!("days_left failed at step {i}"),
1064            };
1065            assert_eq!(sd, pd, "x days differ at step {i}");
1066        }
1067    }
1068
1069    /// Review regression (#419): an error from a speculative step past an
1070    /// `Ok(None)` stop must be discarded (the serial driver never evaluates
1071    /// those steps), while an error before the stop still propagates.
1072    #[test]
1073    fn test_walk_steps_par_error_after_stop_is_discarded() {
1074        use crate::simulation::walk_test_support::RampWalker;
1075        use rust_decimal_macros::dec;
1076
1077        let make_params = || WalkParams {
1078            size: 8,
1079            init_step: Step {
1080                x: Xstep::new(
1081                    Positive::ONE,
1082                    TimeFrame::Day,
1083                    ExpirationDate::Days(pos_or_panic!(30.0)),
1084                ),
1085                y: Ystep::new(0, Positive::HUNDRED),
1086            },
1087            walk_type: WalkType::GeometricBrownian {
1088                dt: pos_or_panic!(0.01),
1089                drift: dec!(0.0),
1090                volatility: pos_or_panic!(0.2),
1091            },
1092            walker: Box::new(RampWalker {
1093                delta: Positive::ONE, // prices 100, 101, 102, ...
1094            }),
1095            seed: None,
1096        };
1097
1098        // Stop at price 102 (step 2), error at price 103 (step 3): the
1099        // speculative error must be discarded and the walk succeed.
1100        let steps = match walk_steps_par(
1101            &make_params(),
1102            |price: &Positive, _vol, _x| -> Result<Option<Positive>, SimulationError> {
1103                if *price == pos_or_panic!(103.0) {
1104                    Err(SimulationError::invalid_parameters("speculative error"))
1105                } else if *price == pos_or_panic!(102.0) {
1106                    Ok(None)
1107                } else {
1108                    Ok(Some(*price))
1109                }
1110            },
1111        ) {
1112            Ok(steps) => steps,
1113            Err(e) => panic!("error past the stop signal must be discarded: {e}"),
1114        };
1115        // init + step 1 (price 101); price 102 stopped the walk.
1116        assert_eq!(steps.len(), 2);
1117
1118        // Error at price 101 (step 1), stop at price 102 (step 2): the
1119        // error precedes the stop and must propagate, as in the serial
1120        // driver.
1121        let result = walk_steps_par(
1122            &make_params(),
1123            |price: &Positive, _vol, _x| -> Result<Option<Positive>, SimulationError> {
1124                if *price == pos_or_panic!(101.0) {
1125                    Err(SimulationError::invalid_parameters("early error"))
1126                } else if *price == pos_or_panic!(102.0) {
1127                    Ok(None)
1128                } else {
1129                    Ok(Some(*price))
1130                }
1131            },
1132        );
1133        assert!(result.is_err(), "error before the stop must propagate");
1134    }
1135}