Skip to main content

optionstratlib_visualization/visualization/terminal/
backtest.rs

1//! Simulation statistics as terminal tables.
2
3use 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
13/// Banner above the summary tables.
14const SUMMARY_BANNER: &str = "========== SIMULATION SUMMARY ==========";
15/// Banner above the per-run table.
16const INDIVIDUAL_RESULTS_BANNER: &str = "========== INDIVIDUAL SIMULATION RESULTS ==========";
17/// Headers of the per-run table.
18const 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];
27/// Decimal places of a rounded figure.
28const DISPLAY_DECIMALS: u32 = 2;
29/// A count is shown as this many percent of the runs.
30const PERCENT: f64 = 100.0;
31
32/// Terminal tables for simulation statistics.
33///
34/// Implemented for
35/// [`SimulationStatsResult`](optionstratlib_backtest::backtesting::SimulationStatsResult),
36/// the statistics a `Simulate::simulate` run returns, and for
37/// [`SimulationStats`](optionstratlib_backtest::backtesting::SimulationStats),
38/// the running accumulator. The summary shows the run count, the trade
39/// outcomes, the P&L figures, the average holding period and the exit
40/// reasons; the individual results show one row per run. Money is written
41/// with a `$` sign and `Decimal`'s `{:.2}`, which truncates to two decimal
42/// places (as the tables always have). The average holding period of a
43/// [`SimulationStats`] summary is rounded half-up to two decimal places
44/// (#691); a [`SimulationStatsResult`] summary writes its own with `{:.2}`.
45/// Exit reasons are listed in the order of their text, so the output is
46/// deterministic.
47///
48/// In colour, headers are blue, and:
49///
50/// - in the [`SimulationStatsResult`] summary, every P&L figure but the
51///   standard deviation is green when positive and red when negative;
52/// - in the [`SimulationStats`] summary, the profitable-close count and
53///   share and the maximum profit are always green, the loss-close count and
54///   share and the maximum loss always red, and the total and average P&L
55///   uncoloured;
56/// - in the individual results of both, the final P&L is green when
57///   positive and red when negative.
58///
59/// The P&L of a run is its total P&L (realized plus unrealized,
60/// `PnL::total_pnl`) for both types, the figure each accumulates (#691).
61/// The [`SimulationStats`] summary takes its average P&L, extremes and
62/// average holding period from `SimulationStats::statistics`, so a run
63/// without a P&L counts as zero there, as in the [`SimulationStatsResult`]
64/// summary.
65///
66/// This replaces the inherent `print_summary` and `print_individual_results`
67/// methods those types had: import the trait and call the same names, now
68/// returning a `Result`.
69pub trait SimulationReport {
70    /// The summary tables as plain text, without ANSI escapes.
71    ///
72    /// # Errors
73    ///
74    /// Returns [`GraphError::Decimal`] when a derived figure (the total or
75    /// the average P&L) leaves the representable `Decimal` range, and
76    /// [`GraphError::Backtest`] when the statistics of a [`SimulationStats`]
77    /// accumulator cannot be computed (its P&L total or variance leaves the
78    /// `Decimal` range).
79    fn render_summary(&self) -> Result<String, GraphError>;
80
81    /// Writes the summary tables to stdout, coloured when stdout is a
82    /// terminal.
83    ///
84    /// # Errors
85    ///
86    /// Returns the errors of [`Self::render_summary`], and
87    /// [`GraphError::Io`] when stdout cannot be written.
88    fn print_summary(&self) -> Result<(), GraphError>;
89
90    /// The per-run table as plain text, without ANSI escapes.
91    #[must_use]
92    fn render_individual_results(&self) -> String;
93
94    /// Writes the per-run table to stdout, coloured when stdout is a
95    /// terminal.
96    ///
97    /// # Errors
98    ///
99    /// Returns [`GraphError::Io`] when stdout cannot be written.
100    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/// The P&L both report types show for a run: realized plus unrealized,
140/// zero when neither is known.
141#[must_use]
142#[inline]
143fn total_pnl_of(result: &SimulationResult) -> Decimal {
144    result.pnl.total_pnl().unwrap_or_default()
145}
146
147/// `count` as a percentage of `total`, with two decimal places.
148///
149/// The same `f64` expression the tables have always printed, `NaN%` for
150/// zero runs included.
151#[must_use]
152fn percentage(count: usize, total: usize) -> String {
153    format!("{:.2}%", (count as f64 / total as f64) * PERCENT)
154}
155
156/// `value` with two decimal places, rounded half-up (#691 decision 2).
157///
158/// `Decimal`'s own `{:.2}` truncates, so the value is rounded first: a tie
159/// goes away from zero, which for the non-negative holding periods this
160/// formats is up.
161#[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/// A dollar amount with two decimal places, or `Unlimited` / `-Unlimited`
170/// for the `Decimal::MAX` / `Decimal::MIN` sentinels.
171#[must_use]
172#[inline]
173fn money(value: Decimal) -> String {
174    format!("{:.2}", DisplayMoney(value))
175}
176
177/// A dollar amount, green when positive and red when negative.
178#[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/// A table whose header row is `headers`, in blue.
191#[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/// The run-count table.
199#[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/// The average-holding-period table, `period` in simulation steps.
210#[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/// How many times each distinct text occurs, in the order of the text.
221///
222/// Counts are group lengths of the sorted texts, so no counter is
223/// incremented.
224#[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/// The exit-reason table, one row per reason in the order of its text.
239#[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
253/// The summary of a [`SimulationStatsResult`].
254fn 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
316/// The summary of a [`SimulationStats`] accumulator.
317///
318/// Every P&L and holding-period figure comes from
319/// `SimulationStats::statistics` (#691 decision 2) and the total from
320/// `SimulationStats::total_pnl`, both over the same outcomes.
321fn 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/// One row per run of `results`, with `pnl_of` as each run's final P&L.
387#[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    /// A summary of zero runs prints what it always printed, `NaN%`
514    /// included: the percentages are the same `f64` expression as before.
515    #[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    /// Both types show the total P&L of a run. #691 decision 1 re-baselines
556    /// the `SimulationStats` side, which showed the realized leg alone
557    /// (`$10.00`) and now shows the total, as it accumulates it.
558    #[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    /// #691 decision 2: the extremes come from `PathStatistics`, which reads a
578    /// run without a P&L as zero. The running extremes used to skip it and
579    /// print the `Decimal::MIN` / `Decimal::MAX` sentinels.
580    #[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    /// An accumulator whose statistics cannot be computed is reported, not
602    /// printed with made-up figures.
603    #[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    /// The average holding period of a `SimulationStats` summary is the
623    /// `PathStatistics` mean rounded half-up to two places: 157 steps over 3
624    /// runs is 52.333…, 313 over 6 is 52.1666… and prints 52.17, where
625    /// `Decimal`'s own `{:.2}` would truncate it to 52.16.
626    #[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    /// Money keeps `Decimal`'s `{:.2}`, which truncates, as the tables
667    /// always printed it.
668    #[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    /// `two_decimals` rounds half-up, ties included, instead of truncating
682    /// or rounding half-to-even.
683    #[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}