1use 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#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
41pub struct PathOutcome {
42 pub pnl: Option<Decimal>,
46 pub holding_period: usize,
48 pub exit_reason: ExitPolicy,
50 pub hit_take_profit: bool,
52 pub hit_stop_loss: bool,
54 pub expired: bool,
56 pub max_premium: Decimal,
58 pub min_premium: Decimal,
60 pub avg_premium: Decimal,
62 pub expiration_premium: Option<Decimal>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
71pub struct PathStatistics {
72 pub total_paths: usize,
74 pub profitable_count: usize,
76 pub loss_count: usize,
78 pub average_pnl: Decimal,
80 pub median_pnl: Decimal,
82 pub std_dev_pnl: Decimal,
85 pub best_pnl: Decimal,
87 pub worst_pnl: Decimal,
89 pub win_rate: Decimal,
91 pub average_holding_period: Decimal,
93}
94
95#[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 #[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
263pub trait PathEvaluator<X, Y>
276where
277 X: Copy + TryInto<Positive> + AddAssign + Display,
278 Y: TryInto<Positive> + Display + Clone,
279{
280 type Outcome;
282
283 type Error: From<SimulationError>;
289
290 fn evaluate_path(
297 &self,
298 walk: &RandomWalk<X, Y>,
299 exit: &ExitPolicy,
300 ) -> Result<Self::Outcome, Self::Error>;
301}
302
303#[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 #[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 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, ¶ms, 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, ¶ms, generator_positive).unwrap();
547 assert!(matches!(
548 evaluate_paths(&AlwaysFails, &sim, &ExitPolicy::Expiration),
549 Err(SimulationError::InvalidParameters { .. })
550 ));
551 }
552}