Skip to main content

optionstratlib_visualization/visualization/
model_impls.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 19/9/25
5******************************************************************************/
6
7//! [`Graph`] implementations for the core model types.
8//!
9//! An `Options` contract plots its payoff at expiry across an optimal price
10//! range around the strike, split into a positive and a negative series; a
11//! `Position` delegates to its underlying contract. The implementations live
12//! in the visualization layer (local trait, core-owned type) so the core
13//! model never references chart types.
14
15use 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
24/// Most prices the single-contract payoff chart evaluates.
25///
26/// Up to this many, the chart steps one unit of the underlying at a time
27/// across the range, as it always has. A wider range (a strike in the
28/// millions or beyond, whose range is millions of units wide) is sampled at
29/// this many evenly spaced prices instead, so the chart stays bounded rather
30/// than looping once per unit (#797). Ten thousand points is several times
31/// the 1600-pixel width the chart is drawn at.
32const MAX_OPTION_CHART_POINTS: u64 = 10_000;
33
34/// The prices the payoff chart evaluates over `[start, end)`, in units of
35/// the underlying: every integer while there are at most
36/// [`MAX_OPTION_CHART_POINTS`] of them, otherwise that many evenly spaced
37/// prices from `start`. An empty or inverted range has none.
38fn 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                // Fallback to a reasonable default range based on strike price
66                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                    // A strike this close to `Positive::MAX` has no range
71                    // around it to chart.
72                    _ => 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            // Every price in the range is non-negative; the `else` is never
100            // taken.
101            let Ok(price) = Positive::new_decimal(x) else {
102                continue;
103            };
104            // A price whose payoff cannot be computed is left out of the
105            // chart rather than drawn at zero.
106            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
148/// Implementation of the `Graph` trait for the `Position` struct, enabling graphical representation
149/// of financial options positions.
150///
151/// This implementation provides methods to visualize the profit/loss (PnL) profile of an options position
152/// across different price levels of the underlying asset. It handles the generation of appropriate title,
153/// data values for plotting, and special chart elements like break-even points.
154///
155/// The visualization capabilities allow traders to analyze the potential outcomes of their options positions
156/// at expiration across various price scenarios.
157impl 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    /// A strike of 1e15 used to loop once per unit across a range about
209    /// 6e14 units wide (#797). It now returns a bounded series.
210    #[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        // A zero payoff is drawn in both series, so the bound is twice the cap.
237        assert!(points <= 2 * MAX_OPTION_CHART_POINTS as usize);
238    }
239}