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}