optionstratlib_backtest/backtesting/
adapters.rs1use 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 #[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 fn from(outcome: PathOutcome) -> Self {
65 Self::from_outcome_at(outcome, wall_clock())
66 }
67}
68
69impl From<&SimulationResult> for PathOutcome {
70 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 #[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 #[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 #[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}