Skip to main content

optionstratlib_backtest/backtesting/
adapters.rs

1//! Adapters between the generic simulation contracts and the
2//! strategy-bound result types.
3//!
4//! The simulation layer reports a run as [`optionstratlib_simulation::simulation::PathOutcome`]s and summarises
5//! them as [`optionstratlib_simulation::simulation::PathStatistics`]. Backtesting keeps the richer
6//! [`crate::backtesting::results::SimulationResult`] (carrying a [`optionstratlib_analytics::pnl::PnL`] and optional risk metrics) and
7//! [`SimulationStatsResult`]; this module owns the conversion between the
8//! two shapes so that neither simulation nor strategies has to know about
9//! it. The aggregate figures of a [`SimulationStatsResult`] are always the
10//! ones [`optionstratlib_simulation::simulation::PathStatistics::from_outcomes`] computes.
11
12use crate::backtesting::results::{SimulationResult, SimulationStatsResult};
13use chrono::Utc;
14use optionstratlib_analytics::pnl::PnL;
15use optionstratlib_core::model::Positive;
16use optionstratlib_simulation::error::SimulationError;
17use optionstratlib_simulation::simulation::{PathOutcome, PathStatistics};
18use std::collections::HashMap;
19
20impl From<PathOutcome> for SimulationResult {
21    /// Lifts a generic outcome into the strategy-bound result.
22    ///
23    /// The path P&L becomes the realized leg of a [`PnL`] with no costs,
24    /// no income and the current timestamp; `simulation_count` is one and
25    /// the risk fields are empty, exactly as the strategy simulations have
26    /// always filled them.
27    fn from(outcome: PathOutcome) -> Self {
28        Self {
29            simulation_count: 1,
30            risk_metrics: None,
31            final_equity_percentiles: HashMap::new(),
32            max_premium: outcome.max_premium,
33            min_premium: outcome.min_premium,
34            avg_premium: outcome.avg_premium,
35            hit_take_profit: outcome.hit_take_profit,
36            hit_stop_loss: outcome.hit_stop_loss,
37            expired: outcome.expired,
38            expiration_premium: outcome.expiration_premium,
39            pnl: PnL {
40                realized: outcome.pnl,
41                unrealized: None,
42                initial_costs: Positive::ZERO,
43                initial_income: Positive::ZERO,
44                date_time: Utc::now(),
45            },
46            holding_period: outcome.holding_period,
47            exit_reason: outcome.exit_reason,
48        }
49    }
50}
51
52impl From<&SimulationResult> for PathOutcome {
53    /// Projects a strategy-bound result onto the generic outcome.
54    ///
55    /// `pnl` is [`PnL::total_pnl`] (realized plus unrealized), which is the
56    /// figure the aggregate statistics have always been computed from.
57    fn from(result: &SimulationResult) -> Self {
58        Self {
59            pnl: result.pnl.total_pnl(),
60            holding_period: result.holding_period,
61            exit_reason: result.exit_reason.clone(),
62            hit_take_profit: result.hit_take_profit,
63            hit_stop_loss: result.hit_stop_loss,
64            expired: result.expired,
65            max_premium: result.max_premium,
66            min_premium: result.min_premium,
67            avg_premium: result.avg_premium,
68            expiration_premium: result.expiration_premium,
69        }
70    }
71}
72
73impl SimulationStatsResult {
74    /// Builds the aggregate from a set of per-run results.
75    ///
76    /// The statistics are [`PathStatistics::from_outcomes`] over the
77    /// generic projection of each result; the results themselves are kept
78    /// for the per-run report.
79    ///
80    /// # Errors
81    ///
82    /// Propagates the errors of [`PathStatistics::from_outcomes`].
83    #[must_use = "the aggregate is the only product of this call"]
84    pub fn from_results(results: Vec<SimulationResult>) -> Result<Self, SimulationError> {
85        let outcomes: Vec<PathOutcome> = results.iter().map(PathOutcome::from).collect();
86        let stats = PathStatistics::from_outcomes(&outcomes)?;
87        Ok(Self::from_parts(results, stats))
88    }
89
90    /// Builds the aggregate from a generic run.
91    ///
92    /// Each outcome is lifted into a [`SimulationResult`] through
93    /// `From<PathOutcome>`; the statistics are computed on the outcomes
94    /// before the lift so a `None` P&L is counted the same way either
95    /// entry point is used.
96    ///
97    /// # Errors
98    ///
99    /// Propagates the errors of [`PathStatistics::from_outcomes`].
100    #[must_use = "the aggregate is the only product of this call"]
101    pub fn from_outcomes(outcomes: Vec<PathOutcome>) -> Result<Self, SimulationError> {
102        let stats = PathStatistics::from_outcomes(&outcomes)?;
103        let results = outcomes.into_iter().map(SimulationResult::from).collect();
104        Ok(Self::from_parts(results, stats))
105    }
106
107    /// Pairs already-computed statistics with the results they describe.
108    #[must_use]
109    fn from_parts(results: Vec<SimulationResult>, stats: PathStatistics) -> Self {
110        Self {
111            results,
112            total_simulations: stats.total_paths,
113            profitable_count: stats.profitable_count,
114            loss_count: stats.loss_count,
115            average_pnl: stats.average_pnl,
116            median_pnl: stats.median_pnl,
117            std_dev_pnl: stats.std_dev_pnl,
118            best_pnl: stats.best_pnl,
119            worst_pnl: stats.worst_pnl,
120            win_rate: stats.win_rate,
121            average_holding_period: stats.average_holding_period,
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use optionstratlib_simulation::simulation::ExitPolicy;
130    use rust_decimal::Decimal;
131    use rust_decimal_macros::dec;
132
133    fn outcome(pnl: Option<Decimal>, holding_period: usize) -> PathOutcome {
134        PathOutcome {
135            pnl,
136            holding_period,
137            exit_reason: ExitPolicy::ProfitPercent(dec!(0.5)),
138            hit_take_profit: pnl.is_some_and(|p| p > Decimal::ZERO),
139            hit_stop_loss: pnl.is_some_and(|p| p < Decimal::ZERO),
140            expired: false,
141            max_premium: dec!(9.0),
142            min_premium: dec!(4.0),
143            avg_premium: dec!(6.0),
144            expiration_premium: Some(dec!(4.5)),
145        }
146    }
147
148    #[test]
149    fn test_path_outcome_round_trips_through_simulation_result() {
150        let original = outcome(Some(dec!(-12.0)), 7);
151        let result = SimulationResult::from(original.clone());
152        assert_eq!(result.simulation_count, 1);
153        assert!(result.risk_metrics.is_none());
154        assert!(result.final_equity_percentiles.is_empty());
155        assert_eq!(result.pnl.realized, Some(dec!(-12.0)));
156        assert_eq!(result.pnl.unrealized, None);
157        assert_eq!(result.pnl.initial_costs, Positive::ZERO);
158        assert_eq!(result.pnl.initial_income, Positive::ZERO);
159        assert_eq!(PathOutcome::from(&result), original);
160    }
161
162    #[test]
163    fn test_path_outcome_from_result_uses_total_pnl() {
164        let mut result = SimulationResult::from(outcome(Some(dec!(10.0)), 1));
165        result.pnl.unrealized = Some(dec!(2.5));
166        assert_eq!(PathOutcome::from(&result).pnl, Some(dec!(12.5)));
167    }
168
169    #[test]
170    fn test_from_outcomes_matches_path_statistics() {
171        let outcomes = vec![
172            outcome(Some(dec!(50.0)), 10),
173            outcome(Some(dec!(-100.0)), 20),
174            outcome(Some(dec!(25.0)), 15),
175            outcome(None, 5),
176        ];
177        let expected = PathStatistics::from_outcomes(&outcomes).unwrap();
178        let stats = SimulationStatsResult::from_outcomes(outcomes).unwrap();
179
180        assert_eq!(stats.results.len(), 4);
181        assert_eq!(stats.total_simulations, expected.total_paths);
182        assert_eq!(stats.profitable_count, expected.profitable_count);
183        assert_eq!(stats.loss_count, expected.loss_count);
184        assert_eq!(stats.average_pnl, expected.average_pnl);
185        assert_eq!(stats.median_pnl, expected.median_pnl);
186        assert_eq!(stats.std_dev_pnl, expected.std_dev_pnl);
187        assert_eq!(stats.best_pnl, expected.best_pnl);
188        assert_eq!(stats.worst_pnl, expected.worst_pnl);
189        assert_eq!(stats.win_rate, expected.win_rate);
190        assert_eq!(
191            stats.average_holding_period,
192            expected.average_holding_period
193        );
194        assert_eq!(stats.average_pnl, dec!(-6.25));
195        assert_eq!(stats.median_pnl, dec!(12.5));
196        assert_eq!(stats.win_rate, dec!(50.0));
197    }
198
199    #[test]
200    fn test_from_results_and_from_outcomes_agree() {
201        let outcomes = vec![
202            outcome(Some(dec!(3.0)), 1),
203            outcome(Some(dec!(-1.0)), 3),
204            outcome(Some(dec!(7.0)), 5),
205        ];
206        let via_outcomes = SimulationStatsResult::from_outcomes(outcomes.clone()).unwrap();
207        let results: Vec<SimulationResult> =
208            outcomes.into_iter().map(SimulationResult::from).collect();
209        let via_results = SimulationStatsResult::from_results(results).unwrap();
210
211        assert_eq!(via_results.total_simulations, 3);
212        assert_eq!(via_results.average_pnl, via_outcomes.average_pnl);
213        assert_eq!(via_results.median_pnl, dec!(3.0));
214        assert_eq!(via_results.std_dev_pnl, dec!(4.0));
215        assert_eq!(via_results.best_pnl, dec!(7.0));
216        assert_eq!(via_results.worst_pnl, dec!(-1.0));
217        assert_eq!(via_results.average_holding_period, dec!(3));
218    }
219
220    #[test]
221    fn test_from_outcomes_empty_run_is_all_zero() {
222        let stats = SimulationStatsResult::from_outcomes(Vec::new()).unwrap();
223        assert!(stats.results.is_empty());
224        assert_eq!(stats.total_simulations, 0);
225        assert_eq!(stats.average_pnl, Decimal::ZERO);
226        assert_eq!(stats.win_rate, Decimal::ZERO);
227    }
228
229    #[test]
230    fn test_from_outcomes_reports_overflow() {
231        let outcomes = vec![
232            outcome(Some(Decimal::MAX), 1),
233            outcome(Some(Decimal::MAX), 1),
234        ];
235        assert!(matches!(
236            SimulationStatsResult::from_outcomes(outcomes),
237            Err(SimulationError::Decimal(_))
238        ));
239    }
240}