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}