Skip to main content

optionstratlib_backtest/backtesting/
strategy_simulation.rs

1//! Simulation orchestration for the single-leg strategies.
2//!
3//! `LongCall`, `LongPut`, `ShortCall` and `ShortPut` are simulated the same
4//! way: the leg is re-priced with Black-Scholes at every step of a random
5//! walk, the exit policy is checked against the premium move, and the P&L
6//! is taken either at the exit or at expiry. This module owns that loop
7//! (backtesting composes strategies and simulation; neither may depend on
8//! the other) and provides the `Simulate` implementations for the four
9//! strategies. The per-strategy differences are captured by
10//! [`crate::backtesting::strategy_simulation::SingleLegSimulation`]: the side of the leg and the fee adjustment
11//! applied to every mark after the opening one.
12//!
13//! Progress over the walks is reported as `tracing` events, one `debug`
14//! event per evaluated path and an `info` event at the end, so the caller's
15//! subscriber decides whether and how to show it (ADR-0002 section 6, #538).
16
17use crate::backtesting::results::{SimulationResult, SimulationStatsResult};
18use crate::error::BacktestError;
19use optionstratlib_analytics::pnl::{PnL, PnLCalculator};
20use optionstratlib_core::model::Positive;
21use optionstratlib_core::model::decimal::{d_add, d_div, d_sub};
22use optionstratlib_core::model::{ExpirationDate, Options};
23use optionstratlib_core::utils::Len;
24use optionstratlib_pricing::pricing::OptionPricing;
25use optionstratlib_simulation::error::SimulationError;
26use optionstratlib_simulation::simulation::randomwalk::RandomWalk;
27use optionstratlib_simulation::simulation::simulator::Simulator;
28use optionstratlib_simulation::simulation::{ExitPolicy, PathEvaluator, check_exit_policy};
29use optionstratlib_strategies::strategies::base::Positionable;
30use optionstratlib_strategies::strategies::{LongCall, LongPut, ShortCall, ShortPut, Strategies};
31use rayon::prelude::*;
32use rust_decimal::Decimal;
33use rust_decimal_macros::dec;
34use std::collections::HashMap;
35use std::fmt::Display;
36use std::ops::AddAssign;
37
38/// Trait for simulating trading strategies across multiple price paths.
39///
40/// This trait enables strategies to be tested against various market scenarios
41/// by running them through multiple simulated price paths (random walks) and
42/// evaluating their performance based on defined exit policies.
43///
44/// # Type Parameters
45///
46/// * `X` - The type representing time steps in the simulation
47/// * `Y` - The type representing price values in the simulation
48///
49/// # Examples
50///
51/// ```ignore
52/// use optionstratlib_backtest::backtesting::Simulate;
53/// use optionstratlib_simulation::simulation::ExitPolicy;
54/// use rust_decimal_macros::dec;
55///
56/// let strategy = ShortPut::new(/* ... */);
57/// let simulator = Simulator::new(/* ... */);
58/// let exit_policy = ExitPolicy::profit_or_loss(dec!(0.5), dec!(1.0));
59///
60/// let results = strategy.simulate(&simulator, exit_policy)?;
61/// ```
62pub trait Simulate<X, Y>
63where
64    X: Copy + TryInto<Positive> + AddAssign + Display,
65    Y: TryInto<Positive> + Display + Clone,
66{
67    /// Simulates the strategy across multiple price paths.
68    ///
69    /// Evaluates the strategy's performance by running it through each random walk
70    /// in the simulator, checking exit conditions at each step, and calculating
71    /// final P&L based on either exit triggers or expiration.
72    ///
73    /// # Parameters
74    ///
75    /// * `sim` - The simulator containing multiple random walks to test against
76    /// * `exit` - The exit policy defining when to close positions
77    ///
78    /// # Returns
79    ///
80    /// A `SimulationStatsResult` containing:
81    /// - Individual `SimulationResult` for each run (with P&L, exit reason, holding period, etc.)
82    /// - Aggregate statistics (average P&L, win rate, std deviation, etc.)
83    ///
84    /// # Errors
85    ///
86    /// Returns an error if:
87    /// - Option pricing calculations fail
88    /// - P&L calculations encounter errors
89    /// - Invalid strategy parameters are detected
90    fn simulate(
91        &self,
92        sim: &Simulator<X, Y>,
93        exit: ExitPolicy,
94    ) -> Result<SimulationStatsResult, BacktestError>;
95}
96
97/// Premium move, as a fraction of the opening premium, at which an exit
98/// counts as a take-profit.
99const TAKE_PROFIT_FRACTION: Decimal = dec!(0.5);
100/// Premium move, as a fraction of the opening premium, at which an exit
101/// counts as a stop-loss.
102const STOP_LOSS_FRACTION: Decimal = dec!(-1.0);
103/// Time to expiry, in days, used to mark the leg at expiration; a strictly
104/// positive stand-in for zero so the closed form stays defined.
105const EXPIRATION_MARK_DAYS: f64 = 0.001;
106
107/// A strategy made of one option leg that can be marked along a price path.
108///
109/// Implemented for the four single-leg strategies. The leg is read through
110/// [`Positionable::get_positions`], so the strategy needs no extra state;
111/// the implementation only states the side and the fee adjustment.
112pub trait SingleLegSimulation: PnLCalculator + Positionable {
113    /// Whether the leg is long (bought) or short (sold). Drives the exit
114    /// policy evaluation and the sign of the premium move.
115    const IS_LONG: bool;
116
117    /// Amount added to every premium mark after the opening one, in quote
118    /// currency. Zero by default; `ShortPut` adds its fees.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`SimulationError`] when the adjustment cannot be computed.
123    fn mark_adjustment(&self) -> Result<Positive, BacktestError> {
124        Ok(Positive::ZERO)
125    }
126
127    /// The single leg of the strategy.
128    ///
129    /// # Errors
130    ///
131    /// Returns [`SimulationError::InvalidParameters`] when the strategy
132    /// does not hold exactly one position.
133    fn simulated_option(&self) -> Result<&Options, SimulationError> {
134        let positions = self.get_positions().map_err(|e| {
135            SimulationError::invalid_parameters(&format!(
136                "single-leg simulation cannot read the strategy positions: {e}"
137            ))
138        })?;
139        match positions.as_slice() {
140            [position] => Ok(&position.option),
141            _ => Err(SimulationError::invalid_parameters(&format!(
142                "single-leg simulation expects exactly one position, found {}",
143                positions.len()
144            ))),
145        }
146    }
147}
148
149impl SingleLegSimulation for LongCall {
150    const IS_LONG: bool = true;
151}
152
153impl SingleLegSimulation for LongPut {
154    const IS_LONG: bool = true;
155}
156
157impl SingleLegSimulation for ShortCall {
158    const IS_LONG: bool = false;
159}
160
161impl SingleLegSimulation for ShortPut {
162    const IS_LONG: bool = false;
163
164    fn mark_adjustment(&self) -> Result<Positive, BacktestError> {
165        // Backtesting composes both layers, so the strategy failure stays
166        // typed in the backtest error; neither `StrategyError` nor
167        // `SimulationError` may name the other.
168        Ok(self.get_fees()?)
169    }
170}
171
172/// Divides without the default rounding of [`d_div`]; the average premium
173/// has always been formed with the raw `Decimal` operator.
174#[inline]
175fn div_unrounded(lhs: Decimal, rhs: Decimal, op: &'static str) -> Result<Decimal, BacktestError> {
176    if rhs.is_zero() {
177        return Err(SimulationError::invalid_parameters(&format!("{op}: division by zero")).into());
178    }
179    lhs.checked_div(rhs).ok_or_else(|| {
180        SimulationError::invalid_parameters(&format!("{op}: {lhs} / {rhs} overflowed")).into()
181    })
182}
183
184/// Evaluates one random walk for a single-leg strategy.
185///
186/// Holds the strategy and its opening premium (the Black-Scholes price of
187/// the leg at construction), which every path is measured against.
188#[derive(Debug)]
189pub struct SingleLegPathEvaluator<'a, S> {
190    strategy: &'a S,
191    initial_premium: Decimal,
192}
193
194impl<'a, S> SingleLegPathEvaluator<'a, S>
195where
196    S: SingleLegSimulation,
197{
198    /// Prices the leg once to fix the opening premium.
199    ///
200    /// # Errors
201    ///
202    /// Returns the [`SimulationError`] raised when the strategy has no
203    /// single leg or the closed-form price fails.
204    pub fn new(strategy: &'a S) -> Result<Self, BacktestError> {
205        let initial_premium = strategy
206            .simulated_option()?
207            .calculate_price_black_scholes()?
208            .abs();
209        Ok(Self {
210            strategy,
211            initial_premium,
212        })
213    }
214
215    /// Opening premium every path is measured against, in quote currency.
216    #[must_use]
217    #[inline]
218    pub fn initial_premium(&self) -> Decimal {
219        self.initial_premium
220    }
221}
222
223impl<S, X, Y> PathEvaluator<X, Y> for SingleLegPathEvaluator<'_, S>
224where
225    S: SingleLegSimulation,
226    X: Copy + Into<Positive> + AddAssign + Display,
227    Y: Into<Positive> + Display + Clone,
228{
229    type Outcome = SimulationResult;
230    type Error = BacktestError;
231
232    fn evaluate_path(
233        &self,
234        random_walk: &RandomWalk<X, Y>,
235        exit: &ExitPolicy,
236    ) -> Result<SimulationResult, BacktestError> {
237        let option = self.strategy.simulated_option()?;
238        let initial_premium = self.initial_premium;
239        let mark_adjustment = self.strategy.mark_adjustment()?.to_dec();
240
241        let mut max_premium = initial_premium;
242        let mut min_premium = initial_premium;
243        let mut premium_sum = initial_premium;
244        let mut premium_count: usize = 1;
245        let mut hit_take_profit = false;
246        let mut hit_stop_loss = false;
247        let mut expired = false;
248        let mut expiration_premium = None;
249        let mut holding_period = 0;
250        let mut exit_reason = ExitPolicy::Expiration;
251        let mut final_pnl = None;
252
253        // One scratch copy of the leg per path: every step overwrites the
254        // two fields the price reads from the walk, so reusing it prices
255        // exactly what a fresh clone per step priced (#863).
256        let mut current_option = option.clone();
257
258        // Iterate through the random walk
259        for step in random_walk.get_steps().iter().skip(1) {
260            let days_left = match step.x.days_left() {
261                Ok(days) => days,
262                Err(_) => {
263                    expired = true;
264                    break;
265                }
266            };
267
268            // Calculate current option premium
269            current_option.underlying_price = step.y.positive()?;
270            current_option.expiration_date = ExpirationDate::Days(days_left);
271
272            let current_premium = d_add(
273                current_option.calculate_price_black_scholes()?.abs(),
274                mark_adjustment,
275                "backtesting::strategy_simulation/mark_adjustment",
276            )?;
277            // A step index is an `i32`; a negative one would wrap to a
278            // holding period near `usize::MAX` under `as`.
279            let index = usize::try_from(*step.x.index()).map_err(|_| {
280                SimulationError::invalid_parameters(&format!(
281                    "backtesting::strategy_simulation: negative step index {}",
282                    step.x.index()
283                ))
284            })?;
285
286            // Track premium statistics
287            max_premium = max_premium.max(current_premium);
288            min_premium = min_premium.min(current_premium);
289            premium_sum = d_add(
290                premium_sum,
291                current_premium,
292                "backtesting::strategy_simulation/premium_sum",
293            )?;
294            premium_count = premium_count.checked_add(1).ok_or_else(|| {
295                SimulationError::invalid_parameters(
296                    "backtesting::strategy_simulation: premium count overflowed",
297                )
298            })?;
299            holding_period = index;
300
301            // Check exit policy
302            if let Some(reason) = check_exit_policy(
303                exit,
304                initial_premium,
305                current_premium,
306                index,
307                days_left,
308                current_option.underlying_price,
309                S::IS_LONG,
310            ) {
311                exit_reason = reason;
312
313                // Check if it's take profit or stop loss.
314                // Long: profit when current > initial; short: profit when
315                // current < initial. A leg that opened at a zero premium has
316                // no percentage to move by; the exit still fires, it just
317                // does not classify as a take-profit or a stop-loss.
318                let move_from_open = if S::IS_LONG {
319                    d_sub(
320                        current_premium,
321                        initial_premium,
322                        "backtesting::strategy_simulation/pnl_move",
323                    )?
324                } else {
325                    d_sub(
326                        initial_premium,
327                        current_premium,
328                        "backtesting::strategy_simulation/pnl_move",
329                    )?
330                };
331                let pnl_percent = d_div(
332                    move_from_open,
333                    initial_premium,
334                    "backtesting::strategy_simulation/pnl_percent",
335                )
336                .unwrap_or(Decimal::ZERO);
337                if pnl_percent >= TAKE_PROFIT_FRACTION {
338                    hit_take_profit = true;
339                } else if pnl_percent <= STOP_LOSS_FRACTION {
340                    hit_stop_loss = true;
341                }
342
343                // Exit triggered: the P&L is the premium move itself (paid
344                // then sold for a long leg, received then bought back for a
345                // short one). Recomputing the opening premium through
346                // `calculate_pnl` would only introduce a discrepancy.
347                let pnl = PnL {
348                    realized: Some(move_from_open),
349                    unrealized: None,
350                    initial_costs: Positive::ZERO,
351                    initial_income: Positive::ZERO,
352                    date_time: chrono::Utc::now(),
353                };
354                final_pnl = Some(pnl);
355                break;
356            }
357        }
358
359        // If no exit triggered, calculate P&L at expiration
360        if final_pnl.is_none()
361            && let Some(last_step) = random_walk.last()
362        {
363            let final_price = last_step.y.positive()?;
364            let pnl = self.strategy.calculate_pnl_at_expiration(&final_price)?;
365
366            // Mark the leg at expiration (a very small time instead of zero)
367            let mut exp_option = option.clone();
368            exp_option.underlying_price = final_price;
369            exp_option.expiration_date = ExpirationDate::Days(Positive::new(EXPIRATION_MARK_DAYS)?);
370            expiration_premium = Some(exp_option.calculate_price_black_scholes()?.abs());
371
372            expired = true;
373            exit_reason = ExitPolicy::Expiration;
374            final_pnl = Some(pnl);
375            // `last()` returned a step, so the walk has at least one.
376            holding_period = random_walk.len().checked_sub(1).ok_or_else(|| {
377                SimulationError::invalid_parameters(
378                    "backtesting::strategy_simulation: a walk with a last step has no length",
379                )
380            })?;
381        }
382
383        let pnl = final_pnl.unwrap_or_default();
384        let avg_premium = div_unrounded(
385            premium_sum,
386            Decimal::from(premium_count),
387            "backtesting::strategy_simulation/avg_premium",
388        )?;
389
390        Ok(SimulationResult {
391            simulation_count: 1,
392            risk_metrics: None,
393            final_equity_percentiles: HashMap::new(),
394            max_premium,
395            min_premium,
396            avg_premium,
397            hit_take_profit,
398            hit_stop_loss,
399            expired,
400            expiration_premium,
401            pnl,
402            holding_period,
403            exit_reason,
404        })
405    }
406}
407
408/// Simulates a single-leg strategy across every walk of `sim`.
409///
410/// Prices the leg once for the opening premium, evaluates each walk with
411/// [`SingleLegPathEvaluator`] on the rayon pool (emitting a `debug` event
412/// per path and an `info` event at the end) and aggregates the results, in
413/// walk order, with [`SimulationStatsResult::from_results`]. The paths are
414/// independent, so the statistics are those of a serial run (#863).
415///
416/// # Errors
417///
418/// Returns a [`BacktestError`] when the strategy has no single leg, a
419/// Black-Scholes price or an expiration P&L fails, a step cannot be read as
420/// a price or carries a negative index, or the aggregate statistics
421/// overflow. When several paths fail, the error is the lowest-indexed
422/// path's, as in a serial run; the other paths are still evaluated.
423#[must_use = "the simulation statistics are the only product of this call"]
424#[tracing::instrument(level = "debug", skip(strategy, sim), fields(walks = sim.len()))]
425pub fn simulate_single_leg<S, X, Y>(
426    strategy: &S,
427    sim: &Simulator<X, Y>,
428    exit: ExitPolicy,
429) -> Result<SimulationStatsResult, BacktestError>
430where
431    S: SingleLegSimulation + Sync,
432    X: Copy + Into<Positive> + AddAssign + Display + Send + Sync,
433    Y: Into<Positive> + Display + Clone + Send + Sync,
434{
435    let evaluator = SingleLegPathEvaluator::new(strategy)?;
436
437    // Progress is a `tracing` event per path instead of a terminal progress
438    // bar (ADR-0002 section 6): the caller's subscriber decides whether and
439    // how to show it.
440    let walks = sim.get_random_walks();
441    let walk_count = walks.len();
442    let outcomes: Vec<Result<SimulationResult, BacktestError>> = walks
443        .into_par_iter()
444        .enumerate()
445        .map(|(path, random_walk)| {
446            let outcome = evaluator.evaluate_path(random_walk, &exit);
447            tracing::debug!(path, total = walk_count, "simulated path evaluated");
448            outcome
449        })
450        .collect();
451    // In walk order, so the first error is the lowest-indexed path's.
452    let simulation_results = outcomes.into_iter().collect::<Result<Vec<_>, _>>()?;
453    tracing::info!(total = walk_count, "simulations completed");
454
455    Ok(SimulationStatsResult::from_results(simulation_results)?)
456}
457
458impl<X, Y> Simulate<X, Y> for LongCall
459where
460    X: Copy + Into<Positive> + AddAssign + Display + Send + Sync,
461    Y: Into<Positive> + Display + Clone + Send + Sync,
462{
463    fn simulate(
464        &self,
465        sim: &Simulator<X, Y>,
466        exit: ExitPolicy,
467    ) -> Result<SimulationStatsResult, BacktestError> {
468        simulate_single_leg(self, sim, exit)
469    }
470}
471
472impl<X, Y> Simulate<X, Y> for LongPut
473where
474    X: Copy + Into<Positive> + AddAssign + Display + Send + Sync,
475    Y: Into<Positive> + Display + Clone + Send + Sync,
476{
477    fn simulate(
478        &self,
479        sim: &Simulator<X, Y>,
480        exit: ExitPolicy,
481    ) -> Result<SimulationStatsResult, BacktestError> {
482        simulate_single_leg(self, sim, exit)
483    }
484}
485
486impl<X, Y> Simulate<X, Y> for ShortCall
487where
488    X: Copy + Into<Positive> + AddAssign + Display + Send + Sync,
489    Y: Into<Positive> + Display + Clone + Send + Sync,
490{
491    fn simulate(
492        &self,
493        sim: &Simulator<X, Y>,
494        exit: ExitPolicy,
495    ) -> Result<SimulationStatsResult, BacktestError> {
496        simulate_single_leg(self, sim, exit)
497    }
498}
499
500impl<X, Y> Simulate<X, Y> for ShortPut
501where
502    X: Copy + Into<Positive> + AddAssign + Display + Send + Sync,
503    Y: Into<Positive> + Display + Clone + Send + Sync,
504{
505    fn simulate(
506        &self,
507        sim: &Simulator<X, Y>,
508        exit: ExitPolicy,
509    ) -> Result<SimulationStatsResult, BacktestError> {
510        simulate_single_leg(self, sim, exit)
511    }
512}
513
514#[cfg(test)]
515mod test_support {
516    //! Fixtures shared by the four single-leg simulation test modules.
517    //! Each strategy is built the way its constructor builds it: the
518    //! default strategy plus one position with the test leg.
519    use super::*;
520    use chrono::Utc;
521    use optionstratlib_core::model::position::Position;
522    use optionstratlib_core::model::types::{OptionStyle, OptionType, Side};
523    use optionstratlib_core::pos_or_panic;
524    use optionstratlib_core::utils::TimeFrame;
525    use optionstratlib_simulation::simulation::steps::Step;
526    use optionstratlib_simulation::simulation::{WalkParams, WalkType, WalkTypeAble};
527
528    #[derive(Clone)]
529    pub(super) struct TestWalker;
530    impl WalkTypeAble<Positive, Positive> for TestWalker {}
531
532    /// The test leg every fixture uses: ATM, 30 days, 20 % vol, 5.0
533    /// premium, no fees.
534    pub(super) fn test_leg(side: Side, style: OptionStyle) -> Position {
535        let option = Options::new(
536            OptionType::European,
537            side,
538            "TEST".to_string(),
539            Positive::HUNDRED,
540            ExpirationDate::Days(pos_or_panic!(30.0)),
541            pos_or_panic!(0.20),
542            Positive::ONE,
543            Positive::HUNDRED,
544            dec!(0.05),
545            style,
546            Positive::ZERO,
547            None,
548        );
549        Position::new(
550            option,
551            pos_or_panic!(5.0),
552            Utc::now(),
553            Positive::ZERO,
554            Positive::ZERO,
555            None,
556            None,
557        )
558    }
559
560    pub(super) fn create_test_long_call() -> LongCall {
561        LongCall::new(
562            "TEST".to_string(),
563            Positive::HUNDRED,
564            ExpirationDate::Days(pos_or_panic!(30.0)),
565            pos_or_panic!(0.20),
566            Positive::ONE,
567            Positive::HUNDRED,
568            dec!(0.05),
569            Positive::ZERO,
570            pos_or_panic!(5.0),
571            Positive::ZERO,
572            Positive::ZERO,
573        )
574        .unwrap()
575    }
576
577    pub(super) fn create_test_long_put() -> LongPut {
578        let mut strategy = LongPut::default();
579        strategy
580            .add_position(&test_leg(Side::Long, OptionStyle::Put))
581            .unwrap();
582        strategy
583    }
584
585    pub(super) fn create_test_short_call() -> ShortCall {
586        let mut strategy = ShortCall::default();
587        strategy
588            .add_position(&test_leg(Side::Short, OptionStyle::Call))
589            .unwrap();
590        strategy
591    }
592
593    pub(super) fn create_test_short_put() -> ShortPut {
594        let mut strategy = ShortPut::default();
595        strategy
596            .add_position(&test_leg(Side::Short, OptionStyle::Put))
597            .unwrap();
598        strategy
599    }
600
601    pub(super) fn create_walk_params(prices: Vec<Positive>) -> WalkParams<Positive, Positive> {
602        let init_step = Step::new(
603            Positive::ONE,
604            TimeFrame::Day,
605            ExpirationDate::Days(pos_or_panic!(30.0)),
606            Positive::HUNDRED,
607        );
608        WalkParams {
609            size: prices.len(),
610            init_step,
611            walker: Box::new(TestWalker),
612            walk_type: WalkType::Historical {
613                timeframe: TimeFrame::Day,
614                prices,
615                symbol: Some("TEST".to_string()),
616            },
617            seed: None,
618        }
619    }
620}
621
622#[cfg(test)]
623mod tests_single_leg_contract {
624    use super::test_support::*;
625    use super::*;
626    use optionstratlib_core::pos_or_panic;
627    use optionstratlib_simulation::simulation::generator_positive;
628    use rust_decimal::MathematicalOps;
629
630    #[test]
631    fn test_single_leg_sides_and_adjustments() {
632        let sides = [
633            <LongCall as SingleLegSimulation>::IS_LONG,
634            <LongPut as SingleLegSimulation>::IS_LONG,
635            <ShortCall as SingleLegSimulation>::IS_LONG,
636            <ShortPut as SingleLegSimulation>::IS_LONG,
637        ];
638        assert_eq!(sides, [true, true, false, false]);
639        assert_eq!(
640            create_test_long_call().mark_adjustment().unwrap(),
641            Positive::ZERO
642        );
643        let short_put = create_test_short_put();
644        assert_eq!(
645            short_put.mark_adjustment().unwrap(),
646            short_put.get_fees().unwrap()
647        );
648    }
649
650    #[test]
651    fn test_simulated_option_is_the_single_leg() {
652        let strategy = create_test_long_call();
653        let option = strategy.simulated_option().unwrap();
654        assert_eq!(option.strike_price, Positive::HUNDRED);
655        let evaluator = SingleLegPathEvaluator::new(&strategy).unwrap();
656        assert_eq!(
657            evaluator.initial_premium(),
658            option.calculate_price_black_scholes().unwrap().abs()
659        );
660    }
661
662    /// A step whose index went below zero (`Xstep::previous` from the first
663    /// step) is reported; `as usize` used to wrap it into a holding period
664    /// near `usize::MAX` (#788).
665    #[test]
666    fn test_evaluate_path_rejects_a_negative_step_index() {
667        let strategy = create_test_long_call();
668        let evaluator = SingleLegPathEvaluator::new(&strategy).unwrap();
669        let walk_params = create_walk_params(vec![Positive::HUNDRED, Positive::HUNDRED]);
670        let walk = RandomWalk::new("negative index".to_string(), &walk_params, |params| {
671            let first = params.init_step.clone();
672            let mut rewound = first.clone();
673            rewound.x = first.x.previous()?;
674            Ok::<_, SimulationError>(vec![first, rewound])
675        })
676        .unwrap();
677        assert_eq!(*walk.get_steps()[1].x.index(), -1);
678
679        let result = evaluator.evaluate_path(&walk, &ExitPolicy::Expiration);
680        assert!(result.is_err(), "negative index accepted: {result:?}");
681    }
682
683    /// A `Positionable` with no legs (or with two) is not a single-leg
684    /// strategy; the contract reports it instead of picking one.
685    #[test]
686    fn test_simulated_option_rejects_a_strategy_without_a_single_leg() {
687        use optionstratlib_core::model::position::Position;
688        use optionstratlib_pricing::error::PricingError;
689
690        struct Legs(Vec<Position>);
691        impl PnLCalculator for Legs {
692            fn calculate_pnl(
693                &self,
694                _underlying_price: &Positive,
695                _expiration_date: ExpirationDate,
696                _implied_volatility: &Positive,
697            ) -> Result<PnL, PricingError> {
698                Ok(PnL::default())
699            }
700            fn calculate_pnl_at_expiration(
701                &self,
702                _underlying_price: &Positive,
703            ) -> Result<PnL, PricingError> {
704                Ok(PnL::default())
705            }
706        }
707        impl Positionable for Legs {
708            fn get_positions(
709                &self,
710            ) -> Result<Vec<&Position>, optionstratlib_core::error::PositionError> {
711                Ok(self.0.iter().collect())
712            }
713        }
714        impl SingleLegSimulation for Legs {
715            const IS_LONG: bool = true;
716        }
717
718        assert!(matches!(
719            Legs(Vec::new()).simulated_option(),
720            Err(SimulationError::InvalidParameters { .. })
721        ));
722        let leg = create_test_long_call().get_positions().unwrap()[0].clone();
723        // The cause stays typed inside the backtest error rather than
724        // being flattened into a message.
725        assert!(matches!(
726            Legs(vec![leg.clone(), leg]).simulated_option(),
727            Err(SimulationError::InvalidParameters { .. })
728        ));
729        assert!(matches!(
730            SingleLegPathEvaluator::new(&Legs(Vec::new())),
731            Err(BacktestError::Simulation(inner))
732                if matches!(*inner, SimulationError::InvalidParameters { .. })
733        ));
734    }
735
736    /// The aggregate of a run is `PathStatistics::from_outcomes` over the
737    /// results: recompute it by hand from the per-run results.
738    #[test]
739    fn test_simulate_aggregate_matches_from_results() {
740        let strategy = create_test_short_put();
741        let walk_params = create_walk_params(vec![
742            Positive::HUNDRED,
743            pos_or_panic!(105.0),
744            pos_or_panic!(110.0),
745        ]);
746        let simulator =
747            Simulator::new("Test".to_string(), 3, &walk_params, generator_positive).unwrap();
748        let stats = strategy
749            .simulate(&simulator, ExitPolicy::Expiration)
750            .unwrap();
751        let recomputed = SimulationStatsResult::from_results(stats.results.clone()).unwrap();
752        assert_eq!(stats.average_pnl, recomputed.average_pnl);
753        assert_eq!(stats.median_pnl, recomputed.median_pnl);
754        assert_eq!(stats.std_dev_pnl, recomputed.std_dev_pnl);
755        assert_eq!(stats.win_rate, recomputed.win_rate);
756        assert_eq!(
757            stats.average_holding_period,
758            recomputed.average_holding_period
759        );
760        assert_eq!(stats.std_dev_pnl, dec!(0.0).sqrt().unwrap());
761    }
762}
763
764#[cfg(test)]
765mod tests_simulate_long_call {
766    use super::test_support::*;
767    use super::*;
768    use optionstratlib_core::pos_or_panic;
769    use optionstratlib_simulation::simulation::generator_positive;
770
771    #[test]
772    fn test_simulate_profit_percent_exit() {
773        let strategy = create_test_long_call();
774        let prices = vec![
775            Positive::HUNDRED,
776            pos_or_panic!(105.0),
777            pos_or_panic!(110.0),
778            pos_or_panic!(115.0),
779        ];
780        let walk_params = create_walk_params(prices);
781        let Ok(simulator) = Simulator::new("Test".to_string(), 1, &walk_params, generator_positive)
782        else {
783            panic!("simulator setup failed");
784        };
785        let results = strategy.simulate(&simulator, ExitPolicy::ProfitPercent(dec!(0.5)));
786        assert!(results.is_ok());
787        let stats = results.unwrap();
788        assert_eq!(stats.total_simulations, 1);
789    }
790
791    #[test]
792    fn test_simulate_expiration_exit() {
793        let strategy = create_test_long_call();
794        let prices = vec![
795            Positive::HUNDRED,
796            pos_or_panic!(101.0),
797            pos_or_panic!(102.0),
798        ];
799        let walk_params = create_walk_params(prices);
800        let Ok(simulator) = Simulator::new("Test".to_string(), 1, &walk_params, generator_positive)
801        else {
802            panic!("simulator setup failed");
803        };
804        let results = strategy.simulate(&simulator, ExitPolicy::Expiration);
805        assert!(results.is_ok(), "Simulate failed: {:?}", results.err());
806        let stats = results.unwrap();
807        assert_eq!(stats.total_simulations, 1);
808    }
809
810    #[test]
811    fn test_simulate_stats_aggregation() {
812        let strategy = create_test_long_call();
813        let prices = vec![
814            Positive::HUNDRED,
815            pos_or_panic!(105.0),
816            pos_or_panic!(110.0),
817        ];
818        let walk_params = create_walk_params(prices);
819        let Ok(simulator) = Simulator::new("Test".to_string(), 3, &walk_params, generator_positive)
820        else {
821            panic!("simulator setup failed");
822        };
823        let results = strategy.simulate(&simulator, ExitPolicy::Expiration);
824        assert!(results.is_ok(), "Simulate failed: {:?}", results.err());
825        let stats = results.unwrap();
826        assert!(stats.total_simulations >= 1);
827        assert_eq!(stats.total_simulations, stats.results.len());
828        assert!(stats.win_rate >= dec!(0.0) && stats.win_rate <= dec!(100.0));
829    }
830}
831
832#[cfg(test)]
833mod tests_simulate_long_put {
834    use super::test_support::*;
835    use super::*;
836    use optionstratlib_core::pos_or_panic;
837    use optionstratlib_simulation::simulation::generator_positive;
838
839    #[test]
840    fn test_simulate_profit_percent_exit() {
841        let strategy = create_test_long_put();
842        let prices = vec![
843            Positive::HUNDRED,
844            pos_or_panic!(95.0),
845            pos_or_panic!(90.0),
846            pos_or_panic!(85.0),
847        ];
848        let walk_params = create_walk_params(prices);
849        let Ok(simulator) = Simulator::new("Test".to_string(), 1, &walk_params, generator_positive)
850        else {
851            panic!("simulator setup failed");
852        };
853        let results = strategy.simulate(&simulator, ExitPolicy::ProfitPercent(dec!(0.5)));
854        assert!(results.is_ok());
855        let stats = results.unwrap();
856        assert_eq!(stats.total_simulations, 1);
857    }
858
859    #[test]
860    fn test_simulate_expiration_exit() {
861        let strategy = create_test_long_put();
862        let prices = vec![Positive::HUNDRED, pos_or_panic!(99.0), pos_or_panic!(98.0)];
863        let walk_params = create_walk_params(prices);
864        let Ok(simulator) = Simulator::new("Test".to_string(), 1, &walk_params, generator_positive)
865        else {
866            panic!("simulator setup failed");
867        };
868        let results = strategy.simulate(&simulator, ExitPolicy::Expiration);
869        assert!(results.is_ok(), "Simulate failed: {:?}", results.err());
870        let stats = results.unwrap();
871        assert_eq!(stats.total_simulations, 1);
872    }
873
874    #[test]
875    fn test_simulate_stats_aggregation() {
876        let strategy = create_test_long_put();
877        let prices = vec![Positive::HUNDRED, pos_or_panic!(95.0), pos_or_panic!(90.0)];
878        let walk_params = create_walk_params(prices);
879        let Ok(simulator) = Simulator::new("Test".to_string(), 3, &walk_params, generator_positive)
880        else {
881            panic!("simulator setup failed");
882        };
883        let results = strategy.simulate(&simulator, ExitPolicy::Expiration);
884        assert!(results.is_ok(), "Simulate failed: {:?}", results.err());
885        let stats = results.unwrap();
886        assert!(stats.total_simulations >= 1);
887        assert_eq!(stats.total_simulations, stats.results.len());
888        assert!(stats.win_rate >= dec!(0.0) && stats.win_rate <= dec!(100.0));
889    }
890}
891
892#[cfg(test)]
893mod tests_simulate_short_call {
894    use super::test_support::*;
895    use super::*;
896    use optionstratlib_core::pos_or_panic;
897    use optionstratlib_simulation::simulation::generator_positive;
898
899    #[test]
900    fn test_simulate_profit_percent_exit() {
901        let strategy = create_test_short_call();
902        let prices = vec![
903            Positive::HUNDRED,
904            pos_or_panic!(95.0),
905            pos_or_panic!(90.0),
906            pos_or_panic!(85.0),
907        ];
908        let walk_params = create_walk_params(prices);
909        let Ok(simulator) = Simulator::new("Test".to_string(), 1, &walk_params, generator_positive)
910        else {
911            panic!("simulator setup failed");
912        };
913        let results = strategy.simulate(&simulator, ExitPolicy::ProfitPercent(dec!(0.5)));
914        assert!(results.is_ok());
915        let stats = results.unwrap();
916        assert_eq!(stats.total_simulations, 1);
917    }
918
919    #[test]
920    fn test_simulate_expiration_exit() {
921        let strategy = create_test_short_call();
922        let prices = vec![Positive::HUNDRED, pos_or_panic!(99.0), pos_or_panic!(98.0)];
923        let walk_params = create_walk_params(prices);
924        let Ok(simulator) = Simulator::new("Test".to_string(), 1, &walk_params, generator_positive)
925        else {
926            panic!("simulator setup failed");
927        };
928        let results = strategy.simulate(&simulator, ExitPolicy::Expiration);
929        assert!(results.is_ok(), "Simulate failed: {:?}", results.err());
930        let stats = results.unwrap();
931        assert_eq!(stats.total_simulations, 1);
932    }
933
934    #[test]
935    fn test_simulate_stats_aggregation() {
936        let strategy = create_test_short_call();
937        let prices = vec![Positive::HUNDRED, pos_or_panic!(95.0), pos_or_panic!(90.0)];
938        let walk_params = create_walk_params(prices);
939        let Ok(simulator) = Simulator::new("Test".to_string(), 3, &walk_params, generator_positive)
940        else {
941            panic!("simulator setup failed");
942        };
943        let results = strategy.simulate(&simulator, ExitPolicy::Expiration);
944        assert!(results.is_ok(), "Simulate failed: {:?}", results.err());
945        let stats = results.unwrap();
946        assert!(stats.total_simulations >= 1);
947        assert_eq!(stats.total_simulations, stats.results.len());
948        assert!(stats.win_rate >= dec!(0.0) && stats.win_rate <= dec!(100.0));
949    }
950}
951
952#[cfg(test)]
953mod tests_simulate_short_put {
954    use super::test_support::*;
955    use super::*;
956    use optionstratlib_core::pos_or_panic;
957    use optionstratlib_simulation::simulation::generator_positive;
958
959    #[test]
960    fn test_simulate_profit_percent_exit() {
961        let strategy = create_test_short_put();
962        let prices = vec![
963            Positive::HUNDRED,
964            pos_or_panic!(105.0),
965            pos_or_panic!(110.0),
966            pos_or_panic!(115.0),
967        ];
968
969        let walk_params = create_walk_params(prices);
970        let Ok(simulator) = Simulator::new(
971            "Test Simulator".to_string(),
972            1,
973            &walk_params,
974            generator_positive,
975        ) else {
976            panic!("simulator setup failed");
977        };
978
979        let exit_policy = ExitPolicy::ProfitPercent(dec!(0.5));
980        let results = strategy.simulate(&simulator, exit_policy);
981
982        assert!(results.is_ok());
983        let stats = results.unwrap();
984        assert_eq!(stats.total_simulations, 1);
985        assert!(!stats.results.is_empty());
986    }
987
988    #[test]
989    fn test_simulate_loss_percent_exit() {
990        let strategy = create_test_short_put();
991        let prices = vec![
992            Positive::HUNDRED,
993            pos_or_panic!(95.0),
994            pos_or_panic!(90.0),
995            pos_or_panic!(85.0),
996        ];
997
998        let walk_params = create_walk_params(prices);
999        let Ok(simulator) = Simulator::new(
1000            "Test Simulator".to_string(),
1001            1,
1002            &walk_params,
1003            generator_positive,
1004        ) else {
1005            panic!("simulator setup failed");
1006        };
1007
1008        let exit_policy = ExitPolicy::LossPercent(dec!(1.0));
1009        let results = strategy.simulate(&simulator, exit_policy);
1010
1011        assert!(results.is_ok());
1012        let stats = results.unwrap();
1013        assert_eq!(stats.total_simulations, 1);
1014        assert!(!stats.results.is_empty());
1015    }
1016
1017    #[test]
1018    fn test_simulate_expiration_exit() {
1019        let strategy = create_test_short_put();
1020        let prices = vec![
1021            Positive::HUNDRED,
1022            pos_or_panic!(101.0),
1023            pos_or_panic!(102.0),
1024            pos_or_panic!(103.0),
1025        ];
1026
1027        let walk_params = create_walk_params(prices);
1028        let Ok(simulator) = Simulator::new(
1029            "Test Simulator".to_string(),
1030            1,
1031            &walk_params,
1032            generator_positive,
1033        ) else {
1034            panic!("simulator setup failed");
1035        };
1036
1037        let exit_policy = ExitPolicy::Expiration;
1038        let results = strategy.simulate(&simulator, exit_policy);
1039
1040        assert!(results.is_ok(), "Simulate failed: {:?}", results.err());
1041        let stats = results.unwrap();
1042        assert_eq!(stats.total_simulations, 1);
1043        assert!(!stats.results.is_empty());
1044    }
1045
1046    #[test]
1047    fn test_simulate_or_exit_policy() {
1048        let strategy = create_test_short_put();
1049        let prices = vec![
1050            Positive::HUNDRED,
1051            pos_or_panic!(105.0),
1052            pos_or_panic!(110.0),
1053            pos_or_panic!(115.0),
1054        ];
1055
1056        let walk_params = create_walk_params(prices);
1057        let Ok(simulator) = Simulator::new(
1058            "Test Simulator".to_string(),
1059            1,
1060            &walk_params,
1061            generator_positive,
1062        ) else {
1063            panic!("simulator setup failed");
1064        };
1065
1066        let exit_policy = ExitPolicy::Or(vec![
1067            ExitPolicy::ProfitPercent(dec!(0.5)),
1068            ExitPolicy::UnderlyingAbove(pos_or_panic!(112.0)),
1069        ]);
1070        let results = strategy.simulate(&simulator, exit_policy);
1071
1072        assert!(results.is_ok());
1073        let stats = results.unwrap();
1074        assert_eq!(stats.total_simulations, 1);
1075        assert!(!stats.results.is_empty());
1076    }
1077
1078    #[test]
1079    fn test_simulate_stats_aggregation() {
1080        let strategy = create_test_short_put();
1081        let prices = vec![
1082            Positive::HUNDRED,
1083            pos_or_panic!(105.0),
1084            pos_or_panic!(110.0),
1085        ];
1086
1087        let walk_params = create_walk_params(prices);
1088        let Ok(simulator) = Simulator::new(
1089            "Test Simulator".to_string(),
1090            5,
1091            &walk_params,
1092            generator_positive,
1093        ) else {
1094            panic!("simulator setup failed");
1095        };
1096
1097        let exit_policy = ExitPolicy::Expiration;
1098        let results = strategy.simulate(&simulator, exit_policy);
1099
1100        assert!(results.is_ok(), "Simulate failed: {:?}", results.err());
1101        let stats = results.unwrap();
1102
1103        assert!(stats.total_simulations >= 1);
1104        assert_eq!(stats.total_simulations, stats.results.len());
1105        assert!(stats.win_rate >= dec!(0.0) && stats.win_rate <= dec!(100.0));
1106        assert!(stats.average_holding_period >= dec!(0.0));
1107    }
1108
1109    #[test]
1110    fn test_simulate_time_steps_exit() {
1111        let strategy = create_test_short_put();
1112        let prices = vec![
1113            Positive::HUNDRED,
1114            pos_or_panic!(101.0),
1115            pos_or_panic!(102.0),
1116            pos_or_panic!(103.0),
1117            pos_or_panic!(104.0),
1118        ];
1119
1120        let walk_params = create_walk_params(prices);
1121        let Ok(simulator) = Simulator::new(
1122            "Test Simulator".to_string(),
1123            1,
1124            &walk_params,
1125            generator_positive,
1126        ) else {
1127            panic!("simulator setup failed");
1128        };
1129
1130        let exit_policy = ExitPolicy::TimeSteps(2);
1131        let results = strategy.simulate(&simulator, exit_policy);
1132
1133        assert!(results.is_ok());
1134        let stats = results.unwrap();
1135        assert_eq!(stats.total_simulations, 1);
1136        assert!(!stats.results.is_empty());
1137    }
1138
1139    #[test]
1140    fn test_simulate_underlying_below_exit() {
1141        let strategy = create_test_short_put();
1142        let prices = vec![Positive::HUNDRED, pos_or_panic!(95.0), pos_or_panic!(90.0)];
1143
1144        let walk_params = create_walk_params(prices);
1145        let Ok(simulator) = Simulator::new(
1146            "Test Simulator".to_string(),
1147            1,
1148            &walk_params,
1149            generator_positive,
1150        ) else {
1151            panic!("simulator setup failed");
1152        };
1153
1154        let exit_policy = ExitPolicy::UnderlyingBelow(pos_or_panic!(92.0));
1155        let results = strategy.simulate(&simulator, exit_policy);
1156
1157        assert!(results.is_ok());
1158        let stats = results.unwrap();
1159        assert_eq!(stats.total_simulations, 1);
1160    }
1161
1162    #[test]
1163    fn test_simulate_and_exit_policy() {
1164        let strategy = create_test_short_put();
1165        let prices = vec![
1166            Positive::HUNDRED,
1167            pos_or_panic!(105.0),
1168            pos_or_panic!(110.0),
1169            pos_or_panic!(115.0),
1170        ];
1171
1172        let walk_params = create_walk_params(prices);
1173        let Ok(simulator) = Simulator::new(
1174            "Test Simulator".to_string(),
1175            1,
1176            &walk_params,
1177            generator_positive,
1178        ) else {
1179            panic!("simulator setup failed");
1180        };
1181
1182        let exit_policy = ExitPolicy::And(vec![
1183            ExitPolicy::TimeSteps(2),
1184            ExitPolicy::UnderlyingAbove(pos_or_panic!(105.0)),
1185        ]);
1186        let results = strategy.simulate(&simulator, exit_policy);
1187
1188        assert!(results.is_ok());
1189        let stats = results.unwrap();
1190        assert_eq!(stats.total_simulations, 1);
1191    }
1192
1193    #[test]
1194    fn test_simulate_multiple_simulations() {
1195        let strategy = create_test_short_put();
1196        let prices = vec![
1197            Positive::HUNDRED,
1198            pos_or_panic!(102.0),
1199            pos_or_panic!(104.0),
1200        ];
1201
1202        let walk_params = create_walk_params(prices);
1203        let Ok(simulator) = Simulator::new(
1204            "Test Simulator".to_string(),
1205            10,
1206            &walk_params,
1207            generator_positive,
1208        ) else {
1209            panic!("simulator setup failed");
1210        };
1211
1212        let exit_policy = ExitPolicy::Expiration;
1213        let results = strategy.simulate(&simulator, exit_policy);
1214
1215        assert!(results.is_ok());
1216        let stats = results.unwrap();
1217        assert_eq!(stats.total_simulations, 10);
1218        assert_eq!(stats.results.len(), 10);
1219        assert!(stats.profitable_count + stats.loss_count <= 10);
1220    }
1221
1222    #[test]
1223    fn test_simulate_stats_calculations() {
1224        let strategy = create_test_short_put();
1225        let prices = vec![
1226            Positive::HUNDRED,
1227            pos_or_panic!(105.0),
1228            pos_or_panic!(110.0),
1229        ];
1230
1231        let walk_params = create_walk_params(prices);
1232        let Ok(simulator) = Simulator::new(
1233            "Test Simulator".to_string(),
1234            3,
1235            &walk_params,
1236            generator_positive,
1237        ) else {
1238            panic!("simulator setup failed");
1239        };
1240
1241        let exit_policy = ExitPolicy::Expiration;
1242        let results = strategy.simulate(&simulator, exit_policy);
1243
1244        assert!(results.is_ok());
1245        let stats = results.unwrap();
1246
1247        // Verify stats are calculated
1248        assert!(stats.average_pnl.abs() >= dec!(0.0));
1249        assert!(stats.median_pnl.abs() >= dec!(0.0));
1250        assert!(stats.std_dev_pnl >= dec!(0.0));
1251        assert!(stats.best_pnl >= stats.worst_pnl);
1252    }
1253
1254    #[test]
1255    fn test_simulate_expiration_path() {
1256        let strategy = create_test_short_put();
1257        // Price stays stable, should expire
1258        let prices = vec![
1259            Positive::HUNDRED,
1260            pos_or_panic!(100.5),
1261            pos_or_panic!(101.0),
1262            pos_or_panic!(100.8),
1263        ];
1264
1265        let walk_params = create_walk_params(prices);
1266        let Ok(simulator) = Simulator::new(
1267            "Test Simulator".to_string(),
1268            1,
1269            &walk_params,
1270            generator_positive,
1271        ) else {
1272            panic!("simulator setup failed");
1273        };
1274
1275        let exit_policy = ExitPolicy::Expiration;
1276        let results = strategy.simulate(&simulator, exit_policy);
1277
1278        assert!(results.is_ok());
1279        let stats = results.unwrap();
1280        assert_eq!(stats.total_simulations, 1);
1281        assert!(!stats.results.is_empty());
1282
1283        // Verify expiration was triggered
1284        let result = &stats.results[0];
1285        assert!(result.expired);
1286        assert!(result.expiration_premium.is_some());
1287    }
1288
1289    #[test]
1290    fn test_simulate_profit_target_hit() {
1291        let strategy = create_test_short_put();
1292        // Price rises significantly to trigger profit
1293        let prices = vec![
1294            Positive::HUNDRED,
1295            pos_or_panic!(110.0),
1296            pos_or_panic!(120.0),
1297        ];
1298
1299        let walk_params = create_walk_params(prices);
1300        let Ok(simulator) = Simulator::new(
1301            "Test Simulator".to_string(),
1302            1,
1303            &walk_params,
1304            generator_positive,
1305        ) else {
1306            panic!("simulator setup failed");
1307        };
1308
1309        let exit_policy = ExitPolicy::ProfitPercent(dec!(0.5));
1310        let results = strategy.simulate(&simulator, exit_policy);
1311
1312        assert!(results.is_ok());
1313        let stats = results.unwrap();
1314        assert_eq!(stats.total_simulations, 1);
1315
1316        let result = &stats.results[0];
1317        assert!(result.hit_take_profit || result.expired);
1318    }
1319
1320    #[test]
1321    fn test_simulate_stop_loss_hit() {
1322        let strategy = create_test_short_put();
1323        // Price drops significantly to trigger stop loss
1324        let prices = vec![Positive::HUNDRED, pos_or_panic!(85.0), pos_or_panic!(70.0)];
1325
1326        let walk_params = create_walk_params(prices);
1327        let Ok(simulator) = Simulator::new(
1328            "Test Simulator".to_string(),
1329            1,
1330            &walk_params,
1331            generator_positive,
1332        ) else {
1333            panic!("simulator setup failed");
1334        };
1335
1336        let exit_policy = ExitPolicy::LossPercent(dec!(1.0));
1337        let results = strategy.simulate(&simulator, exit_policy);
1338
1339        assert!(results.is_ok());
1340        let stats = results.unwrap();
1341        assert_eq!(stats.total_simulations, 1);
1342
1343        let result = &stats.results[0];
1344        assert!(result.hit_stop_loss || result.expired);
1345    }
1346
1347    #[test]
1348    fn test_simulate_premium_tracking() {
1349        let strategy = create_test_short_put();
1350        let prices = vec![
1351            Positive::HUNDRED,
1352            pos_or_panic!(95.0),
1353            pos_or_panic!(105.0),
1354            Positive::HUNDRED,
1355        ];
1356
1357        let walk_params = create_walk_params(prices);
1358        let Ok(simulator) = Simulator::new(
1359            "Test Simulator".to_string(),
1360            1,
1361            &walk_params,
1362            generator_positive,
1363        ) else {
1364            panic!("simulator setup failed");
1365        };
1366
1367        let exit_policy = ExitPolicy::Expiration;
1368        let results = strategy.simulate(&simulator, exit_policy);
1369
1370        assert!(results.is_ok());
1371        let stats = results.unwrap();
1372
1373        let result = &stats.results[0];
1374        // Verify premium tracking
1375        assert!(result.max_premium >= result.min_premium);
1376        assert!(result.avg_premium >= result.min_premium);
1377        assert!(result.avg_premium <= result.max_premium);
1378    }
1379
1380    #[test]
1381    fn test_simulate_holding_period() {
1382        let strategy = create_test_short_put();
1383        let prices = vec![
1384            Positive::HUNDRED,
1385            pos_or_panic!(101.0),
1386            pos_or_panic!(102.0),
1387        ];
1388        let price_count = prices.len();
1389
1390        let walk_params = create_walk_params(prices);
1391        let Ok(simulator) = Simulator::new(
1392            "Test Simulator".to_string(),
1393            1,
1394            &walk_params,
1395            generator_positive,
1396        ) else {
1397            panic!("simulator setup failed");
1398        };
1399
1400        let exit_policy = ExitPolicy::TimeSteps(1);
1401        let results = strategy.simulate(&simulator, exit_policy);
1402
1403        assert!(results.is_ok());
1404        let stats = results.unwrap();
1405
1406        let result = &stats.results[0];
1407        assert!(result.holding_period > 0);
1408        assert!(result.holding_period <= price_count);
1409    }
1410
1411    #[test]
1412    fn test_simulate_mixed_results() {
1413        let strategy = create_test_short_put();
1414        let prices = vec![Positive::HUNDRED, pos_or_panic!(102.0), pos_or_panic!(98.0)];
1415
1416        let walk_params = create_walk_params(prices);
1417        let Ok(simulator) = Simulator::new(
1418            "Test Simulator".to_string(),
1419            5,
1420            &walk_params,
1421            generator_positive,
1422        ) else {
1423            panic!("simulator setup failed");
1424        };
1425
1426        let exit_policy = ExitPolicy::Or(vec![
1427            ExitPolicy::ProfitPercent(dec!(0.5)),
1428            ExitPolicy::LossPercent(dec!(1.0)),
1429        ]);
1430        let results = strategy.simulate(&simulator, exit_policy);
1431
1432        assert!(results.is_ok());
1433        let stats = results.unwrap();
1434
1435        // Verify we have results
1436        assert_eq!(stats.total_simulations, 5);
1437        assert_eq!(stats.results.len(), 5);
1438
1439        // Verify win rate is calculated
1440        assert!(stats.win_rate >= dec!(0.0));
1441        assert!(stats.win_rate <= dec!(100.0));
1442    }
1443}