optionstratlib-visualization 0.23.1

Backend-neutral chart models, the Graph contract and its implementations for OptionStratLib types, with optional Plotly rendering and static export. Re-exported by the optionstratlib facade under its `visualization` feature.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
//! `Graph` implementations for the strategies.
//!
//! Every payoff strategy is charted the same way: profit/loss over the
//! strategy's preferred price range, split into positive and negative
//! segments, with a zero line, a marker at the current underlying price
//! and labels at the break-even points. `impl_graph_for_payoff_strategy!`
//! generates that implementation; the invocation at the end of this file
//! covers every concrete strategy. The implementations live here because
//! `Graph` is a visualization trait and strategies must not depend on
//! visualization (ADR-0001 D2, strategies row).

use optionstratlib_strategies::strategies::{
    BearCallSpread, BearPutSpread, BullCallLadder, BullCallSpread, BullPutSpread, IronButterfly,
    IronCondor, LongButterflySpread, LongCall, LongPut, LongStraddle, LongStrangle,
    PoorMansCoveredCall, ShortButterflySpread, ShortCall, ShortPut, ShortStraddle, ShortStrangle,
};

/// Macro `impl_graph_for_payoff_strategy` generates implementations of the `Graph` trait
/// for one or more given types. This is specifically designed for types that represent
/// payoff strategies, enabling them to produce graph data and configurations for financial
/// visualizations, such as profit/loss graphs based on an underlying price range.
///
/// # Usage
///
/// Use the macro to implement the `Graph` trait for multiple types. For example:
/// This will generate implementations of the `Graph` trait for `TypeA` and `TypeB`.
///
/// # Generated Trait Implementation
///
/// ## `graph_data`
///
/// - Generates `GraphData` for the associated type by calculating the profit/loss data for
///   a price range obtained from the `get_best_range_to_show` method.
/// - The result is displayed as two series:
///   1. A "Positive Payoff" series (green) for profits.
///   2. A "Negative Payoff" series (red) for losses.
/// - Handles profits, losses, and break-even points separately:
///   - Break-even points (`0`) are shared between positive and negative series.
///   - Prices where profit is positive are included in the positive series only.
///   - Prices where profit is negative are included in the negative series only.
/// - If `get_best_range_to_show` fails, a default empty `GraphData::Series` is returned.
///
/// ## `graph_config`
///
/// - Produces a `GraphConfig` structure for styling and configuring the graph:
///   - Title: Based on the strategy's title via `get_title` method.
///   - Width and height: Predefined dimensions (1600x900).
///   - Labels: X-axis represents "Underlying Price", and Y-axis represents "Profit/Loss".
///   - Other visual configuration properties include:
///     - `line_style`: Set to `LineStyle::Solid`.
///     - `color_scheme`: Set to default.
///     - `legend`: Title is added to the legend, but `show_legend` is disabled by default.
///
/// # Parameters
///
/// - `$t`: One or more types for which the `Graph` trait should be implemented.
///
///
/// This allows the `MyStrategy` type to generate graph data and configurations for visual representation
/// of its payoff strategy.
///
/// # Notes
///
/// - The macro assumes that the types implemented provide specific methods (`get_best_range_to_show`,
///   `calculate_profit_at`, and `get_title`) necessary for the `Graph` trait.
/// - Predefined values like colors, line widths, and dimensions can be adjusted within the macro if needed.
/// - The expansion reaches every item it names through `$crate` paths, so the
///   calling module needs no imports beyond the types it lists.
///
#[macro_export]
macro_rules! impl_graph_for_payoff_strategy {
    ($($t:ty),*) => {
        $(
            impl $crate::visualization::Graph for $t {
                fn graph_data(&self) -> $crate::visualization::GraphData {
                    use $crate::__private::{
                        BasicAble, BreakEvenable, Decimal, Positive, Profit, Strategies,
                    };
                    use $crate::visualization::{
                        GraphData, Label2D, Series2D, TraceMode, VisPoint2D,
                    };

                    let break_even_points = match self.get_break_even_points() {
                        Ok(points) => points,
                        Err(_) => return GraphData::Series(Series2D::default()),
                    };
                    let underlying_price = match self.get_underlying_price() {
                        Ok(price) => price,
                        Err(_) => return GraphData::Series(Series2D::default()),
                    };
                    // The marker and label at the current price are left out when the
                    // payoff there cannot be computed, rather than drawn at zero.
                    let pay_off_at_underlying_price = self.calculate_profit_at(&underlying_price).ok();
                    let range = match self.get_best_range_to_show(Positive::ONE){
                        Ok(range) => range,
                        Err(_) => return GraphData::Series(Series2D::default()),
                    };

                    // Create a vector to store break-even point labels
                    let mut break_even_labels = Vec::new();

                    // Create a single continuous series for the profit/loss chart
                    let mut profit_series = Series2D {
                        x: vec![],
                        y: vec![],
                        name: "Profit/Loss".to_string(),
                        mode: TraceMode::Lines,
                        line_color: Some("#2ca02c".to_string()),
                        line_width: Some(2.0),
                    };

                    // Add a zero line for reference
                    let mut zero_line = Series2D {
                        x: vec![],
                        y: vec![],
                        name: "Break Even".to_string(),
                        mode: TraceMode::Lines,
                        line_color: Some("#000000".to_string()),
                        line_width: Some(1.0),
                    };

                    // Add a vertical line at the current underlying price
                    let mut current_price_line = Series2D {
                        x: vec![],
                        y: vec![],
                        name: "Current Price".to_string(),
                        mode: TraceMode::Lines,
                        line_color: Some("#0000FF".to_string()), // Blue color
                        line_width: Some(1.0),
                    };

                    let mut marker = pay_off_at_underlying_price.map(|pay_off_at_underlying_price| {
                        // Add a point at current price with its payoff value
                        // Color depends on payoff value: green (positive), blue (zero), red (negative)
                        let point_color = if pay_off_at_underlying_price > Decimal::ZERO {
                            "#2ca02c".to_string() // Green for positive values
                        } else if pay_off_at_underlying_price < Decimal::ZERO {
                            "#FF0000".to_string() // Red for negative values
                        } else {
                            "#0000FF".to_string() // Blue for zero
                        };

                        // Format the payoff value with 2 decimals for the label
                        let formatted_payoff = format!("{:.2}", pay_off_at_underlying_price);

                        // Create a point to mark the current position with a larger size
                        // and use the appropriate color based on the payoff value
                        let current_point = Series2D {
                            x: vec![underlying_price.to_dec()],
                            y: vec![pay_off_at_underlying_price],
                            // The label will appear in the legend and when hovering over the point
                            name: format!("Current P/L: {}", formatted_payoff),
                            // Use LinesMarkers to display the point
                            mode: TraceMode::Markers,
                            line_color: Some(point_color.clone()),
                            line_width: Some(10.0), // Larger point for better visibility
                        };

                        // We'll set the y_offset after calculating min_profit and max_profit
                        // This will allow us to position the labels at the top or bottom of the graph
                        let y_offset = Decimal::ZERO; // Initial value, will be updated later

                        // Create a Point2D for the label position
                        let label_point = VisPoint2D {
                            x: underlying_price.to_dec(),
                            y: y_offset,
                            name: format!("P/L Label"),
                            mode: TraceMode::TextLabels,
                            color: Some(point_color),
                            width: Some(0.0),
                        };

                        // Create a Label2D that combines the point and the text
                        let label = Label2D {
                            point: label_point,
                            label: formatted_payoff,
                        };

                        // Convert the Label2D to a Series2D for compatibility with existing code
                        let label_series = Series2D {
                            x: vec![label.point.x],
                            y: vec![label.point.y],
                            name: label.label.clone(),
                            mode: TraceMode::TextLabels,
                            line_color: label.point.color,
                            line_width: label.point.width,
                        };
                        (current_point, label_series)
                    });

                    // Get min and max values for reference lines
                    if let (Some(first), Some(last)) = (range.first(), range.last()) {
                        let min_price = first.to_dec();
                        let max_price = last.to_dec();

                        // Add zero line points (horizontal line at y=0)
                        zero_line.x.push(min_price);
                        zero_line.y.push(Decimal::ZERO);
                        zero_line.x.push(max_price);
                        zero_line.y.push(Decimal::ZERO);

                        // Calculate min and max profit values to determine the vertical range
                        let mut min_profit = Decimal::MAX;
                        let mut max_profit = Decimal::MIN;

                        for price in range.iter() {
                            if let Ok(profit) = self.calculate_profit_at(price) {
                                if profit < min_profit {
                                    min_profit = profit;
                                }
                                if profit > max_profit {
                                    max_profit = profit;
                                }
                            }
                        }

                        // Add vertical line at current underlying price
                        // Extend it slightly beyond min/max profit for visibility:
                        // 5% padding on each side, and the break-even labels half
                        // a padding above the top. A range where no price could
                        // be scored has no vertical extent (the extremes are
                        // still at their seeds), and a padded extent outside
                        // `Decimal` cannot be drawn; both chart as empty, like
                        // the other failures above.
                        let extent = if min_profit <= max_profit {
                            max_profit
                                .checked_sub(min_profit)
                                .and_then(|span| span.checked_mul(Decimal::new(5, 2)))
                                .and_then(|padding| {
                                    Some((
                                        min_profit.checked_sub(padding)?,
                                        max_profit.checked_add(padding)?,
                                        max_profit
                                            .checked_add(padding.checked_mul(Decimal::new(5, 1))?)?,
                                    ))
                                })
                        } else {
                            None
                        };
                        let Some((padded_min, padded_max, label_height)) = extent else {
                            $crate::__private::tracing::warn!(
                                %min_profit,
                                %max_profit,
                                "payoff chart has no drawable vertical extent"
                            );
                            return GraphData::Series(Series2D::default());
                        };

                        // Now that we have min_profit and max_profit, update the y_offset for the label
                        // For positive values: place near the top of the graph
                        // For negative values: place near the bottom of the graph
                        if let Some((payoff, (_, label_series))) =
                            pay_off_at_underlying_price.zip(marker.as_mut())
                        {
                            let y_offset = if payoff >= Decimal::ZERO {
                                // For positive values, place the label at the top of the graph
                                padded_max
                            } else {
                                // For negative values, place the label at the bottom of the graph
                                padded_min
                            };

                            // Update the label series with the new y_offset
                            if let Some(label_y) = label_series.y.first_mut() {
                                *label_y = y_offset;
                            }
                        }

                        // Create labels for break-even points
                        // For each break-even point, create a label showing its X value
                        for be_point in break_even_points.iter() {
                            // Create a Point2D for the break-even label position
                            let be_label_point = VisPoint2D {
                                x: be_point.to_dec(),
                                y: label_height, // Position above the graph
                                name: format!("Break-even"),
                                mode: TraceMode::TextLabels,
                                color: Some("#000000".to_string()), // Black color for break-even labels
                                width: Some(0.0),
                            };

                            // Create a Label2D that combines the point and the text
                            let be_label = Label2D {
                                point: be_label_point,
                                label: format!("BE: {}", be_point.to_dec().round_dp(2)),
                            };

                            break_even_labels.push(be_label);
                        }

                        current_price_line.x.push(underlying_price.to_dec());
                        current_price_line.y.push(padded_min);
                        current_price_line.x.push(underlying_price.to_dec());
                        current_price_line.y.push(padded_max);
                    }

                    // Calculate profit at each price point and add to the series
                    for price in range {
                        let profit = match self.calculate_profit_at(&price) {
                            Ok(p) => p,
                            Err(e) => {
                                $crate::__private::tracing::warn!(
                                    error = %e,
                                    "skipping price point with unscorable profit"
                                );
                                continue;
                            }
                        };

                        profit_series.x.push(price.to_dec());
                        profit_series.y.push(profit);
                    }

                    // Create separate series for positive and negative segments, ensuring continuity
                    let mut segments = Vec::new();
                    let mut current_segment = Vec::new();
                    let mut current_sign: Option<i8> = None;

                    // Process points to create continuous segments with the same sign
                    for (price, &profit) in profit_series.x.iter().zip(&profit_series.y) {
                        let sign = if profit > Decimal::ZERO {
                            1
                        } else if profit < Decimal::ZERO {
                            -1
                        } else {
                            0
                        };

                        // If the sign changes or it's the first point
                        let sign_changed = match current_sign {
                            None => true,
                            Some(cur) => sign != 0 && cur != sign,
                        };
                        if sign_changed {
                            // If there are already points in the current segment, save it
                            if !current_segment.is_empty() {
                                if let Some(cur) = current_sign {
                                    segments.push((current_segment, cur));
                                }
                                current_segment = Vec::new();
                            }
                            current_sign = Some(sign);
                        }

                        // Add the point to the current segment
                        current_segment.push((*price, profit));
                    }

                    // Add the last segment if it's not empty
                    if let (false, Some(cur)) = (current_segment.is_empty(), current_sign) {
                        segments.push((current_segment, cur));
                    }

                    // Create series for each segment
                    let mut series_list = Vec::new();
                    for ((segment, sign), number) in segments.iter().zip(1usize..) {
                        let color = if *sign > 0 {
                            "#2ca02c".to_string() // Green for positive values
                        } else if *sign < 0 {
                            "#FF0000".to_string() // Red for negative values
                        } else {
                            "#000000".to_string() // Black for zero
                        };

                        let series = Series2D {
                            x: segment.iter().map(|(x, _)| *x).collect(),
                            y: segment.iter().map(|(_, y)| *y).collect(),
                            name: format!("Segment {number}"),
                            mode: TraceMode::Lines,
                            line_color: Some(color),
                            line_width: Some(2.0),
                        };

                        series_list.push(series);
                    }

                    // Remove vertical lines for each price, as they are not necessary
                    // and were causing errors by using undefined variables

                    // Add reference lines, current point and label
                    series_list.push(zero_line);
                    series_list.push(current_price_line);
                    if let Some((current_point, label_series)) = marker {
                        series_list.push(current_point);
                        series_list.push(label_series);
                    }

                    // Add break-even point labels
                    for be_label in break_even_labels {
                        // Convert each Label2D to Series2D
                        let be_series = Series2D {
                            x: vec![be_label.point.x],
                            y: vec![be_label.point.y],
                            name: be_label.label.clone(),
                            mode: TraceMode::TextLabels,
                            line_color: be_label.point.color,
                            line_width: be_label.point.width,
                        };

                        series_list.push(be_series);
                    }

                    GraphData::MultiSeries(series_list)
                }

                fn graph_config(&self) -> $crate::visualization::GraphConfig {
                    use $crate::__private::BasicAble;
                    use $crate::visualization::{ColorScheme, GraphConfig, LineStyle};

                    let title = self.get_title();
                    let legend = Some(vec![title.clone()]);
                    GraphConfig {
                        title,
                        width: 1600,
                        height: 900,
                        x_label: Some("Underlying Price".to_string()),
                        y_label: Some("Profit/Loss".to_string()),
                        z_label: None,
                        line_style: LineStyle::Solid,
                        color_scheme: ColorScheme::Default,
                        legend,
                        show_legend: false,
                    }
                }
            }
        )*
    };
}

impl_graph_for_payoff_strategy!(
    BullCallSpread,
    BearCallSpread,
    BullPutSpread,
    BearPutSpread,
    LongButterflySpread,
    ShortButterflySpread,
    IronCondor,
    IronButterfly,
    LongStraddle,
    ShortStraddle,
    LongStrangle,
    ShortStrangle,
    LongCall,
    LongPut,
    ShortCall,
    ShortPut,
    PoorMansCoveredCall,
    BullCallLadder,
    optionstratlib_strategies::strategies::custom::CustomStrategy,
    optionstratlib_strategies::strategies::covered_call::CoveredCall,
    optionstratlib_strategies::strategies::collar::Collar,
    optionstratlib_strategies::strategies::protective_put::ProtectivePut
);

#[cfg(test)]
mod tests {
    use crate::visualization::Graph;
    use optionstratlib_strategies::strategies::collar::Collar;
    use optionstratlib_strategies::strategies::covered_call::CoveredCall;
    use optionstratlib_strategies::strategies::custom::CustomStrategy;
    use optionstratlib_strategies::strategies::protective_put::ProtectivePut;
    use optionstratlib_strategies::strategies::{
        BearCallSpread, BearPutSpread, BullCallLadder, BullCallSpread, BullPutSpread,
        IronButterfly, IronCondor, LongButterflySpread, LongCall, LongPut, LongStraddle,
        LongStrangle, PoorMansCoveredCall, ShortButterflySpread, ShortCall, ShortPut,
        ShortStraddle, ShortStrangle, Strategable,
    };
    use static_assertions::assert_impl_all;

    // `Graph` is no longer a supertrait of `Strategable` (#658), so nothing
    // in the strategies layer proves a strategy can be charted. Every
    // concrete strategy must keep both.
    #[test]
    fn test_every_strategy_implements_graph() {
        assert_impl_all!(BullCallSpread: Strategable, Graph);
        assert_impl_all!(BearCallSpread: Strategable, Graph);
        assert_impl_all!(BullPutSpread: Strategable, Graph);
        assert_impl_all!(BearPutSpread: Strategable, Graph);
        assert_impl_all!(LongButterflySpread: Strategable, Graph);
        assert_impl_all!(ShortButterflySpread: Strategable, Graph);
        assert_impl_all!(IronCondor: Strategable, Graph);
        assert_impl_all!(IronButterfly: Strategable, Graph);
        assert_impl_all!(LongStraddle: Strategable, Graph);
        assert_impl_all!(ShortStraddle: Strategable, Graph);
        assert_impl_all!(LongStrangle: Strategable, Graph);
        assert_impl_all!(ShortStrangle: Strategable, Graph);
        assert_impl_all!(LongCall: Strategable, Graph);
        assert_impl_all!(LongPut: Strategable, Graph);
        assert_impl_all!(ShortCall: Strategable, Graph);
        assert_impl_all!(ShortPut: Strategable, Graph);
        assert_impl_all!(PoorMansCoveredCall: Strategable, Graph);
        assert_impl_all!(BullCallLadder: Strategable, Graph);
        assert_impl_all!(CustomStrategy: Strategable, Graph);
        assert_impl_all!(CoveredCall: Strategable, Graph);
        assert_impl_all!(Collar: Strategable, Graph);
        assert_impl_all!(ProtectivePut: Strategable, Graph);
    }
}