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