optionstratlib_visualization/visualization/terminal/
chain.rs1use 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
9const HIGHLIGHTED_STRIKE_MULTIPLE: u32 = 25;
11
12const CHAIN_HEADER_COLUMNS: [&str; 3] = ["Symbol", "Underlying Price", "Expiration Date"];
14
15pub trait ChainReport {
29 #[must_use]
31 fn render_table(&self) -> String;
32
33 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#[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}