Skip to main content

optionstratlib_visualization/visualization/
graph.rs

1//! The `Graph` contract: one trait on every feature surface.
2//!
3//! An implementor supplies [`Graph::graph_data`] and may override
4//! [`Graph::graph_config`]; that is the whole contract, with or without a
5//! backend. `plotly` adds the provided rendering methods (`to_plot`,
6//! `write_html`, `show`, `render`, `to_interactive_html`) and
7//! `static_export` adds `write_png` and `write_svg`. Features only add
8//! provided methods, so an implementation never changes with them
9//! (ADR-0002 section 4).
10
11use crate::visualization::{GraphConfig, GraphData};
12#[cfg(feature = "plotly")]
13use {
14    crate::error::GraphError,
15    crate::visualization::OutputType,
16    crate::visualization::file::prepare_file_path,
17    crate::visualization::{make_scatter, make_surface, pick_color},
18    plotly::layout::Axis,
19    plotly::{Layout, Plot, common},
20};
21
22#[cfg(feature = "static_export")]
23use {plotly::plotly_static::ImageFormat, std::sync::Mutex, tracing::debug};
24
25/// Serializes every static export in the process.
26///
27/// `Plot::write_image` builds a `StaticExporter` per call, which attaches to a
28/// chromedriver already listening on the default port or spawns its own. The
29/// exporter that spawned the driver SIGKILLs it on close, even while another
30/// exporter that attached to it still has a browser session open; that
31/// session's headless Chrome is then orphaned and never exits (#724). Holding
32/// this lock across the whole export, retries included, makes every
33/// spawn → session → close → stop cycle run alone.
34///
35/// The lock is per process: two test binaries running at once (parallel
36/// worktrees, or a runner with one process per test) still share the default
37/// chromedriver port.
38#[cfg(feature = "static_export")]
39static STATIC_EXPORT_LOCK: Mutex<()> = Mutex::new(());
40
41/// Writes `plot` to `path` as a static image, retrying up to three times.
42///
43/// Holds [`STATIC_EXPORT_LOCK`] for the whole call so no two exports share a
44/// chromedriver.
45#[cfg(feature = "static_export")]
46fn export_image(
47    plot: &Plot,
48    path: &std::path::Path,
49    format: ImageFormat,
50    width: u32,
51    height: u32,
52) -> Result<(), GraphError> {
53    const MAX_ATTEMPTS: u32 = 3;
54
55    prepare_file_path(path)?;
56    let label = format.to_string().to_uppercase();
57    debug!("Writing {label} to: {}", path.display());
58
59    // The guarded value is `()`, so a panic in another export leaves nothing
60    // inconsistent behind: recover the guard instead of failing every export
61    // after it.
62    let _guard = STATIC_EXPORT_LOCK
63        .lock()
64        .unwrap_or_else(std::sync::PoisonError::into_inner);
65
66    let mut last_error = String::new();
67    for attempt in 1..=MAX_ATTEMPTS {
68        debug!("{label} export attempt {attempt} of {MAX_ATTEMPTS}");
69        match plot.write_image(path, format.clone(), width as usize, height as usize, 1.0) {
70            Ok(()) => {
71                debug!("Successfully wrote {label} to: {}", path.display());
72                return Ok(());
73            }
74            Err(e) => {
75                debug!("{label} export attempt {attempt} failed: {e}");
76                last_error = e.to_string();
77                if attempt < MAX_ATTEMPTS {
78                    std::thread::sleep(std::time::Duration::from_millis(100));
79                }
80            }
81        }
82    }
83
84    Err(GraphError::Render(format!(
85        "Failed to write {label} after {MAX_ATTEMPTS} attempts: {last_error} on path: {}",
86        path.display()
87    )))
88}
89
90/// Chart contract of every type the library can draw: strategies, option
91/// chains' curves and surfaces, options, positions and simulations.
92///
93/// The required items are the same on every feature surface: implementors
94/// supply `graph_data()` and may override `graph_config()`. The backend
95/// features add provided renderers on top: `to_plot`, `write_html`, `show`,
96/// `render` and `to_interactive_html` under `plotly`, and `write_png` and
97/// `write_svg` under `static_export`. None of them needs to be implemented.
98pub trait Graph {
99    /// Return the raw data ready for plotting.
100    fn graph_data(&self) -> GraphData;
101
102    /// Optional per‑object configuration overrides.
103    fn graph_config(&self) -> GraphConfig {
104        GraphConfig::default()
105    }
106
107    /// Build a `plotly::Plot` according to data + config.
108    #[cfg(feature = "plotly")]
109    fn to_plot(&self) -> Plot {
110        let cfg = self.graph_config();
111        let mut plot = Plot::new();
112
113        match self.graph_data() {
114            GraphData::Series(s) => {
115                let mut series = s.clone();
116                if let Some(legend) = &cfg.legend
117                    && let Some(label) = legend.first()
118                {
119                    series.name = label.clone();
120                }
121                plot.add_trace(make_scatter(&series));
122            }
123            GraphData::MultiSeries(list) => {
124                for (idx, s) in list.into_iter().enumerate() {
125                    let mut series = s;
126
127                    if series.line_color.is_none() {
128                        series.line_color = pick_color(&cfg, idx);
129                    }
130
131                    if let Some(label) = cfg.legend.as_ref().and_then(|legend| legend.get(idx)) {
132                        series.name = label.clone();
133                    }
134
135                    plot.add_trace(make_scatter(&series));
136                }
137            }
138            GraphData::GraphSurface(surf) => {
139                let mut surface = surf.clone();
140                if let Some(legend) = &cfg.legend
141                    && let Some(label) = legend.first()
142                {
143                    surface.name = label.clone();
144                }
145                plot.add_trace(make_surface(&surface));
146            }
147        }
148
149        let mut layout = Layout::new()
150            .width(cfg.width as usize)
151            .height(cfg.height as usize)
152            .title(common::Title::from(&cfg.title))
153            .show_legend(cfg.show_legend);
154
155        if let Some(label) = cfg.x_label {
156            layout = layout.x_axis(Axis::new().title(common::Title::from(&label)));
157        }
158        if let Some(label) = cfg.y_label {
159            layout = layout.y_axis(Axis::new().title(common::Title::from(&label)));
160        }
161        if let Some(label) = cfg.z_label {
162            layout = layout.z_axis(Axis::new().title(common::Title::from(&label)));
163        }
164
165        plot.set_layout(layout);
166        plot
167    }
168
169    /// Writes the graph as a PNG image to the specified file path.
170    ///
171    /// # Arguments
172    ///
173    /// * `path` - A reference to a `std::path::Path` that specifies the destination
174    ///   file path where the PNG image will be written to.
175    ///
176    /// # Returns
177    ///
178    /// Returns a `Result`:
179    /// * `Ok(())` - If the PNG image is successfully generated and written to the specified file.
180    /// * `Err(GraphError)` - If there is an error during the process of preparing the file path
181    ///   or writing the image.
182    ///
183    /// # Behavior
184    ///
185    /// * Prepares the target file path using the `prepare_file_path` function. If the preparation fails,
186    ///   an error is returned.
187    ///
188    /// * Retrieves the graph configuration (such as dimensions) using `self.graph_config()`.
189    /// * Converts the graph data into a plot using `self.to_plot()`, then generates and writes a PNG
190    ///   image to the specified path using the provided dimensions, `ImageFormat::PNG`, and a scaling factor of `1.0`.
191    ///
192    /// # Logging
193    ///
194    /// Logs a debug message with the target file path using the `debug!` macro before writing the PNG.
195    ///
196    /// # Errors
197    ///
198    /// Errors that might occur during execution:
199    /// * Issues with preparing the file path (e.g., invalid path, permissions issue).
200    /// * Internal errors with the image writing process.
201    ///
202    /// # Concurrency
203    ///
204    /// Static exports run one at a time per process: concurrent calls in the
205    /// same process wait on a shared lock so they never share a chromedriver
206    /// (#724). Separate processes are not coordinated.
207    ///
208    /// # Blocking
209    ///
210    /// This is a synchronous call. It blocks the calling thread while it waits
211    /// for the lock, while the WebDriver renders, and, after a failed attempt,
212    /// for a short pause before the next of up to three attempts. From async
213    /// code, run it on a blocking thread (for example
214    /// `tokio::task::spawn_blocking`) rather than on an executor thread.
215    ///
216    #[cfg(feature = "static_export")]
217    fn write_png(&self, path: &std::path::Path) -> Result<(), GraphError> {
218        let cfg = self.graph_config();
219        export_image(
220            &self.to_plot(),
221            path,
222            ImageFormat::PNG,
223            cfg.width,
224            cfg.height,
225        )
226    }
227
228    /// Writes the graph data to an HTML file at the specified path.
229    ///
230    /// This method generates a plot representation of the graph and saves it
231    /// as an HTML document. It ensures that the provided file path is prepared
232    /// (i.e., directories are created if necessary) before writing the file.
233    ///
234    /// # Arguments
235    ///
236    /// * `path` - A reference to a `std::path::Path` specifying the file path where
237    ///   the HTML file will be written.
238    ///
239    /// # Returns
240    ///
241    /// * `Ok(())` if the HTML file is successfully written.
242    /// * `Err(GraphError)` if an error occurs during file preparation or writing.
243    ///
244    /// # Errors
245    ///
246    /// This method can return the following errors:
247    /// * A `GraphError` if the file path preparation fails.
248    /// * Any other error propagated from the `.to_plot().write_html()` method.
249    ///
250    /// # Notes
251    ///
252    /// Ensure that the directory specified in the file path exists or can be created
253    /// with appropriate permissions to avoid errors during file preparation.
254    #[cfg(feature = "plotly")]
255    fn write_html(&self, path: &std::path::Path) -> Result<(), GraphError> {
256        prepare_file_path(path)?;
257
258        // Create a plot with the graph data
259        let plot = self.to_plot();
260
261        // Get the plot configuration
262        let cfg = self.graph_config();
263
264        // Get the JSON representation of the plot
265        let plot_json = plot.to_json();
266
267        // Create a complete HTML document with embedded Plotly.js
268        let html = format!(
269            "\
270<!DOCTYPE html>
271<html lang=\"en\">
272<head>
273    <meta charset=\"utf-8\">
274    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
275    <title>{}</title>
276    <script src=\"https://cdn.plot.ly/plotly-2.24.1.min.js\" charset=\"utf-8\"></script>
277    <style>
278        body {{ margin: 0; padding: 20px; font-family: Arial, sans-serif; }}
279        #plotly-graph {{ width: 100%; height: 600px; }}
280    </style>
281</head>
282<body>
283    <div id=\"plotly-graph\"></div>
284    <script>
285        var plotJson = {};
286        Plotly.newPlot('plotly-graph', plotJson);
287    </script>
288</body>
289</html>",
290            cfg.title, plot_json
291        );
292
293        // Write HTML content to file
294        std::fs::write(path, html)
295            .map_err(|e| GraphError::Render(format!("Failed to write HTML file: {e}")))?;
296
297        Ok(())
298    }
299
300    /// Writes the graph representation to an SVG file at the specified path.
301    ///
302    /// # Arguments
303    ///
304    /// * `path` - A reference to a `std::path::Path` that specifies the location
305    ///   where the SVG file should be created.
306    ///
307    /// # Returns
308    ///
309    /// * `Result<(), GraphError>` - Returns `Ok(())` if the SVG is successfully
310    ///   written to the specified path. Otherwise, returns a `GraphError` if an
311    ///   issue occurs during the file preparation or writing process.
312    ///
313    /// # Behavior
314    ///
315    /// - Prepares the file path by ensuring it exists and is accessible.
316    /// - Retrieves the graph configuration (such as width and height).
317    /// - Converts the graph representation into a format suitable for plotting.
318    /// - Writes the graph into an SVG file with the specified width, height, and scale.
319    ///
320    /// # Errors
321    ///
322    /// This function may return a `GraphError` in the following cases:
323    /// - The file path cannot be prepared (e.g., due to permissions issues or invalid path).
324    /// - An error occurs during the conversion or writing process.
325    ///
326    /// # Concurrency
327    ///
328    /// Static exports run one at a time per process: concurrent calls in the
329    /// same process wait on a shared lock so they never share a chromedriver
330    /// (#724). Separate processes are not coordinated.
331    ///
332    /// # Blocking
333    ///
334    /// This is a synchronous call. It blocks the calling thread while it waits
335    /// for the lock, while the WebDriver renders, and, after a failed attempt,
336    /// for a short pause before the next of up to three attempts. From async
337    /// code, run it on a blocking thread (for example
338    /// `tokio::task::spawn_blocking`) rather than on an executor thread.
339    ///
340    #[cfg(feature = "static_export")]
341    fn write_svg(&self, path: &std::path::Path) -> Result<(), GraphError> {
342        let cfg = self.graph_config();
343        export_image(
344            &self.to_plot(),
345            path,
346            ImageFormat::SVG,
347            cfg.width,
348            cfg.height,
349        )
350    }
351
352    /// Show the plot in browser
353    ///
354    /// # Errors
355    ///
356    /// Returns [`GraphError::Io`] when the page cannot be written to the
357    /// system temp directory or the platform's opener (`open`, `xdg-open`,
358    /// `explorer`) cannot be started, as on a headless server, and
359    /// [`GraphError::Render`] on a platform with no default opener.
360    #[cfg(feature = "plotly")]
361    fn show(&self) -> Result<(), GraphError> {
362        show_in_browser(&self.to_plot())
363    }
364
365    /// One‑stop rendering with error propagation.
366    ///
367    /// # Errors
368    ///
369    /// Returns `GraphError::Render` when the chosen `OutputType`
370    /// backend (PNG/SVG via `static_export`, HTML, etc.) fails to
371    /// serialize or render, or when a PNG or SVG is asked for in a build
372    /// without `static_export`, and `GraphError::Io` when the
373    /// destination path cannot be written.
374    #[cfg(feature = "plotly")]
375    fn render(&self, output: OutputType) -> Result<(), GraphError> {
376        match output {
377            #[cfg(feature = "static_export")]
378            OutputType::Png(path) => {
379                debug!("Rendering PNG to: {}", path.display());
380                match self.write_png(path) {
381                    Ok(_) => debug!("Successfully wrote PNG to: {}", path.display()),
382                    Err(e) => return Err(GraphError::Render(format!("Failed to write PNG: {e}"))),
383                }
384            }
385            #[cfg(feature = "static_export")]
386            OutputType::Svg(path) => {
387                debug!("Rendering SVG to: {}", path.display());
388                match self.write_svg(path) {
389                    Ok(_) => debug!("Successfully wrote SVG to: {}", path.display()),
390                    Err(e) => return Err(GraphError::Render(format!("Failed to write SVG: {e}"))),
391                }
392            }
393            #[cfg(not(feature = "static_export"))]
394            OutputType::Png(_) | OutputType::Svg(_) => {
395                return Err(static_export_disabled());
396            }
397            OutputType::Browser => self.show()?,
398            OutputType::Html(path) => self.to_interactive_html(path)?,
399        }
400        Ok(())
401    }
402
403    /// Generate interactive HTML with hover info + annotations.
404    ///
405    /// # Errors
406    ///
407    /// Propagates any [`GraphError`] returned by
408    /// `PlotlyChart::write_html`, typically
409    /// `GraphError::Io` when the target file cannot be
410    /// created or written.
411    #[cfg(feature = "plotly")]
412    fn to_interactive_html(&self, path: &std::path::Path) -> Result<(), GraphError> {
413        self.write_html(path)
414    }
415}
416
417/// The error `Graph::render` returns for a PNG or SVG target in a build
418/// without the `static_export` feature, which is what writes images.
419/// Writes `plot` as a standalone page in the system temp directory and hands
420/// it to the platform's default opener.
421///
422/// `plotly`'s own `Plot::show` does the same, with the same page, but aborts
423/// the process when the temp file cannot be written or the opener cannot be
424/// started (a headless server has no `xdg-open`); here both come back as a
425/// [`GraphError`].
426#[cfg(feature = "plotly")]
427#[inline(never)]
428fn show_in_browser(plot: &Plot) -> Result<(), GraphError> {
429    use std::sync::atomic::{AtomicU64, Ordering};
430    use std::time::{SystemTime, UNIX_EPOCH};
431
432    // Process id, wall-clock nanoseconds and a per-process sequence number
433    // keep two pages shown at once from overwriting each other; the
434    // sequence only names files, so it is free to wrap.
435    static SEQUENCE: AtomicU64 = AtomicU64::new(0);
436    let nanos = SystemTime::now()
437        .duration_since(UNIX_EPOCH)
438        .map(|elapsed| elapsed.as_nanos())
439        .unwrap_or_default();
440    let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
441    let path = std::env::temp_dir().join(format!(
442        "plotly_optionstratlib_{}_{nanos}_{sequence}.html",
443        std::process::id()
444    ));
445    std::fs::write(&path, plot.to_html())?;
446    open_in_default_app(&path)
447}
448
449/// Opens `path` with the platform's default application, the way `plotly`
450/// does: `open` on macOS, `xdg-open` on the other Unix systems and
451/// `explorer` on Windows.
452#[cfg(feature = "plotly")]
453#[inline(never)]
454fn open_in_default_app(path: &std::path::Path) -> Result<(), GraphError> {
455    use std::process::Command;
456
457    #[cfg(target_os = "macos")]
458    Command::new("open").arg(path).output()?;
459    #[cfg(all(unix, not(target_os = "macos")))]
460    Command::new("xdg-open").arg(path).output()?;
461    #[cfg(target_os = "windows")]
462    Command::new("explorer").arg(path).spawn()?;
463    #[cfg(not(any(unix, target_os = "windows")))]
464    return Err(GraphError::Render(format!(
465        "no default application to open {} on this platform",
466        path.display()
467    )));
468    #[cfg(any(unix, target_os = "windows"))]
469    Ok(())
470}
471
472#[cfg(all(feature = "plotly", not(feature = "static_export")))]
473#[cold]
474#[inline(never)]
475fn static_export_disabled() -> GraphError {
476    GraphError::Render(
477        "png and svg export need the `static_export` feature of optionstratlib-visualization"
478            .to_string(),
479    )
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use crate::visualization::{ColorScheme, LineStyle, Series2D};
486    use rust_decimal_macros::dec;
487
488    // A simple struct that implements the Graph trait for testing
489    struct TestGraph {
490        data: GraphData,
491        config: Option<GraphConfig>,
492    }
493
494    impl TestGraph {
495        fn new(data: GraphData) -> Self {
496            TestGraph { data, config: None }
497        }
498
499        fn with_config(data: GraphData, config: GraphConfig) -> Self {
500            TestGraph {
501                data,
502                config: Some(config),
503            }
504        }
505    }
506
507    impl Graph for TestGraph {
508        fn graph_data(&self) -> GraphData {
509            self.data.clone()
510        }
511
512        fn graph_config(&self) -> GraphConfig {
513            match &self.config {
514                Some(config) => config.clone(),
515                None => GraphConfig::default(),
516            }
517        }
518    }
519
520    fn default_series() -> GraphData {
521        GraphData::Series(Series2D {
522            x: vec![dec!(1.0), dec!(2.0)],
523            y: vec![dec!(3.0), dec!(4.0)],
524            name: "Test Series".to_string(),
525            mode: crate::visualization::TraceMode::Lines,
526            line_color: None,
527            line_width: None,
528        })
529    }
530
531    #[test]
532    fn test_graph_data() {
533        // Create some test data
534        let data = default_series();
535        let graph = TestGraph::new(data.clone());
536
537        // Verify that graph_data returns the expected data
538        assert_eq!(graph.graph_data(), data);
539    }
540
541    #[test]
542    fn test_default_graph_config() {
543        let data = default_series();
544        let graph = TestGraph::new(data);
545
546        // Verify that the default config is returned when none is specified
547        let default_config = GraphConfig::default();
548        assert_eq!(graph.graph_config(), default_config);
549    }
550
551    #[test]
552    fn test_custom_graph_config() {
553        let data = default_series();
554
555        // Create a custom config
556        let custom_config = GraphConfig {
557            title: "Custom Title".to_string(),
558            width: 800,
559            height: 600,
560            x_label: Some("X Axis".to_string()),
561            y_label: Some("Y Axis".to_string()),
562            z_label: None,
563            line_style: LineStyle::Dashed,
564            color_scheme: ColorScheme::Default,
565            legend: Some(vec!["Series 1".to_string(), "Series 2".to_string()]),
566            show_legend: true,
567        };
568
569        let graph = TestGraph::with_config(data, custom_config.clone());
570
571        // Verify that the custom config is returned
572        assert_eq!(graph.graph_config(), custom_config);
573    }
574
575    #[test]
576    fn test_graph_config_fields() {
577        let data = default_series();
578
579        // Create a custom config with specific properties to test
580        let custom_config = GraphConfig {
581            title: "Test Chart".to_string(),
582            width: 1024,
583            height: 768,
584            x_label: Some("Time".to_string()),
585            y_label: Some("Value".to_string()),
586            z_label: Some("Depth".to_string()),
587            line_style: LineStyle::Solid,
588            color_scheme: ColorScheme::Viridis,
589            legend: Some(vec!["Data A".to_string(), "Data B".to_string()]),
590            show_legend: true,
591        };
592
593        let graph = TestGraph::with_config(data, custom_config);
594        let config = graph.graph_config();
595
596        // Test individual fields
597        assert_eq!(config.title, "Test Chart");
598        assert_eq!(config.width, 1024);
599        assert_eq!(config.height, 768);
600        assert_eq!(config.x_label, Some("Time".to_string()));
601        assert_eq!(config.y_label, Some("Value".to_string()));
602        assert_eq!(config.z_label, Some("Depth".to_string()));
603        assert_eq!(config.line_style, LineStyle::Solid);
604        assert_eq!(config.color_scheme, ColorScheme::Viridis);
605        assert_eq!(
606            config.legend,
607            Some(vec!["Data A".to_string(), "Data B".to_string()])
608        );
609        assert!(config.show_legend);
610    }
611
612    #[cfg(all(feature = "plotly", not(feature = "static_export")))]
613    #[test]
614    fn test_render_png_and_svg_without_static_export_return_render_error() {
615        let graph = TestGraph::new(default_series());
616        let path = std::path::PathBuf::from("render_without_static_export.png");
617        for output in [OutputType::Png(&path), OutputType::Svg(&path)] {
618            match graph.render(output) {
619                Err(GraphError::Render(message)) => assert!(message.contains("static_export")),
620                other => panic!("expected GraphError::Render, got {other:?}"),
621            }
622        }
623        assert!(!path.exists());
624    }
625}