Skip to main content

optionstratlib_simulation/simulation/
outcome.rs

1//! Generic path evaluation contracts.
2//!
3//! A simulation produces price paths; what a path is worth depends on the
4//! caller (a strategy, a hedging rule, a research fixture). This module
5//! defines the contract between the two halves without naming either side:
6//!
7//! - [`PathEvaluator`] turns one [`crate::simulation::randomwalk::RandomWalk`] into an outcome of the
8//!   caller's choosing.
9//! - [`PathOutcome`] is the per-path record the library understands: a
10//!   P&L, a holding period, an exit reason and the premium marks observed
11//!   along the way. It is built from core types only.
12//! - [`PathStatistics`] summarises a set of outcomes (mean, median, sample
13//!   standard deviation, best, worst, win rate, average holding period).
14//! - [`evaluate_paths`] runs an evaluator over every walk of a
15//!   [`crate::simulation::simulator::Simulator`].
16//!
17//! Strategy-bound result types (the ones carrying `PnL` and risk metrics)
18//! live in the backtesting layer, which adapts them to and from
19//! [`PathOutcome`].
20
21use crate::error::SimulationError;
22use crate::simulation::ExitPolicy;
23use crate::simulation::randomwalk::RandomWalk;
24use crate::simulation::simulator::Simulator;
25use optionstratlib_core::model::Positive;
26use optionstratlib_core::model::decimal::{d_add, d_div, d_mul, d_sqrt, d_sub, d_sum_iter};
27use optionstratlib_core::utils::Len;
28use rust_decimal::Decimal;
29use rust_decimal_macros::dec;
30use serde::{Deserialize, Serialize};
31use std::fmt::Display;
32use std::ops::AddAssign;
33
34/// Outcome of evaluating one simulated price path.
35///
36/// Every field is a core type, so the record can be produced by any
37/// evaluator and summarised by [`PathStatistics`] without knowing what was
38/// traded along the path. Premium fields are the marks observed while the
39/// position was open, in the quote currency of the instrument.
40#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
41pub struct PathOutcome {
42    /// Total P&L of the path, in quote currency. `None` when the path
43    /// could not be evaluated (an empty walk yields no P&L); summaries
44    /// count such a path as zero.
45    pub pnl: Option<Decimal>,
46    /// Number of steps the position was held before exit or expiry.
47    pub holding_period: usize,
48    /// Policy that closed the position (`Expiration` when none fired).
49    pub exit_reason: ExitPolicy,
50    /// Whether the take-profit threshold was reached at exit.
51    pub hit_take_profit: bool,
52    /// Whether the stop-loss threshold was reached at exit.
53    pub hit_stop_loss: bool,
54    /// Whether the position ran to expiry without an exit trigger.
55    pub expired: bool,
56    /// Highest premium mark observed along the path.
57    pub max_premium: Decimal,
58    /// Lowest premium mark observed along the path.
59    pub min_premium: Decimal,
60    /// Arithmetic mean of the premium marks observed along the path.
61    pub avg_premium: Decimal,
62    /// Premium mark at expiry; `None` when the position exited early.
63    pub expiration_premium: Option<Decimal>,
64}
65
66/// Summary statistics over a set of [`PathOutcome`]s.
67///
68/// P&L figures are in quote currency; `win_rate` is a percentage
69/// (`0..=100`); `average_holding_period` is in steps.
70#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
71pub struct PathStatistics {
72    /// Number of outcomes summarised.
73    pub total_paths: usize,
74    /// Outcomes with a strictly positive P&L.
75    pub profitable_count: usize,
76    /// Outcomes with a strictly negative P&L.
77    pub loss_count: usize,
78    /// Mean P&L.
79    pub average_pnl: Decimal,
80    /// Median P&L (mean of the two middle values for an even count).
81    pub median_pnl: Decimal,
82    /// Sample standard deviation of the P&L (`n - 1` denominator; zero for
83    /// fewer than two outcomes).
84    pub std_dev_pnl: Decimal,
85    /// Highest P&L.
86    pub best_pnl: Decimal,
87    /// Lowest P&L.
88    pub worst_pnl: Decimal,
89    /// Percentage of outcomes with a strictly positive P&L.
90    pub win_rate: Decimal,
91    /// Mean holding period in steps.
92    pub average_holding_period: Decimal,
93}
94
95/// Divides without the default rounding of [`d_div`].
96///
97/// The strategy simulations have always formed their averages and ratios
98/// with the raw `Decimal` operator, which keeps the full quotient; this
99/// helper reports the overflow that operator would abort on and changes
100/// nothing else, so the summaries stay bit-for-bit what they were.
101#[inline]
102fn div_unrounded(lhs: Decimal, rhs: Decimal, op: &'static str) -> Result<Decimal, SimulationError> {
103    if rhs.is_zero() {
104        return Err(SimulationError::invalid_parameters(&format!(
105            "{op}: division by zero"
106        )));
107    }
108    lhs.checked_div(rhs).ok_or_else(|| {
109        SimulationError::invalid_parameters(&format!("{op}: {lhs} / {rhs} overflowed"))
110    })
111}
112
113impl PathStatistics {
114    /// Summarises a set of outcomes.
115    ///
116    /// An outcome without a P&L counts as zero. The median averages the
117    /// two middle values for an even count; the standard deviation uses
118    /// the sample (`n - 1`) denominator and is zero for fewer than two
119    /// outcomes. An empty set yields all-zero statistics.
120    ///
121    /// # Errors
122    ///
123    /// Returns [`SimulationError::Decimal`] when a sum, difference,
124    /// product, or the median and variance quotients (which go through
125    /// `d_div`) leave the representable `Decimal` range, and
126    /// [`SimulationError::InvalidParameters`] when one of the three
127    /// unrounded quotients (mean, win rate, average holding period) does or
128    /// the holding-period total overflows `usize`.
129    #[must_use = "the statistics are the only product of this call"]
130    pub fn from_outcomes(outcomes: &[PathOutcome]) -> Result<Self, SimulationError> {
131        let total_paths = outcomes.len();
132        let mut pnl_values: Vec<Decimal> = outcomes
133            .iter()
134            .map(|outcome| outcome.pnl.unwrap_or(dec!(0.0)))
135            .collect();
136        pnl_values.sort();
137
138        let profitable_count = pnl_values.iter().filter(|&&pnl| pnl > dec!(0.0)).count();
139        let loss_count = pnl_values.iter().filter(|&&pnl| pnl < dec!(0.0)).count();
140
141        let sum_pnl = d_sum_iter(
142            pnl_values.iter().copied(),
143            "PathStatistics::from_outcomes/sum_pnl",
144        )?;
145        let average_pnl = if total_paths > 0 {
146            div_unrounded(
147                sum_pnl,
148                Decimal::from(total_paths),
149                "PathStatistics::from_outcomes/average_pnl",
150            )?
151        } else {
152            dec!(0.0)
153        };
154
155        let median_pnl = if pnl_values.is_empty() {
156            dec!(0.0)
157        } else {
158            let mid = pnl_values.len() / 2;
159            let at = |i: usize| {
160                pnl_values.get(i).copied().ok_or_else(|| {
161                    SimulationError::invalid_parameters(
162                        "PathStatistics::from_outcomes: median index out of range",
163                    )
164                })
165            };
166            if pnl_values.len().is_multiple_of(2) {
167                let lower = mid.checked_sub(1).ok_or_else(|| {
168                    SimulationError::invalid_parameters(
169                        "PathStatistics::from_outcomes: even sample count with no lower median",
170                    )
171                })?;
172                d_div(
173                    d_add(at(lower)?, at(mid)?, "PathStatistics::from_outcomes/median")?,
174                    dec!(2.0),
175                    "PathStatistics::from_outcomes/median",
176                )?
177            } else {
178                at(mid)?
179            }
180        };
181
182        let variance = if total_paths > 1 {
183            let mut sum_squared_diff = Decimal::ZERO;
184            for &pnl in &pnl_values {
185                let diff = d_sub(
186                    pnl,
187                    average_pnl,
188                    "PathStatistics::from_outcomes/variance_diff",
189                )?;
190                sum_squared_diff = d_add(
191                    sum_squared_diff,
192                    d_mul(diff, diff, "PathStatistics::from_outcomes/variance_square")?,
193                    "PathStatistics::from_outcomes/variance_sum",
194                )?;
195            }
196            d_div(
197                sum_squared_diff,
198                Decimal::from(total_paths.checked_sub(1).ok_or_else(|| {
199                    SimulationError::invalid_parameters(
200                        "PathStatistics::from_outcomes: variance of an empty sample",
201                    )
202                })?),
203                "PathStatistics::from_outcomes/variance",
204            )?
205        } else {
206            dec!(0.0)
207        };
208        let std_dev_pnl =
209            d_sqrt(variance, "simulation::outcome::from_outcomes").unwrap_or(dec!(0.0));
210
211        let best_pnl = pnl_values.last().copied().unwrap_or(dec!(0.0));
212        let worst_pnl = pnl_values.first().copied().unwrap_or(dec!(0.0));
213
214        let win_rate = if total_paths > 0 {
215            d_mul(
216                div_unrounded(
217                    Decimal::from(profitable_count),
218                    Decimal::from(total_paths),
219                    "PathStatistics::from_outcomes/win_rate",
220                )?,
221                dec!(100.0),
222                "PathStatistics::from_outcomes/win_rate",
223            )?
224        } else {
225            dec!(0.0)
226        };
227
228        let average_holding_period = if total_paths > 0 {
229            let sum_holding = outcomes
230                .iter()
231                .try_fold(0usize, |acc, outcome| {
232                    acc.checked_add(outcome.holding_period)
233                })
234                .ok_or_else(|| {
235                    SimulationError::invalid_parameters(
236                        "PathStatistics::from_outcomes: holding period total overflowed",
237                    )
238                })?;
239            div_unrounded(
240                Decimal::from(sum_holding),
241                Decimal::from(total_paths),
242                "PathStatistics::from_outcomes/average_holding_period",
243            )?
244        } else {
245            dec!(0.0)
246        };
247
248        Ok(Self {
249            total_paths,
250            profitable_count,
251            loss_count,
252            average_pnl,
253            median_pnl,
254            std_dev_pnl,
255            best_pnl,
256            worst_pnl,
257            win_rate,
258            average_holding_period,
259        })
260    }
261}
262
263/// Evaluates one simulated price path.
264///
265/// Implementors decide what a path is worth; the simulation layer only
266/// generates the paths and, through [`evaluate_paths`], drives the
267/// evaluator over each of them. `Outcome` is usually [`PathOutcome`] so
268/// the result can be summarised by [`PathStatistics`], but any type is
269/// admissible for evaluators with their own reporting.
270///
271/// # Type Parameters
272///
273/// * `X` - Time-step type of the walk.
274/// * `Y` - Price type of the walk.
275pub trait PathEvaluator<X, Y>
276where
277    X: Copy + TryInto<Positive> + AddAssign + Display,
278    Y: TryInto<Positive> + Display + Clone,
279{
280    /// What one evaluated path produces.
281    type Outcome;
282
283    /// What one evaluated path reports when it fails.
284    ///
285    /// The evaluator lives in the caller's layer, so it reports the caller's
286    /// error: a backtest reports `BacktestError`, keeping the strategy and
287    /// simulation causes typed, without simulation naming either.
288    type Error: From<SimulationError>;
289
290    /// Evaluates `walk` under `exit`.
291    ///
292    /// # Errors
293    ///
294    /// Implementation-defined; typically a pricing failure or an invalid
295    /// step along the walk.
296    fn evaluate_path(
297        &self,
298        walk: &RandomWalk<X, Y>,
299        exit: &ExitPolicy,
300    ) -> Result<Self::Outcome, Self::Error>;
301}
302
303/// Runs `evaluator` over every walk in `sim`, in order.
304///
305/// The result has one entry per walk. The first evaluation failure aborts
306/// the run; partial results are not returned.
307///
308/// # Errors
309///
310/// Propagates the first error returned by
311/// [`PathEvaluator::evaluate_path`].
312#[must_use = "the outcomes are the only product of this call"]
313pub fn evaluate_paths<X, Y, E>(
314    evaluator: &E,
315    sim: &Simulator<X, Y>,
316    exit: &ExitPolicy,
317) -> Result<Vec<E::Outcome>, E::Error>
318where
319    X: Copy + TryInto<Positive> + AddAssign + Display,
320    Y: TryInto<Positive> + Display + Clone,
321    E: PathEvaluator<X, Y>,
322{
323    let mut outcomes = Vec::with_capacity(sim.len());
324    for walk in sim {
325        outcomes.push(evaluator.evaluate_path(walk, exit)?);
326    }
327    Ok(outcomes)
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::simulation::steps::Step;
334    use crate::simulation::{WalkParams, WalkType, WalkTypeAble, generator_positive};
335    use optionstratlib_core::model::ExpirationDate;
336    use optionstratlib_core::pos_or_panic;
337    use optionstratlib_core::utils::TimeFrame;
338
339    fn outcome(
340        pnl: Option<Decimal>,
341        holding_period: usize,
342        exit_reason: ExitPolicy,
343    ) -> PathOutcome {
344        PathOutcome {
345            pnl,
346            holding_period,
347            exit_reason,
348            hit_take_profit: false,
349            hit_stop_loss: false,
350            expired: false,
351            max_premium: dec!(10.0),
352            min_premium: dec!(5.0),
353            avg_premium: dec!(7.5),
354            expiration_premium: None,
355        }
356    }
357
358    #[test]
359    fn test_path_statistics_empty_set_all_zero() {
360        let stats = PathStatistics::from_outcomes(&[]).unwrap();
361        assert_eq!(stats, PathStatistics::default());
362    }
363
364    #[test]
365    fn test_path_statistics_single_outcome_zero_std_dev() {
366        let stats =
367            PathStatistics::from_outcomes(&[outcome(Some(dec!(12.5)), 4, ExitPolicy::Expiration)])
368                .unwrap();
369        assert_eq!(stats.total_paths, 1);
370        assert_eq!(stats.profitable_count, 1);
371        assert_eq!(stats.loss_count, 0);
372        assert_eq!(stats.average_pnl, dec!(12.5));
373        assert_eq!(stats.median_pnl, dec!(12.5));
374        assert_eq!(stats.std_dev_pnl, dec!(0.0));
375        assert_eq!(stats.best_pnl, dec!(12.5));
376        assert_eq!(stats.worst_pnl, dec!(12.5));
377        assert_eq!(stats.win_rate, dec!(100.0));
378        assert_eq!(stats.average_holding_period, dec!(4));
379    }
380
381    /// Hand-built set: P&L {50, -100, 25, 0}, holding {10, 20, 15, 5}.
382    /// Sorted P&L: [-100, 0, 25, 50]; mean -6.25; median (0 + 25) / 2 =
383    /// 12.5; sample variance (8789.0625 + 39.0625 + 976.5625 + 3164.0625)
384    /// / 3 = 4322.9166...; win rate 2 / 4 = 50 %.
385    #[test]
386    fn test_path_statistics_hand_built_set_exact() {
387        let outcomes = [
388            outcome(Some(dec!(50.0)), 10, ExitPolicy::ProfitPercent(dec!(0.5))),
389            outcome(Some(dec!(-100.0)), 20, ExitPolicy::LossPercent(dec!(1.0))),
390            outcome(Some(dec!(25.0)), 15, ExitPolicy::Expiration),
391            outcome(None, 5, ExitPolicy::Expiration),
392        ];
393        let stats = PathStatistics::from_outcomes(&outcomes).unwrap();
394
395        assert_eq!(stats.total_paths, 4);
396        assert_eq!(stats.profitable_count, 2);
397        assert_eq!(stats.loss_count, 1);
398        assert_eq!(stats.average_pnl, dec!(-6.25));
399        assert_eq!(stats.median_pnl, dec!(12.5));
400        assert_eq!(stats.best_pnl, dec!(50.0));
401        assert_eq!(stats.worst_pnl, dec!(-100.0));
402        assert_eq!(stats.win_rate, dec!(50.0));
403        assert_eq!(stats.average_holding_period, dec!(12.5));
404
405        let variance = d_div(dec!(12968.75), dec!(3), "test/variance").unwrap();
406        assert_eq!(stats.std_dev_pnl, d_sqrt(variance, "test").unwrap());
407    }
408
409    #[test]
410    fn test_path_statistics_odd_count_median_is_middle_value() {
411        let outcomes = [
412            outcome(Some(dec!(3.0)), 1, ExitPolicy::Expiration),
413            outcome(Some(dec!(-1.0)), 1, ExitPolicy::Expiration),
414            outcome(Some(dec!(7.0)), 1, ExitPolicy::Expiration),
415        ];
416        let stats = PathStatistics::from_outcomes(&outcomes).unwrap();
417        assert_eq!(stats.median_pnl, dec!(3.0));
418        assert_eq!(stats.average_pnl, dec!(3.0));
419        assert_eq!(stats.std_dev_pnl, dec!(4.0));
420    }
421
422    #[test]
423    fn test_path_statistics_sum_overflow_is_reported() {
424        let outcomes = [
425            outcome(Some(Decimal::MAX), 1, ExitPolicy::Expiration),
426            outcome(Some(Decimal::MAX), 1, ExitPolicy::Expiration),
427        ];
428        assert!(matches!(
429            PathStatistics::from_outcomes(&outcomes),
430            Err(SimulationError::Decimal(_))
431        ));
432    }
433
434    #[test]
435    fn test_div_unrounded_reports_division_by_zero() {
436        assert!(matches!(
437            div_unrounded(dec!(1), Decimal::ZERO, "test"),
438            Err(SimulationError::InvalidParameters { .. })
439        ));
440    }
441
442    /// A simulation-only evaluator: P&L is the move from the first to the
443    /// last price, holding period the number of steps taken.
444    struct LastMinusFirst;
445
446    impl PathEvaluator<Positive, Positive> for LastMinusFirst {
447        type Outcome = PathOutcome;
448        type Error = SimulationError;
449
450        fn evaluate_path(
451            &self,
452            walk: &RandomWalk<Positive, Positive>,
453            _exit: &ExitPolicy,
454        ) -> Result<PathOutcome, SimulationError> {
455            let steps = walk.get_steps();
456            let first = steps
457                .first()
458                .ok_or_else(|| SimulationError::invalid_parameters("empty walk"))?;
459            let last = steps
460                .last()
461                .ok_or_else(|| SimulationError::invalid_parameters("empty walk"))?;
462            let pnl = d_sub(
463                last.y.positive()?.to_dec(),
464                first.y.positive()?.to_dec(),
465                "test",
466            )?;
467            Ok(PathOutcome {
468                pnl: Some(pnl),
469                holding_period: steps.len() - 1,
470                exit_reason: ExitPolicy::Expiration,
471                expired: true,
472                ..PathOutcome::default()
473            })
474        }
475    }
476
477    #[derive(Clone)]
478    struct TestWalker;
479    impl WalkTypeAble<Positive, Positive> for TestWalker {}
480
481    fn historical_params(prices: Vec<Positive>) -> WalkParams<Positive, Positive> {
482        let init_step = Step::new(
483            Positive::ONE,
484            TimeFrame::Day,
485            ExpirationDate::Days(pos_or_panic!(30.0)),
486            Positive::HUNDRED,
487        );
488        WalkParams {
489            size: prices.len(),
490            init_step,
491            walker: Box::new(TestWalker),
492            walk_type: WalkType::Historical {
493                timeframe: TimeFrame::Day,
494                prices,
495                symbol: Some("TEST".to_string()),
496            },
497            seed: None,
498        }
499    }
500
501    #[test]
502    fn test_evaluate_paths_simulation_only_fixture_summarises() {
503        let params = historical_params(vec![
504            Positive::HUNDRED,
505            pos_or_panic!(105.0),
506            pos_or_panic!(110.0),
507        ]);
508        let sim = Simulator::new("fixture".to_string(), 3, &params, generator_positive).unwrap();
509
510        let outcomes = evaluate_paths(&LastMinusFirst, &sim, &ExitPolicy::Expiration).unwrap();
511        assert_eq!(outcomes.len(), 3);
512        for outcome in &outcomes {
513            assert_eq!(outcome.pnl, Some(dec!(10.0)));
514            assert_eq!(outcome.holding_period, 2);
515            assert!(outcome.expired);
516        }
517
518        let stats = PathStatistics::from_outcomes(&outcomes).unwrap();
519        assert_eq!(stats.total_paths, 3);
520        assert_eq!(stats.profitable_count, 3);
521        assert_eq!(stats.average_pnl, dec!(10.0));
522        assert_eq!(stats.median_pnl, dec!(10.0));
523        assert_eq!(stats.std_dev_pnl, dec!(0.0));
524        assert_eq!(stats.win_rate, dec!(100.0));
525        assert_eq!(stats.average_holding_period, dec!(2));
526    }
527
528    struct AlwaysFails;
529
530    impl PathEvaluator<Positive, Positive> for AlwaysFails {
531        type Outcome = ();
532        type Error = SimulationError;
533
534        fn evaluate_path(
535            &self,
536            _walk: &RandomWalk<Positive, Positive>,
537            _exit: &ExitPolicy,
538        ) -> Result<(), SimulationError> {
539            Err(SimulationError::invalid_parameters("fixture failure"))
540        }
541    }
542
543    #[test]
544    fn test_evaluate_paths_propagates_first_error() {
545        let params = historical_params(vec![Positive::HUNDRED, pos_or_panic!(101.0)]);
546        let sim = Simulator::new("fixture".to_string(), 2, &params, generator_positive).unwrap();
547        assert!(matches!(
548            evaluate_paths(&AlwaysFails, &sim, &ExitPolicy::Expiration),
549            Err(SimulationError::InvalidParameters { .. })
550        ));
551    }
552}