Skip to main content

optionstratlib_visualization/visualization/
strategies.rs

1//! `Graph` implementations for the strategies.
2//!
3//! Every payoff strategy is charted the same way: profit/loss over the
4//! strategy's preferred price range, split into positive and negative
5//! segments, with a zero line, a marker at the current underlying price
6//! and labels at the break-even points. `impl_graph_for_payoff_strategy!`
7//! generates that implementation; the invocation at the end of this file
8//! covers every concrete strategy. The implementations live here because
9//! `Graph` is a visualization trait and strategies must not depend on
10//! visualization (ADR-0001 D2, strategies row).
11
12use optionstratlib_strategies::strategies::{
13    BearCallSpread, BearPutSpread, BullCallLadder, BullCallSpread, BullPutSpread, IronButterfly,
14    IronCondor, LongButterflySpread, LongCall, LongPut, LongStraddle, LongStrangle,
15    PoorMansCoveredCall, ShortButterflySpread, ShortCall, ShortPut, ShortStraddle, ShortStrangle,
16};
17
18/// Macro `impl_graph_for_payoff_strategy` generates implementations of the `Graph` trait
19/// for one or more given types. This is specifically designed for types that represent
20/// payoff strategies, enabling them to produce graph data and configurations for financial
21/// visualizations, such as profit/loss graphs based on an underlying price range.
22///
23/// # Usage
24///
25/// Use the macro to implement the `Graph` trait for multiple types. For example:
26/// This will generate implementations of the `Graph` trait for `TypeA` and `TypeB`.
27///
28/// # Generated Trait Implementation
29///
30/// ## `graph_data`
31///
32/// - Generates `GraphData` for the associated type by calculating the profit/loss data for
33///   a price range obtained from the `get_best_range_to_show` method.
34/// - The result is displayed as two series:
35///   1. A "Positive Payoff" series (green) for profits.
36///   2. A "Negative Payoff" series (red) for losses.
37/// - Handles profits, losses, and break-even points separately:
38///   - Break-even points (`0`) are shared between positive and negative series.
39///   - Prices where profit is positive are included in the positive series only.
40///   - Prices where profit is negative are included in the negative series only.
41/// - If `get_best_range_to_show` fails, a default empty `GraphData::Series` is returned.
42///
43/// ## `graph_config`
44///
45/// - Produces a `GraphConfig` structure for styling and configuring the graph:
46///   - Title: Based on the strategy's title via `get_title` method.
47///   - Width and height: Predefined dimensions (1600x900).
48///   - Labels: X-axis represents "Underlying Price", and Y-axis represents "Profit/Loss".
49///   - Other visual configuration properties include:
50///     - `line_style`: Set to `LineStyle::Solid`.
51///     - `color_scheme`: Set to default.
52///     - `legend`: Title is added to the legend, but `show_legend` is disabled by default.
53///
54/// # Parameters
55///
56/// - `$t`: One or more types for which the `Graph` trait should be implemented.
57///
58///
59/// This allows the `MyStrategy` type to generate graph data and configurations for visual representation
60/// of its payoff strategy.
61///
62/// # Notes
63///
64/// - The macro assumes that the types implemented provide specific methods (`get_best_range_to_show`,
65///   `calculate_profit_at`, and `get_title`) necessary for the `Graph` trait.
66/// - Predefined values like colors, line widths, and dimensions can be adjusted within the macro if needed.
67/// - The expansion reaches every item it names through `$crate` paths, so the
68///   calling module needs no imports beyond the types it lists.
69///
70#[macro_export]
71macro_rules! impl_graph_for_payoff_strategy {
72    ($($t:ty),*) => {
73        $(
74            impl $crate::visualization::Graph for $t {
75                fn graph_data(&self) -> $crate::visualization::GraphData {
76                    use $crate::__private::{
77                        BasicAble, BreakEvenable, Decimal, Positive, Profit, Strategies,
78                    };
79                    use $crate::visualization::{
80                        GraphData, Label2D, Series2D, TraceMode, VisPoint2D,
81                    };
82
83                    let break_even_points = match self.get_break_even_points() {
84                        Ok(points) => points,
85                        Err(_) => return GraphData::Series(Series2D::default()),
86                    };
87                    let underlying_price = match self.get_underlying_price() {
88                        Ok(price) => price,
89                        Err(_) => return GraphData::Series(Series2D::default()),
90                    };
91                    // The marker and label at the current price are left out when the
92                    // payoff there cannot be computed, rather than drawn at zero.
93                    let pay_off_at_underlying_price = self.calculate_profit_at(&underlying_price).ok();
94                    let range = match self.get_best_range_to_show(Positive::ONE){
95                        Ok(range) => range,
96                        Err(_) => return GraphData::Series(Series2D::default()),
97                    };
98
99                    // Create a vector to store break-even point labels
100                    let mut break_even_labels = Vec::new();
101
102                    // Create a single continuous series for the profit/loss chart
103                    let mut profit_series = Series2D {
104                        x: vec![],
105                        y: vec![],
106                        name: "Profit/Loss".to_string(),
107                        mode: TraceMode::Lines,
108                        line_color: Some("#2ca02c".to_string()),
109                        line_width: Some(2.0),
110                    };
111
112                    // Add a zero line for reference
113                    let mut zero_line = Series2D {
114                        x: vec![],
115                        y: vec![],
116                        name: "Break Even".to_string(),
117                        mode: TraceMode::Lines,
118                        line_color: Some("#000000".to_string()),
119                        line_width: Some(1.0),
120                    };
121
122                    // Add a vertical line at the current underlying price
123                    let mut current_price_line = Series2D {
124                        x: vec![],
125                        y: vec![],
126                        name: "Current Price".to_string(),
127                        mode: TraceMode::Lines,
128                        line_color: Some("#0000FF".to_string()), // Blue color
129                        line_width: Some(1.0),
130                    };
131
132                    let mut marker = pay_off_at_underlying_price.map(|pay_off_at_underlying_price| {
133                        // Add a point at current price with its payoff value
134                        // Color depends on payoff value: green (positive), blue (zero), red (negative)
135                        let point_color = if pay_off_at_underlying_price > Decimal::ZERO {
136                            "#2ca02c".to_string() // Green for positive values
137                        } else if pay_off_at_underlying_price < Decimal::ZERO {
138                            "#FF0000".to_string() // Red for negative values
139                        } else {
140                            "#0000FF".to_string() // Blue for zero
141                        };
142
143                        // Format the payoff value with 2 decimals for the label
144                        let formatted_payoff = format!("{:.2}", pay_off_at_underlying_price);
145
146                        // Create a point to mark the current position with a larger size
147                        // and use the appropriate color based on the payoff value
148                        let current_point = Series2D {
149                            x: vec![underlying_price.to_dec()],
150                            y: vec![pay_off_at_underlying_price],
151                            // The label will appear in the legend and when hovering over the point
152                            name: format!("Current P/L: {}", formatted_payoff),
153                            // Use LinesMarkers to display the point
154                            mode: TraceMode::Markers,
155                            line_color: Some(point_color.clone()),
156                            line_width: Some(10.0), // Larger point for better visibility
157                        };
158
159                        // We'll set the y_offset after calculating min_profit and max_profit
160                        // This will allow us to position the labels at the top or bottom of the graph
161                        let y_offset = Decimal::ZERO; // Initial value, will be updated later
162
163                        // Create a Point2D for the label position
164                        let label_point = VisPoint2D {
165                            x: underlying_price.to_dec(),
166                            y: y_offset,
167                            name: format!("P/L Label"),
168                            mode: TraceMode::TextLabels,
169                            color: Some(point_color),
170                            width: Some(0.0),
171                        };
172
173                        // Create a Label2D that combines the point and the text
174                        let label = Label2D {
175                            point: label_point,
176                            label: formatted_payoff,
177                        };
178
179                        // Convert the Label2D to a Series2D for compatibility with existing code
180                        let label_series = Series2D {
181                            x: vec![label.point.x],
182                            y: vec![label.point.y],
183                            name: label.label.clone(),
184                            mode: TraceMode::TextLabels,
185                            line_color: label.point.color,
186                            line_width: label.point.width,
187                        };
188                        (current_point, label_series)
189                    });
190
191                    // Get min and max values for reference lines
192                    if let (Some(first), Some(last)) = (range.first(), range.last()) {
193                        let min_price = first.to_dec();
194                        let max_price = last.to_dec();
195
196                        // Add zero line points (horizontal line at y=0)
197                        zero_line.x.push(min_price);
198                        zero_line.y.push(Decimal::ZERO);
199                        zero_line.x.push(max_price);
200                        zero_line.y.push(Decimal::ZERO);
201
202                        // Calculate min and max profit values to determine the vertical range
203                        let mut min_profit = Decimal::MAX;
204                        let mut max_profit = Decimal::MIN;
205
206                        for price in range.iter() {
207                            if let Ok(profit) = self.calculate_profit_at(price) {
208                                if profit < min_profit {
209                                    min_profit = profit;
210                                }
211                                if profit > max_profit {
212                                    max_profit = profit;
213                                }
214                            }
215                        }
216
217                        // Add vertical line at current underlying price
218                        // Extend it slightly beyond min/max profit for visibility:
219                        // 5% padding on each side, and the break-even labels half
220                        // a padding above the top. A range where no price could
221                        // be scored has no vertical extent (the extremes are
222                        // still at their seeds), and a padded extent outside
223                        // `Decimal` cannot be drawn; both chart as empty, like
224                        // the other failures above.
225                        let extent = if min_profit <= max_profit {
226                            max_profit
227                                .checked_sub(min_profit)
228                                .and_then(|span| span.checked_mul(Decimal::new(5, 2)))
229                                .and_then(|padding| {
230                                    Some((
231                                        min_profit.checked_sub(padding)?,
232                                        max_profit.checked_add(padding)?,
233                                        max_profit
234                                            .checked_add(padding.checked_mul(Decimal::new(5, 1))?)?,
235                                    ))
236                                })
237                        } else {
238                            None
239                        };
240                        let Some((padded_min, padded_max, label_height)) = extent else {
241                            $crate::__private::tracing::warn!(
242                                %min_profit,
243                                %max_profit,
244                                "payoff chart has no drawable vertical extent"
245                            );
246                            return GraphData::Series(Series2D::default());
247                        };
248
249                        // Now that we have min_profit and max_profit, update the y_offset for the label
250                        // For positive values: place near the top of the graph
251                        // For negative values: place near the bottom of the graph
252                        if let Some((payoff, (_, label_series))) =
253                            pay_off_at_underlying_price.zip(marker.as_mut())
254                        {
255                            let y_offset = if payoff >= Decimal::ZERO {
256                                // For positive values, place the label at the top of the graph
257                                padded_max
258                            } else {
259                                // For negative values, place the label at the bottom of the graph
260                                padded_min
261                            };
262
263                            // Update the label series with the new y_offset
264                            if let Some(label_y) = label_series.y.first_mut() {
265                                *label_y = y_offset;
266                            }
267                        }
268
269                        // Create labels for break-even points
270                        // For each break-even point, create a label showing its X value
271                        for be_point in break_even_points.iter() {
272                            // Create a Point2D for the break-even label position
273                            let be_label_point = VisPoint2D {
274                                x: be_point.to_dec(),
275                                y: label_height, // Position above the graph
276                                name: format!("Break-even"),
277                                mode: TraceMode::TextLabels,
278                                color: Some("#000000".to_string()), // Black color for break-even labels
279                                width: Some(0.0),
280                            };
281
282                            // Create a Label2D that combines the point and the text
283                            let be_label = Label2D {
284                                point: be_label_point,
285                                label: format!("BE: {}", be_point.to_dec().round_dp(2)),
286                            };
287
288                            break_even_labels.push(be_label);
289                        }
290
291                        current_price_line.x.push(underlying_price.to_dec());
292                        current_price_line.y.push(padded_min);
293                        current_price_line.x.push(underlying_price.to_dec());
294                        current_price_line.y.push(padded_max);
295                    }
296
297                    // Calculate profit at each price point and add to the series
298                    for price in range {
299                        let profit = match self.calculate_profit_at(&price) {
300                            Ok(p) => p,
301                            Err(e) => {
302                                $crate::__private::tracing::warn!(
303                                    error = %e,
304                                    "skipping price point with unscorable profit"
305                                );
306                                continue;
307                            }
308                        };
309
310                        profit_series.x.push(price.to_dec());
311                        profit_series.y.push(profit);
312                    }
313
314                    // Create separate series for positive and negative segments, ensuring continuity
315                    let mut segments = Vec::new();
316                    let mut current_segment = Vec::new();
317                    let mut current_sign: Option<i8> = None;
318
319                    // Process points to create continuous segments with the same sign
320                    for (price, &profit) in profit_series.x.iter().zip(&profit_series.y) {
321                        let sign = if profit > Decimal::ZERO {
322                            1
323                        } else if profit < Decimal::ZERO {
324                            -1
325                        } else {
326                            0
327                        };
328
329                        // If the sign changes or it's the first point
330                        let sign_changed = match current_sign {
331                            None => true,
332                            Some(cur) => sign != 0 && cur != sign,
333                        };
334                        if sign_changed {
335                            // If there are already points in the current segment, save it
336                            if !current_segment.is_empty() {
337                                if let Some(cur) = current_sign {
338                                    segments.push((current_segment, cur));
339                                }
340                                current_segment = Vec::new();
341                            }
342                            current_sign = Some(sign);
343                        }
344
345                        // Add the point to the current segment
346                        current_segment.push((*price, profit));
347                    }
348
349                    // Add the last segment if it's not empty
350                    if let (false, Some(cur)) = (current_segment.is_empty(), current_sign) {
351                        segments.push((current_segment, cur));
352                    }
353
354                    // Create series for each segment
355                    let mut series_list = Vec::new();
356                    for ((segment, sign), number) in segments.iter().zip(1usize..) {
357                        let color = if *sign > 0 {
358                            "#2ca02c".to_string() // Green for positive values
359                        } else if *sign < 0 {
360                            "#FF0000".to_string() // Red for negative values
361                        } else {
362                            "#000000".to_string() // Black for zero
363                        };
364
365                        let series = Series2D {
366                            x: segment.iter().map(|(x, _)| *x).collect(),
367                            y: segment.iter().map(|(_, y)| *y).collect(),
368                            name: format!("Segment {number}"),
369                            mode: TraceMode::Lines,
370                            line_color: Some(color),
371                            line_width: Some(2.0),
372                        };
373
374                        series_list.push(series);
375                    }
376
377                    // Remove vertical lines for each price, as they are not necessary
378                    // and were causing errors by using undefined variables
379
380                    // Add reference lines, current point and label
381                    series_list.push(zero_line);
382                    series_list.push(current_price_line);
383                    if let Some((current_point, label_series)) = marker {
384                        series_list.push(current_point);
385                        series_list.push(label_series);
386                    }
387
388                    // Add break-even point labels
389                    for be_label in break_even_labels {
390                        // Convert each Label2D to Series2D
391                        let be_series = Series2D {
392                            x: vec![be_label.point.x],
393                            y: vec![be_label.point.y],
394                            name: be_label.label.clone(),
395                            mode: TraceMode::TextLabels,
396                            line_color: be_label.point.color,
397                            line_width: be_label.point.width,
398                        };
399
400                        series_list.push(be_series);
401                    }
402
403                    GraphData::MultiSeries(series_list)
404                }
405
406                fn graph_config(&self) -> $crate::visualization::GraphConfig {
407                    use $crate::__private::BasicAble;
408                    use $crate::visualization::{ColorScheme, GraphConfig, LineStyle};
409
410                    let title = self.get_title();
411                    let legend = Some(vec![title.clone()]);
412                    GraphConfig {
413                        title,
414                        width: 1600,
415                        height: 900,
416                        x_label: Some("Underlying Price".to_string()),
417                        y_label: Some("Profit/Loss".to_string()),
418                        z_label: None,
419                        line_style: LineStyle::Solid,
420                        color_scheme: ColorScheme::Default,
421                        legend,
422                        show_legend: false,
423                    }
424                }
425            }
426        )*
427    };
428}
429
430impl_graph_for_payoff_strategy!(
431    BullCallSpread,
432    BearCallSpread,
433    BullPutSpread,
434    BearPutSpread,
435    LongButterflySpread,
436    ShortButterflySpread,
437    IronCondor,
438    IronButterfly,
439    LongStraddle,
440    ShortStraddle,
441    LongStrangle,
442    ShortStrangle,
443    LongCall,
444    LongPut,
445    ShortCall,
446    ShortPut,
447    PoorMansCoveredCall,
448    BullCallLadder,
449    optionstratlib_strategies::strategies::custom::CustomStrategy,
450    optionstratlib_strategies::strategies::covered_call::CoveredCall,
451    optionstratlib_strategies::strategies::collar::Collar,
452    optionstratlib_strategies::strategies::protective_put::ProtectivePut
453);
454
455#[cfg(test)]
456mod tests {
457    use crate::visualization::Graph;
458    use optionstratlib_strategies::strategies::collar::Collar;
459    use optionstratlib_strategies::strategies::covered_call::CoveredCall;
460    use optionstratlib_strategies::strategies::custom::CustomStrategy;
461    use optionstratlib_strategies::strategies::protective_put::ProtectivePut;
462    use optionstratlib_strategies::strategies::{
463        BearCallSpread, BearPutSpread, BullCallLadder, BullCallSpread, BullPutSpread,
464        IronButterfly, IronCondor, LongButterflySpread, LongCall, LongPut, LongStraddle,
465        LongStrangle, PoorMansCoveredCall, ShortButterflySpread, ShortCall, ShortPut,
466        ShortStraddle, ShortStrangle, Strategable,
467    };
468    use static_assertions::assert_impl_all;
469
470    // `Graph` is no longer a supertrait of `Strategable` (#658), so nothing
471    // in the strategies layer proves a strategy can be charted. Every
472    // concrete strategy must keep both.
473    #[test]
474    fn test_every_strategy_implements_graph() {
475        assert_impl_all!(BullCallSpread: Strategable, Graph);
476        assert_impl_all!(BearCallSpread: Strategable, Graph);
477        assert_impl_all!(BullPutSpread: Strategable, Graph);
478        assert_impl_all!(BearPutSpread: Strategable, Graph);
479        assert_impl_all!(LongButterflySpread: Strategable, Graph);
480        assert_impl_all!(ShortButterflySpread: Strategable, Graph);
481        assert_impl_all!(IronCondor: Strategable, Graph);
482        assert_impl_all!(IronButterfly: Strategable, Graph);
483        assert_impl_all!(LongStraddle: Strategable, Graph);
484        assert_impl_all!(ShortStraddle: Strategable, Graph);
485        assert_impl_all!(LongStrangle: Strategable, Graph);
486        assert_impl_all!(ShortStrangle: Strategable, Graph);
487        assert_impl_all!(LongCall: Strategable, Graph);
488        assert_impl_all!(LongPut: Strategable, Graph);
489        assert_impl_all!(ShortCall: Strategable, Graph);
490        assert_impl_all!(ShortPut: Strategable, Graph);
491        assert_impl_all!(PoorMansCoveredCall: Strategable, Graph);
492        assert_impl_all!(BullCallLadder: Strategable, Graph);
493        assert_impl_all!(CustomStrategy: Strategable, Graph);
494        assert_impl_all!(CoveredCall: Strategable, Graph);
495        assert_impl_all!(Collar: Strategable, Graph);
496        assert_impl_all!(ProtectivePut: Strategable, Graph);
497    }
498}