Skip to main content

optionstratlib_visualization/visualization/
simulation.rs

1//! `Graph` implementations for the simulation containers.
2//!
3//! A [`optionstratlib_simulation::simulation::randomwalk::RandomWalk`] plots as one price series against days to expiry
4//! (negated so time runs left to right); a [`optionstratlib_simulation::simulation::simulator::Simulator`] plots every walk
5//! it holds as one series each. The implementations live here because
6//! `Graph` is a visualization trait and simulation must not depend on
7//! visualization (ADR-0001 D2, simulation row).
8
9use crate::visualization::{ColorScheme, Graph, GraphConfig, GraphData, Series2D, TraceMode};
10use optionstratlib_core::model::Positive;
11use optionstratlib_simulation::simulation::randomwalk::RandomWalk;
12use optionstratlib_simulation::simulation::simulator::Simulator;
13use rust_decimal::Decimal;
14use std::fmt::Display;
15use std::ops::AddAssign;
16
17/// Chart width in pixels shared by the simulation charts.
18const SIMULATION_CHART_WIDTH: u32 = 1600;
19/// Chart height in pixels shared by the simulation charts.
20const SIMULATION_CHART_HEIGHT: u32 = 900;
21/// Line width shared by the simulation charts.
22const SIMULATION_LINE_WIDTH: f64 = 2.0;
23/// Line colour of a single random walk.
24const RANDOM_WALK_LINE_COLOR: &str = "#1f77b4";
25
26impl<X, Y> Graph for RandomWalk<X, Y>
27where
28    X: Copy + TryInto<Positive> + AddAssign + Display,
29    Y: TryInto<Positive> + Display + Clone,
30{
31    fn graph_data(&self) -> GraphData {
32        let steps = self.get_steps();
33        let y: Vec<Decimal> = steps
34            .iter()
35            .map(|step| step.get_graph_y_value().unwrap_or(Positive::ZERO).to_dec())
36            .collect();
37        let x: Vec<Decimal> = steps
38            .iter()
39            .map(|step| -step.get_graph_x_in_days_left().to_dec())
40            .collect();
41
42        GraphData::Series(Series2D {
43            x,
44            y,
45            name: self.get_title().to_string(),
46            mode: TraceMode::Lines,
47            line_color: Some(RANDOM_WALK_LINE_COLOR.to_string()),
48            line_width: Some(SIMULATION_LINE_WIDTH),
49        })
50    }
51
52    fn graph_config(&self) -> GraphConfig {
53        GraphConfig {
54            title: self.get_title().to_string(),
55            x_label: Some("Date".to_string()),
56            y_label: Some("Price".to_string()),
57            z_label: None,
58            width: SIMULATION_CHART_WIDTH,
59            height: SIMULATION_CHART_HEIGHT,
60            show_legend: false,
61            color_scheme: ColorScheme::Default,
62            line_style: Default::default(),
63            legend: None,
64        }
65    }
66}
67
68impl<X, Y> Graph for Simulator<X, Y>
69where
70    X: Copy + TryInto<Positive> + AddAssign + Display,
71    Y: TryInto<Positive> + Display + Clone,
72{
73    fn graph_data(&self) -> GraphData {
74        let random_walks = self.get_steps();
75        let mut series: Vec<Series2D> = Vec::with_capacity(random_walks.len());
76        for (i, steps) in random_walks.iter().enumerate() {
77            let y: Vec<Decimal> = steps
78                .iter()
79                .map(|step| step.get_graph_y_value().unwrap_or(Positive::ZERO).to_dec())
80                .collect();
81            let x: Vec<Decimal> = steps
82                .iter()
83                .map(|step| -step.get_graph_x_in_days_left().to_dec())
84                .collect();
85            series.push(Series2D {
86                x,
87                y,
88                name: format!("Sim_{i}"),
89                mode: TraceMode::Lines,
90                line_color: None,
91                line_width: Some(SIMULATION_LINE_WIDTH),
92            });
93        }
94        GraphData::MultiSeries(series)
95    }
96
97    fn graph_config(&self) -> GraphConfig {
98        GraphConfig {
99            title: self.get_title().to_string(),
100            x_label: Some("Date".to_string()),
101            y_label: Some("Price".to_string()),
102            z_label: None,
103            width: SIMULATION_CHART_WIDTH,
104            height: SIMULATION_CHART_HEIGHT,
105            show_legend: true,
106            color_scheme: ColorScheme::HighContrast,
107            line_style: Default::default(),
108            legend: None,
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use optionstratlib_core::model::ExpirationDate;
117    use optionstratlib_core::pos_or_panic;
118    use optionstratlib_core::utils::TimeFrame;
119    use optionstratlib_simulation::simulation::steps::Step;
120    use optionstratlib_simulation::simulation::{
121        WalkParams, WalkType, WalkTypeAble, generator_positive,
122    };
123    use rust_decimal_macros::dec;
124
125    #[derive(Clone)]
126    struct TestWalker;
127    impl WalkTypeAble<Positive, Positive> for TestWalker {}
128
129    fn params() -> WalkParams<Positive, Positive> {
130        WalkParams {
131            size: 3,
132            init_step: Step::new(
133                Positive::ONE,
134                TimeFrame::Day,
135                ExpirationDate::Days(pos_or_panic!(30.0)),
136                Positive::HUNDRED,
137            ),
138            walker: Box::new(TestWalker),
139            walk_type: WalkType::Historical {
140                timeframe: TimeFrame::Day,
141                prices: vec![
142                    Positive::HUNDRED,
143                    pos_or_panic!(101.0),
144                    pos_or_panic!(102.0),
145                ],
146                symbol: None,
147            },
148            seed: None,
149        }
150    }
151
152    fn simulator(size: usize) -> Simulator<Positive, Positive> {
153        Simulator::new(
154            "Graph Test".to_string(),
155            size,
156            &params(),
157            generator_positive,
158        )
159        .unwrap()
160    }
161
162    #[test]
163    fn test_random_walk_graph_data_is_one_series_of_prices() {
164        let sim = simulator(1);
165        let walk = sim.first().unwrap();
166        match walk.graph_data() {
167            GraphData::Series(series) => {
168                assert_eq!(series.name, "Graph Test_0");
169                assert_eq!(series.y, vec![dec!(100), dec!(101), dec!(102)]);
170                assert_eq!(series.x.len(), 3);
171                assert!(series.x.windows(2).all(|w| w[0] <= w[1]));
172                assert_eq!(series.line_color.as_deref(), Some(RANDOM_WALK_LINE_COLOR));
173            }
174            other => panic!("expected a single series, got {other:?}"),
175        }
176        let config = walk.graph_config();
177        assert_eq!(config.title, "Graph Test_0");
178        assert!(!config.show_legend);
179        assert_eq!(config.width, SIMULATION_CHART_WIDTH);
180    }
181
182    #[test]
183    fn test_simulator_graph_data_has_one_series_per_walk() {
184        let sim = simulator(3);
185        match sim.graph_data() {
186            GraphData::MultiSeries(series) => {
187                assert_eq!(series.len(), 3);
188                let names: Vec<&str> = series.iter().map(|s| s.name.as_str()).collect();
189                assert_eq!(names, vec!["Sim_0", "Sim_1", "Sim_2"]);
190                assert!(series.iter().all(|s| s.y.len() == 3));
191            }
192            other => panic!("expected multiple series, got {other:?}"),
193        }
194        let config = sim.graph_config();
195        assert_eq!(config.title, "Graph Test");
196        assert!(config.show_legend);
197        assert_eq!(config.height, SIMULATION_CHART_HEIGHT);
198    }
199
200    #[cfg(feature = "plotly")]
201    #[test]
202    fn test_simulator_write_html_round_trip() -> Result<(), Box<dyn std::error::Error>> {
203        use std::{fs, path::Path};
204
205        let sim = simulator(2);
206        let file_name = "Draws/Simulation/test_simulator.html".as_ref();
207        sim.write_html(file_name)?;
208        if Path::new(file_name).exists() {
209            fs::remove_file(file_name)?;
210        }
211        Ok(())
212    }
213}