1use super::table::{Section, boxed_table, header_row, print, render};
4use crate::error::GraphError;
5use optionstratlib_backtest::backtesting::{
6 SimulationResult, SimulationStats, SimulationStatsResult,
7};
8use optionstratlib_core::model::DisplayMoney;
9use optionstratlib_core::model::decimal::d_sum_iter;
10use prettytable::{Attr, Cell, Row, Table, color};
11use rust_decimal::{Decimal, RoundingStrategy};
12
13const SUMMARY_BANNER: &str = "========== SIMULATION SUMMARY ==========";
15const INDIVIDUAL_RESULTS_BANNER: &str = "========== INDIVIDUAL SIMULATION RESULTS ==========";
17const INDIVIDUAL_RESULTS_HEADERS: [&str; 7] = [
19 "Sim",
20 "Max\nPremium",
21 "Min\nPremium",
22 "Avg\nPremium",
23 "Final\nP&L",
24 "Holding\nPeriod",
25 "Exit\nReason",
26];
27const DISPLAY_DECIMALS: u32 = 2;
29const PERCENT: f64 = 100.0;
31
32pub trait SimulationReport {
70 fn render_summary(&self) -> Result<String, GraphError>;
80
81 fn print_summary(&self) -> Result<(), GraphError>;
89
90 #[must_use]
92 fn render_individual_results(&self) -> String;
93
94 fn print_individual_results(&self) -> Result<(), GraphError>;
101}
102
103impl SimulationReport for SimulationStatsResult {
104 fn render_summary(&self) -> Result<String, GraphError> {
105 Ok(render(&result_summary_sections(self)?))
106 }
107
108 fn print_summary(&self) -> Result<(), GraphError> {
109 print(&result_summary_sections(self)?)
110 }
111
112 fn render_individual_results(&self) -> String {
113 render(&individual_results_sections(&self.results, total_pnl_of))
114 }
115
116 fn print_individual_results(&self) -> Result<(), GraphError> {
117 print(&individual_results_sections(&self.results, total_pnl_of))
118 }
119}
120
121impl SimulationReport for SimulationStats {
122 fn render_summary(&self) -> Result<String, GraphError> {
123 Ok(render(&stats_summary_sections(self)?))
124 }
125
126 fn print_summary(&self) -> Result<(), GraphError> {
127 print(&stats_summary_sections(self)?)
128 }
129
130 fn render_individual_results(&self) -> String {
131 render(&individual_results_sections(self.results(), total_pnl_of))
132 }
133
134 fn print_individual_results(&self) -> Result<(), GraphError> {
135 print(&individual_results_sections(self.results(), total_pnl_of))
136 }
137}
138
139#[must_use]
142#[inline]
143fn total_pnl_of(result: &SimulationResult) -> Decimal {
144 result.pnl.total_pnl().unwrap_or_default()
145}
146
147#[must_use]
152fn percentage(count: usize, total: usize) -> String {
153 format!("{:.2}%", (count as f64 / total as f64) * PERCENT)
154}
155
156#[must_use]
162fn two_decimals(value: Decimal) -> String {
163 format!(
164 "{:.2}",
165 value.round_dp_with_strategy(DISPLAY_DECIMALS, RoundingStrategy::MidpointAwayFromZero)
166 )
167}
168
169#[must_use]
172#[inline]
173fn money(value: Decimal) -> String {
174 format!("{:.2}", DisplayMoney(value))
175}
176
177#[must_use]
179fn pnl_cell(value: Decimal) -> Cell {
180 let cell = Cell::new(&money(value));
181 if value < Decimal::ZERO {
182 cell.with_style(Attr::ForegroundColor(color::RED))
183 } else if value > Decimal::ZERO {
184 cell.with_style(Attr::ForegroundColor(color::GREEN))
185 } else {
186 cell
187 }
188}
189
190#[must_use]
192fn titled_table(headers: &[&str]) -> Table {
193 let mut table = boxed_table();
194 table.set_titles(header_row(headers, color::BLUE));
195 table
196}
197
198#[must_use]
200fn run_count_table(total_simulations: usize) -> Table {
201 let mut table = titled_table(&["Metric", "Value"]);
202 table.add_row(Row::new(vec![
203 Cell::new("Total Simulations"),
204 Cell::new(&total_simulations.to_string()),
205 ]));
206 table
207}
208
209#[must_use]
211fn holding_period_table(period: String) -> Table {
212 let mut table = titled_table(&["Metric", "Value"]);
213 table.add_row(Row::new(vec![
214 Cell::new("Average Holding Period"),
215 Cell::new(&format!("{period} steps")),
216 ]));
217 table
218}
219
220#[must_use]
225fn count_by_text(texts: impl Iterator<Item = String>) -> Vec<(String, usize)> {
226 let mut sorted: Vec<String> = texts.collect();
227 sorted.sort();
228 let mut counts = Vec::new();
229 let mut rest = sorted.as_slice();
230 while let Some(first) = rest.first() {
231 let run = rest.partition_point(|text| text == first);
232 counts.push((first.clone(), run));
233 rest = rest.get(run..).unwrap_or_default();
234 }
235 counts
236}
237
238#[must_use]
240fn exit_reasons_table(mut reasons: Vec<(String, usize)>, total: usize) -> Table {
241 reasons.sort();
242 let mut table = titled_table(&["Exit Reason", "Count", "Percentage"]);
243 for (reason, count) in reasons {
244 table.add_row(Row::new(vec![
245 Cell::new(&reason),
246 Cell::new(&count.to_string()),
247 Cell::new(&percentage(count, total)),
248 ]));
249 }
250 table
251}
252
253fn result_summary_sections(stats: &SimulationStatsResult) -> Result<Vec<Section>, GraphError> {
255 let total = stats.total_simulations;
256 let expired_count = stats.results.iter().filter(|r| r.expired).count();
257
258 let mut outcomes = titled_table(&["Outcome", "Count", "Percentage"]);
259 outcomes.add_row(Row::new(vec![
260 Cell::new("Profitable Trades"),
261 Cell::new(&stats.profitable_count.to_string()),
262 Cell::new(&format!("{:.2}%", stats.win_rate)),
263 ]));
264 outcomes.add_row(Row::new(vec![
265 Cell::new("Loss Trades"),
266 Cell::new(&stats.loss_count.to_string()),
267 Cell::new(&percentage(stats.loss_count, total)),
268 ]));
269 outcomes.add_row(Row::new(vec![
270 Cell::new("Expired Trades"),
271 Cell::new(&expired_count.to_string()),
272 Cell::new(&percentage(expired_count, total)),
273 ]));
274
275 let total_pnl = d_sum_iter(
276 stats.results.iter().filter_map(|r| r.pnl.total_pnl()),
277 "visualization::terminal::total_pnl",
278 )?;
279 let mut pnl = titled_table(&["Metric", "Amount"]);
280 pnl.add_row(Row::new(vec![Cell::new("Total P&L"), pnl_cell(total_pnl)]));
281 pnl.add_row(Row::new(vec![
282 Cell::new("Average P&L per Trade"),
283 pnl_cell(stats.average_pnl),
284 ]));
285 pnl.add_row(Row::new(vec![
286 Cell::new("Median P&L"),
287 pnl_cell(stats.median_pnl),
288 ]));
289 pnl.add_row(Row::new(vec![
290 Cell::new("Std Dev P&L"),
291 Cell::new(&money(stats.std_dev_pnl)),
292 ]));
293 pnl.add_row(Row::new(vec![
294 Cell::new("Maximum Profit"),
295 pnl_cell(stats.best_pnl),
296 ]));
297 pnl.add_row(Row::new(vec![
298 Cell::new("Maximum Loss"),
299 pnl_cell(stats.worst_pnl),
300 ]));
301
302 let reasons = count_by_text(stats.results.iter().map(|r| r.exit_reason.to_string()));
303
304 Ok(vec![
305 Section::titled(SUMMARY_BANNER, run_count_table(total)),
306 Section::titled("--- Trade Outcomes ---", outcomes),
307 Section::titled("--- Profit/Loss Statistics ---", pnl),
308 Section::titled(
309 "--- Holding Period ---",
310 holding_period_table(format!("{:.2}", stats.average_holding_period)),
311 ),
312 Section::titled("--- Exit Reasons ---", exit_reasons_table(reasons, total)),
313 ])
314}
315
316fn stats_summary_sections(stats: &SimulationStats) -> Result<Vec<Section>, GraphError> {
322 let total = stats.total_simulations();
323 let statistics = stats.statistics()?;
324
325 let mut outcomes = titled_table(&["Outcome", "Count", "Percentage"]);
326 if total > 0 {
327 outcomes.add_row(Row::new(vec![
328 Cell::new("Take-Profit Closes"),
329 Cell::new(&stats.profitable_closes().to_string())
330 .with_style(Attr::ForegroundColor(color::GREEN)),
331 Cell::new(&percentage(stats.profitable_closes(), total))
332 .with_style(Attr::ForegroundColor(color::GREEN)),
333 ]));
334 outcomes.add_row(Row::new(vec![
335 Cell::new("Stop-Loss Closes"),
336 Cell::new(&stats.loss_closes().to_string())
337 .with_style(Attr::ForegroundColor(color::RED)),
338 Cell::new(&percentage(stats.loss_closes(), total))
339 .with_style(Attr::ForegroundColor(color::RED)),
340 ]));
341 outcomes.add_row(Row::new(vec![
342 Cell::new("Expired Trades"),
343 Cell::new(&stats.expired_trades().to_string()),
344 Cell::new(&percentage(stats.expired_trades(), total)),
345 ]));
346 }
347
348 let mut pnl = titled_table(&["Metric", "Amount"]);
349 pnl.add_row(Row::new(vec![
350 Cell::new("Total P&L"),
351 Cell::new(&money(stats.total_pnl()?)),
352 ]));
353 if total > 0 {
354 pnl.add_row(Row::new(vec![
355 Cell::new("Average P&L per Trade"),
356 Cell::new(&money(statistics.average_pnl)),
357 ]));
358 }
359 pnl.add_row(Row::new(vec![
360 Cell::new("Maximum Profit"),
361 Cell::new(&money(statistics.best_pnl)).with_style(Attr::ForegroundColor(color::GREEN)),
362 ]));
363 pnl.add_row(Row::new(vec![
364 Cell::new("Maximum Loss"),
365 Cell::new(&money(statistics.worst_pnl)).with_style(Attr::ForegroundColor(color::RED)),
366 ]));
367
368 let reasons = stats
369 .exit_reasons()
370 .iter()
371 .map(|(reason, count)| (reason.to_string(), *count))
372 .collect();
373
374 Ok(vec![
375 Section::titled(SUMMARY_BANNER, run_count_table(total)),
376 Section::titled("--- Trade Outcomes ---", outcomes),
377 Section::titled("--- Profit/Loss Statistics ---", pnl),
378 Section::titled(
379 "--- Holding Period ---",
380 holding_period_table(two_decimals(statistics.average_holding_period)),
381 ),
382 Section::titled("--- Exit Reasons ---", exit_reasons_table(reasons, total)),
383 ])
384}
385
386#[must_use]
388fn individual_results_sections(
389 results: &[SimulationResult],
390 pnl_of: fn(&SimulationResult) -> Decimal,
391) -> [Section; 1] {
392 let mut table = titled_table(&INDIVIDUAL_RESULTS_HEADERS);
393 for result in results {
394 table.add_row(Row::new(vec![
395 Cell::new(&result.simulation_count.to_string()),
396 Cell::new(&money(result.max_premium)),
397 Cell::new(&money(result.min_premium)),
398 Cell::new(&money(result.avg_premium)),
399 pnl_cell(pnl_of(result)),
400 Cell::new(&result.holding_period.to_string()),
401 Cell::new(&result.exit_reason.to_string()),
402 ]));
403 }
404 [Section::titled(INDIVIDUAL_RESULTS_BANNER, table)]
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use optionstratlib_simulation::simulation::ExitPolicy;
411 use rust_decimal_macros::dec;
412
413 fn result(
414 sim: usize,
415 pnl: Decimal,
416 holding_period: usize,
417 expired: bool,
418 exit_reason: ExitPolicy,
419 ) -> SimulationResult {
420 let mut result = SimulationResult {
421 simulation_count: sim,
422 max_premium: dec!(100.0),
423 min_premium: dec!(50.0),
424 avg_premium: dec!(75.0),
425 hit_take_profit: pnl > Decimal::ZERO && !expired,
426 hit_stop_loss: pnl < Decimal::ZERO && !expired,
427 expired,
428 expiration_premium: if expired { Some(dec!(50.0)) } else { None },
429 holding_period,
430 exit_reason,
431 ..SimulationResult::default()
432 };
433 result.pnl.realized = Some(pnl);
434 result
435 }
436
437 fn results() -> Vec<SimulationResult> {
438 vec![
439 result(
440 1,
441 dec!(100.0),
442 10,
443 false,
444 ExitPolicy::ProfitPercent(dec!(0.5)),
445 ),
446 result(
447 2,
448 dec!(-50.0),
449 15,
450 false,
451 ExitPolicy::LossPercent(dec!(1.0)),
452 ),
453 result(3, dec!(25.0), 20, true, ExitPolicy::Expiration),
454 ]
455 }
456
457 fn stats_result() -> SimulationStatsResult {
458 SimulationStatsResult {
459 results: results(),
460 total_simulations: 3,
461 profitable_count: 2,
462 loss_count: 1,
463 average_pnl: dec!(25.0),
464 median_pnl: dec!(25.0),
465 std_dev_pnl: dec!(61.24),
466 best_pnl: dec!(100.0),
467 worst_pnl: dec!(-50.0),
468 win_rate: dec!(66.67),
469 average_holding_period: dec!(15.0),
470 }
471 }
472
473 fn stats() -> SimulationStats {
474 let mut stats = SimulationStats::new();
475 for result in results() {
476 stats.update(result).unwrap();
477 }
478 stats
479 }
480
481 #[test]
482 fn test_terminal_backtest_summary_matches_snapshot() {
483 assert_eq!(
484 stats_result().render_summary().unwrap(),
485 include_str!("snapshots/backtest_summary.txt")
486 );
487 }
488
489 #[test]
490 fn test_terminal_backtest_individual_results_match_snapshot() {
491 assert_eq!(
492 stats_result().render_individual_results(),
493 include_str!("snapshots/backtest_individual_results.txt")
494 );
495 }
496
497 #[test]
498 fn test_terminal_simulation_summary_matches_snapshot() {
499 assert_eq!(
500 stats().render_summary().unwrap(),
501 include_str!("snapshots/simulation_summary.txt")
502 );
503 }
504
505 #[test]
506 fn test_terminal_simulation_individual_results_match_snapshot() {
507 assert_eq!(
508 stats().render_individual_results(),
509 include_str!("snapshots/simulation_individual_results.txt")
510 );
511 }
512
513 #[test]
516 fn test_terminal_backtest_empty_summary_keeps_its_percentages() {
517 let empty = SimulationStatsResult::default();
518 let rendered = empty.render_summary().unwrap();
519 assert!(rendered.contains("NaN%"));
520 assert!(!rendered.contains('\u{1b}'));
521 }
522
523 #[test]
524 fn test_terminal_simulation_empty_summary_omits_outcome_rows() {
525 let rendered = SimulationStats::new().render_summary().unwrap();
526 assert!(!rendered.contains("Profitable Closes"));
527 assert!(!rendered.contains("Average P&L per Trade"));
528 assert!(rendered.contains("Total Simulations"));
529 }
530
531 #[test]
532 fn test_terminal_backtest_summary_total_pnl_overflow_is_decimal_error() {
533 let mut stats = stats_result();
534 stats.results = vec![
535 result(1, Decimal::MAX, 1, false, ExitPolicy::Expiration),
536 result(2, Decimal::MAX, 1, false, ExitPolicy::Expiration),
537 ];
538 assert!(matches!(
539 stats.render_summary(),
540 Err(GraphError::Decimal(_))
541 ));
542 assert!(matches!(stats.print_summary(), Err(GraphError::Decimal(_))));
543 }
544
545 #[test]
546 fn test_terminal_backtest_exit_reasons_are_sorted() {
547 let rendered = stats_result().render_summary().unwrap();
548 let reasons = rendered.split("--- Exit Reasons ---").nth(1).unwrap();
549 let expiration = reasons.find("Hold to Expiration").unwrap();
550 let profit = reasons.find("Profit Target").unwrap();
551 let stop = reasons.find("Stop Loss").unwrap();
552 assert!(expiration < profit && profit < stop);
553 }
554
555 #[test]
559 fn test_terminal_individual_results_show_total_pnl() {
560 let mut run = result(1, dec!(10.0), 5, false, ExitPolicy::Expiration);
561 run.pnl.unrealized = Some(dec!(2.5));
562
563 let mut stats_result = stats_result();
564 stats_result.results = vec![run.clone()];
565 assert!(stats_result.render_individual_results().contains("$12.50"));
566
567 let mut stats = SimulationStats::new();
568 stats.update(run).unwrap();
569 let rendered = stats.render_individual_results();
570 assert!(rendered.contains("$12.50"));
571 assert!(!rendered.contains("$10.00"));
572 let summary = stats.render_summary().unwrap();
573 assert!(summary.contains("$12.50"));
574 assert!(!summary.contains("$10.00"));
575 }
576
577 #[test]
581 fn test_terminal_simulation_summary_reads_missing_pnl_as_zero() {
582 let mut run = result(1, dec!(10.0), 5, false, ExitPolicy::Expiration);
583 run.pnl.realized = None;
584 let mut stats = SimulationStats::new();
585 stats.update(run).unwrap();
586
587 let rendered = stats.render_summary().unwrap();
588 let maximum_profit = rendered
589 .lines()
590 .find(|line| line.contains("Maximum Profit"))
591 .unwrap();
592 let maximum_loss = rendered
593 .lines()
594 .find(|line| line.contains("Maximum Loss"))
595 .unwrap();
596 assert!(maximum_profit.contains("$0.00"), "{maximum_profit}");
597 assert!(maximum_loss.contains("$0.00"), "{maximum_loss}");
598 assert!(!rendered.contains(&Decimal::MAX.to_string()));
599 }
600
601 #[test]
604 fn test_terminal_simulation_summary_statistics_overflow_is_backtest_error() {
605 let mut stats = SimulationStats::new();
606 stats
607 .update(result(1, Decimal::MAX, 1, false, ExitPolicy::Expiration))
608 .unwrap();
609 stats
610 .update(result(2, Decimal::MAX, 1, false, ExitPolicy::Expiration))
611 .unwrap();
612 assert!(matches!(
613 stats.render_summary(),
614 Err(GraphError::Backtest(_))
615 ));
616 assert!(matches!(
617 stats.print_summary(),
618 Err(GraphError::Backtest(_))
619 ));
620 }
621
622 #[test]
627 fn test_terminal_simulation_summary_rounds_holding_period_half_up() {
628 let mut stats = SimulationStats::new();
629 for (sim, holding) in [50, 50, 50, 50, 50, 63].into_iter().enumerate() {
630 stats
631 .update(result(
632 sim,
633 dec!(1.0),
634 holding,
635 false,
636 ExitPolicy::Expiration,
637 ))
638 .unwrap();
639 }
640 let rendered = stats.render_summary().unwrap();
641 assert!(rendered.contains("52.17 steps"), "{rendered}");
642 }
643
644 #[test]
645 fn test_terminal_reports_print_without_error() {
646 assert!(stats_result().print_summary().is_ok());
647 assert!(stats_result().print_individual_results().is_ok());
648 assert!(stats().print_summary().is_ok());
649 assert!(stats().print_individual_results().is_ok());
650 }
651
652 #[test]
653 fn test_terminal_count_by_text_groups_sorted() {
654 let counts = count_by_text(["b", "a", "b", "c", "b"].into_iter().map(String::from));
655 assert_eq!(
656 counts,
657 vec![
658 ("a".to_string(), 1),
659 ("b".to_string(), 3),
660 ("c".to_string(), 1)
661 ]
662 );
663 assert!(count_by_text(std::iter::empty()).is_empty());
664 }
665
666 #[test]
669 fn test_terminal_money_truncates_to_two_decimals() {
670 assert_eq!(money(dec!(1.239)), "$1.23");
671 assert_eq!(money(dec!(-50.0)), "$-50.00");
672 }
673
674 #[test]
675 fn test_terminal_percentage_matches_the_f64_expression() {
676 assert_eq!(percentage(1, 3), "33.33%");
677 assert_eq!(percentage(2, 3), "66.67%");
678 assert_eq!(percentage(0, 0), "NaN%");
679 }
680
681 #[test]
684 fn test_terminal_two_decimals_rounds_half_up() {
685 assert_eq!(two_decimals(dec!(52.1666)), "52.17");
686 assert_eq!(two_decimals(dec!(52.164)), "52.16");
687 assert_eq!(two_decimals(dec!(2.125)), "2.13");
688 assert_eq!(two_decimals(dec!(2.135)), "2.14");
689 assert_eq!(two_decimals(dec!(15)), "15.00");
690 assert_eq!(two_decimals(Decimal::ZERO), "0.00");
691 }
692}