Skip to main content

optionstratlib_visualization/visualization/terminal/
chain.rs

1//! The option chain as terminal tables.
2
3use super::table::{Section, boxed_table, header_row, print, render};
4use crate::error::GraphError;
5use optionstratlib_market::chains::{OPTION_CHAIN_TABLE_HEADERS, OptionChain};
6use prettytable::{Attr, Cell, Row, color};
7use rust_decimal::Decimal;
8
9/// Strikes on a multiple of this many points are highlighted.
10const HIGHLIGHTED_STRIKE_MULTIPLE: u32 = 25;
11
12/// Headers of the table naming the chain.
13const CHAIN_HEADER_COLUMNS: [&str; 3] = ["Symbol", "Underlying Price", "Expiration Date"];
14
15/// Terminal tables for an option chain.
16///
17/// The chain renders as two box-drawn tables: one naming the symbol, the
18/// underlying price and the expiration date, then one row per strike with
19/// the columns of
20/// [`OPTION_CHAIN_TABLE_HEADERS`](optionstratlib_market::chains::OPTION_CHAIN_TABLE_HEADERS)
21/// and the cells of
22/// [`OptionData::table_cells`](optionstratlib_market::chains::OptionData::table_cells).
23/// In colour, the headers are green, the chain's name magenta, and the rows
24/// of strikes on a multiple of 25 points yellow.
25///
26/// This replaces `OptionChain::show`; `OptionChain`'s own `Display` is the
27/// dependency-free plain-text table.
28pub trait ChainReport {
29    /// The chain's tables as plain text, without ANSI escapes.
30    #[must_use]
31    fn render_table(&self) -> String;
32
33    /// Writes the chain's tables to stdout, coloured when stdout is a
34    /// terminal.
35    ///
36    /// # Errors
37    ///
38    /// Returns [`GraphError::Io`] when stdout cannot be written.
39    fn print_table(&self) -> Result<(), GraphError>;
40}
41
42impl ChainReport for OptionChain {
43    fn render_table(&self) -> String {
44        render(&chain_sections(self))
45    }
46
47    fn print_table(&self) -> Result<(), GraphError> {
48        print(&chain_sections(self))
49    }
50}
51
52/// The header table and the strike table of `chain`.
53#[must_use]
54fn chain_sections(chain: &OptionChain) -> [Section; 2] {
55    let mut header = boxed_table();
56    header.add_row(header_row(&CHAIN_HEADER_COLUMNS, color::GREEN));
57    header.add_row(Row::new(
58        [
59            chain.symbol.clone(),
60            chain.underlying_price.to_string(),
61            chain.get_expiration_date(),
62        ]
63        .iter()
64        .map(|value| Cell::new(value).with_style(Attr::ForegroundColor(color::MAGENTA)))
65        .collect(),
66    ));
67
68    let highlight = Decimal::from(HIGHLIGHTED_STRIKE_MULTIPLE);
69    let mut strikes = boxed_table();
70    strikes.add_row(header_row(&OPTION_CHAIN_TABLE_HEADERS, color::GREEN));
71    for option in &chain.options {
72        let highlighted = option.strike_price.is_multiple_of_dec(highlight);
73        strikes.add_row(Row::new(
74            option
75                .table_cells()
76                .iter()
77                .map(|value| {
78                    let cell = Cell::new(value);
79                    if highlighted {
80                        cell.with_style(Attr::ForegroundColor(color::YELLOW))
81                    } else {
82                        cell
83                    }
84                })
85                .collect(),
86        ));
87    }
88
89    [Section::untitled(header), Section::untitled(strikes)]
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use optionstratlib_core::model::Positive;
96    use optionstratlib_core::pos_or_panic;
97    use optionstratlib_market::chains::OptionData;
98    use rust_decimal_macros::dec;
99
100    fn option(strike: Positive) -> OptionData {
101        OptionData {
102            strike_price: strike,
103            call_bid: Some(pos_or_panic!(5.12345)),
104            call_ask: Some(pos_or_panic!(5.5)),
105            call_middle: Some(pos_or_panic!(5.31)),
106            put_bid: Some(pos_or_panic!(1.2)),
107            put_ask: None,
108            put_middle: None,
109            implied_volatility: pos_or_panic!(0.2),
110            delta_call: Some(dec!(0.55)),
111            delta_put: Some(dec!(-0.45)),
112            gamma: Some(dec!(0.0123)),
113            volume: Some(pos_or_panic!(250.0)),
114            open_interest: Some(1000),
115            ..OptionData::default()
116        }
117    }
118
119    fn chain() -> OptionChain {
120        let mut chain = OptionChain::new(
121            "TEST",
122            Positive::HUNDRED,
123            "2030-01-18".to_string(),
124            None,
125            None,
126        );
127        chain.options.insert(option(pos_or_panic!(95.0)));
128        chain.options.insert(option(Positive::HUNDRED));
129        chain
130    }
131
132    #[test]
133    fn test_terminal_chain_render_table_matches_snapshot() {
134        assert_eq!(
135            chain().render_table(),
136            include_str!("snapshots/chain_table.txt")
137        );
138    }
139
140    #[test]
141    fn test_terminal_chain_render_table_has_no_ansi_escapes() {
142        assert!(!chain().render_table().contains('\u{1b}'));
143    }
144
145    #[test]
146    fn test_terminal_chain_render_table_empty_chain_has_headers_only() {
147        let empty = OptionChain::new("EMPTY", Positive::ONE, "2030-01-18".to_string(), None, None);
148        let rendered = empty.render_table();
149        assert!(rendered.contains("EMPTY"));
150        assert!(rendered.contains("Strike"));
151        assert!(!rendered.contains("1000"));
152    }
153
154    #[test]
155    fn test_terminal_chain_print_table_writes_without_error() {
156        assert!(chain().print_table().is_ok());
157    }
158}