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