optionstratlib_visualization/visualization/
model_impls.rs1use crate::visualization::{
16 ColorScheme, Graph, GraphConfig, GraphData, LineStyle, Series2D, TraceMode,
17};
18use optionstratlib_core::model::Positive;
19use optionstratlib_core::model::{Options, Position};
20use optionstratlib_market::chains::utils::calculate_optimal_price_range;
21use optionstratlib_strategies::strategies::base::BasicAble;
22use rust_decimal::Decimal;
23
24const MAX_OPTION_CHART_POINTS: u64 = 10_000;
33
34fn chart_prices(start: u64, end: u64) -> Vec<Decimal> {
39 let Some(width) = end.checked_sub(start) else {
40 return Vec::new();
41 };
42 if width <= MAX_OPTION_CHART_POINTS {
43 return (start..end).map(Decimal::from).collect();
44 }
45 let first = Decimal::from(start);
46 let Some(step) = Decimal::from(width).checked_div(Decimal::from(MAX_OPTION_CHART_POINTS))
47 else {
48 return Vec::new();
49 };
50 (0..MAX_OPTION_CHART_POINTS)
51 .filter_map(|index| first.checked_add(step.checked_mul(Decimal::from(index))?))
52 .collect()
53}
54
55impl Graph for Options {
56 fn graph_data(&self) -> GraphData {
57 let range = match calculate_optimal_price_range(
58 self.underlying_price,
59 self.strike_price,
60 self.implied_volatility,
61 self.expiration_date,
62 ) {
63 Ok(range) => range,
64 Err(_) => {
65 let lower = self.strike_price.checked_mul_dec(Decimal::new(5, 1));
67 let upper = self.strike_price.checked_mul_dec(Decimal::new(15, 1));
68 match (lower, upper) {
69 (Ok(lower), Ok(upper)) => (lower, upper),
70 _ => return GraphData::Series(Series2D::default()),
73 }
74 }
75 };
76
77 let mut positive_series = Series2D {
78 x: vec![],
79 y: vec![],
80 name: "Positive Payoff".to_string(),
81 mode: TraceMode::Lines,
82 line_color: Some("#2ca02c".to_string()),
83 line_width: Some(2.0),
84 };
85 let mut negative_series = Series2D {
86 x: vec![],
87 y: vec![],
88 name: "Negative Payoff".to_string(),
89 mode: TraceMode::Lines,
90 line_color: Some("#FF0000".to_string()),
91 line_width: Some(2.0),
92 };
93
94 let (range_start, range_end) = (
95 range.0.to_u64_checked().unwrap_or_default(),
96 range.1.to_u64_checked().unwrap_or_default(),
97 );
98 for x in chart_prices(range_start, range_end) {
99 let Ok(price) = Positive::new_decimal(x) else {
102 continue;
103 };
104 let Ok(profit) = self.payoff_at_price(&price) else {
107 continue;
108 };
109 match profit {
110 p if p == Decimal::ZERO => {
111 positive_series.x.push(x);
112 positive_series.y.push(profit);
113 negative_series.x.push(x);
114 negative_series.y.push(profit);
115 }
116 p if p > Decimal::ZERO => {
117 positive_series.x.push(x);
118 positive_series.y.push(profit);
119 }
120 _ => {
121 negative_series.x.push(x);
122 negative_series.y.push(profit);
123 }
124 }
125 }
126 let multi_series_2d = vec![positive_series, negative_series];
127 GraphData::MultiSeries(multi_series_2d)
128 }
129
130 fn graph_config(&self) -> GraphConfig {
131 let title = self.get_title();
132 let legend = Some(vec![title.clone()]);
133 GraphConfig {
134 title,
135 width: 1600,
136 height: 900,
137 x_label: Some("Underlying Price".to_string()),
138 y_label: Some("Profit/Loss".to_string()),
139 z_label: None,
140 line_style: LineStyle::Solid,
141 color_scheme: ColorScheme::Default,
142 legend,
143 show_legend: false,
144 }
145 }
146}
147
148impl Graph for Position {
158 fn graph_data(&self) -> GraphData {
159 self.option.graph_data()
160 }
161
162 fn graph_config(&self) -> GraphConfig {
163 self.option.graph_config()
164 }
165}
166
167#[cfg(test)]
168mod tests_option_chart_sampling {
169 use super::*;
170 use optionstratlib_core::model::ExpirationDate;
171 use optionstratlib_core::model::types::{OptionStyle, OptionType, Side};
172 use rust_decimal_macros::dec;
173
174 #[test]
175 fn test_chart_prices_steps_one_unit_up_to_the_cap() {
176 let prices = chart_prices(70, 130);
177 assert_eq!(prices, (70..130).map(Decimal::from).collect::<Vec<_>>());
178
179 let at_cap = chart_prices(0, MAX_OPTION_CHART_POINTS);
180 assert_eq!(at_cap.len(), MAX_OPTION_CHART_POINTS as usize);
181 assert_eq!(
182 at_cap.last(),
183 Some(&Decimal::from(MAX_OPTION_CHART_POINTS - 1))
184 );
185 }
186
187 #[test]
188 fn test_chart_prices_samples_a_wide_range_evenly() {
189 let start = 700_000_000_000_000_u64;
190 let end = 1_300_000_000_000_000_u64;
191 let prices = chart_prices(start, end);
192 assert_eq!(prices.len(), MAX_OPTION_CHART_POINTS as usize);
193 assert_eq!(prices.first(), Some(&Decimal::from(start)));
194 assert!(
195 prices
196 .windows(2)
197 .all(|pair| matches!(pair, [a, b] if a < b))
198 );
199 assert!(prices.iter().all(|price| *price < Decimal::from(end)));
200 }
201
202 #[test]
203 fn test_chart_prices_empty_or_inverted_range_has_none() {
204 assert!(chart_prices(10, 10).is_empty());
205 assert!(chart_prices(10, 5).is_empty());
206 }
207
208 #[test]
211 fn test_option_graph_data_with_a_huge_strike_is_bounded() {
212 let huge = Positive::new_decimal(dec!(1000000000000000)).unwrap();
213 let option = Options::new(
214 OptionType::European,
215 Side::Long,
216 "HUGE".to_string(),
217 huge,
218 ExpirationDate::Days(Positive::new(30.0).unwrap()),
219 Positive::new(0.2).unwrap(),
220 Positive::ONE,
221 huge,
222 dec!(0.05),
223 OptionStyle::Call,
224 Positive::ZERO,
225 None,
226 );
227
228 let started = std::time::Instant::now();
229 let GraphData::MultiSeries(series) = option.graph_data() else {
230 panic!("expected the positive and negative payoff series");
231 };
232 assert!(started.elapsed() < std::time::Duration::from_secs(30));
233
234 let points: usize = series.iter().map(|s| s.x.len()).sum();
235 assert!(points > 0);
236 assert!(points <= 2 * MAX_OPTION_CHART_POINTS as usize);
238 }
239}