1use ratatui::{
2 buffer::Buffer,
3 layout::Rect,
4 style::{Color, Modifier, Style},
5 widgets::{Block, Borders, Widget},
6};
7
8use crate::model::candle::{Candle, CandleBuilder};
9use crate::model::order::OrderSide;
10
11#[derive(Debug, Clone)]
12pub struct FillMarker {
13 pub candle_index: usize,
14 pub price: f64,
15 pub side: OrderSide,
16}
17
18pub struct PriceChart<'a> {
19 candles: &'a [Candle],
20 current_candle: Option<&'a CandleBuilder>,
21 fill_markers: &'a [FillMarker],
22 fast_sma: Option<f64>,
23 slow_sma: Option<f64>,
24 symbol: &'a str,
25}
26
27impl<'a> PriceChart<'a> {
28 pub fn new(candles: &'a [Candle], symbol: &'a str) -> Self {
29 Self {
30 candles,
31 current_candle: None,
32 fill_markers: &[],
33 fast_sma: None,
34 slow_sma: None,
35 symbol,
36 }
37 }
38
39 pub fn current_candle(mut self, val: Option<&'a CandleBuilder>) -> Self {
40 self.current_candle = val;
41 self
42 }
43
44 pub fn fast_sma(mut self, val: Option<f64>) -> Self {
45 self.fast_sma = val;
46 self
47 }
48
49 pub fn slow_sma(mut self, val: Option<f64>) -> Self {
50 self.slow_sma = val;
51 self
52 }
53
54 pub fn fill_markers(mut self, val: &'a [FillMarker]) -> Self {
55 self.fill_markers = val;
56 self
57 }
58}
59
60impl Widget for PriceChart<'_> {
61 fn render(self, area: Rect, buf: &mut Buffer) {
62 let in_progress: Option<Candle> = self.current_candle.map(|cb| cb.finish());
64 let all_candles: Vec<&Candle> = self.candles.iter().chain(in_progress.as_ref()).collect();
65
66 let title = if let Some(c) = all_candles.last() {
68 format!(" {} | {:.2} ", self.symbol, c.close)
69 } else {
70 format!(" {} | --- ", self.symbol)
71 };
72
73 let block = Block::default()
74 .title(title)
75 .borders(Borders::ALL)
76 .border_style(Style::default().fg(Color::DarkGray));
77 let inner = block.inner(area);
78 block.render(area, buf);
79
80 if all_candles.is_empty() || inner.height < 3 || inner.width < 10 {
81 if inner.height >= 1 && inner.width >= 10 {
82 buf.set_string(
83 inner.x + 1,
84 inner.y,
85 "Waiting for data...",
86 Style::default().fg(Color::DarkGray),
87 );
88 }
89 return;
90 }
91
92 let left_label_width: u16 = 10;
94 let right_label_width: u16 = 12;
95 let chart_x_start = inner.x + left_label_width;
96 let chart_width = inner
97 .width
98 .saturating_sub(left_label_width + right_label_width)
99 as usize;
100 let chart_height = inner.height as usize;
101
102 if chart_width < 2 || chart_height < 2 {
103 return;
104 }
105
106 let visible_start = all_candles.len().saturating_sub(chart_width);
108 let visible: Vec<&Candle> = if all_candles.len() > chart_width {
109 all_candles[visible_start..].to_vec()
110 } else {
111 all_candles.clone()
112 };
113
114 let data_min = visible.iter().map(|c| c.low).fold(f64::INFINITY, f64::min);
116 let data_max = visible
117 .iter()
118 .map(|c| c.high)
119 .fold(f64::NEG_INFINITY, f64::max);
120 let data_range = data_max - data_min;
121 let data_range = if data_range < 0.01 { 1.0 } else { data_range };
122
123 let current_price = visible.last().map(|c| c.close).unwrap_or(data_min);
125 let half_range = data_range / 2.0 * 1.1; let min_price = current_price - half_range;
127 let max_price = current_price + half_range;
128 let range = max_price - min_price;
129
130 let price_to_row = |price: f64| -> u16 {
132 let normalized = ((price - min_price) / range).clamp(0.0, 1.0);
133 let row = ((1.0 - normalized) * (chart_height - 1) as f64).round() as u16;
134 row.min(inner.height - 1)
135 };
136
137 let mid_price = (max_price + min_price) / 2.0;
139 buf.set_string(
140 inner.x,
141 inner.y,
142 &format!("{:>9.1}", max_price),
143 Style::default().fg(Color::DarkGray),
144 );
145 if chart_height > 2 {
146 buf.set_string(
147 inner.x,
148 inner.y + (chart_height / 2) as u16,
149 &format!("{:>9.1}", mid_price),
150 Style::default().fg(Color::DarkGray),
151 );
152 }
153 buf.set_string(
154 inner.x,
155 inner.y + (chart_height - 1) as u16,
156 &format!("{:>9.1}", min_price),
157 Style::default().fg(Color::DarkGray),
158 );
159
160 let chart_x_end = chart_x_start + chart_width as u16;
162 for (i, candle) in visible.iter().enumerate() {
163 let x = chart_x_start + i as u16;
164 if x >= chart_x_end {
165 break;
166 }
167
168 let color = if candle.is_bullish() {
169 Color::Green
170 } else {
171 Color::Red
172 };
173
174 let high_row = price_to_row(candle.high);
175 let low_row = price_to_row(candle.low);
176
177 let body_top_price = candle.open.max(candle.close);
178 let body_bot_price = candle.open.min(candle.close);
179 let body_top_row = price_to_row(body_top_price);
180 let body_bot_row = price_to_row(body_bot_price);
181
182 for row in high_row..=low_row {
184 let y = inner.y + row;
185 buf.set_string(x, y, "│", Style::default().fg(Color::DarkGray));
186 }
187
188 if body_top_row == body_bot_row {
190 let y = inner.y + body_top_row;
191 buf.set_string(x, y, "─", Style::default().fg(color));
192 } else {
193 for row in body_top_row..=body_bot_row {
194 let y = inner.y + row;
195 buf.set_string(x, y, "█", Style::default().fg(color));
196 }
197 }
198 }
199
200 for marker in self.fill_markers {
202 if marker.candle_index < visible_start
203 || marker.candle_index >= visible_start + visible.len()
204 {
205 continue;
206 }
207 if marker.price < min_price || marker.price > max_price {
208 continue;
209 }
210
211 let x = chart_x_start + (marker.candle_index - visible_start) as u16;
212 if x >= chart_x_end {
213 continue;
214 }
215 let y = inner.y + price_to_row(marker.price);
216 let (side_ch, color) = match marker.side {
217 OrderSide::Buy => ('B', Color::Green),
218 OrderSide::Sell => ('S', Color::Red),
219 };
220 buf.set_string(
221 x,
222 y,
223 side_ch.to_string(),
224 Style::default().fg(color).add_modifier(Modifier::BOLD),
225 );
226 }
227
228 let price_row = price_to_row(current_price);
230 let right_x = chart_x_end;
231 if right_x + right_label_width <= inner.x + inner.width {
232 let price_label = format!("▶{:.1}", current_price);
233 let price_color = if let Some(c) = visible.last() {
234 if c.is_bullish() {
235 Color::Green
236 } else {
237 Color::Red
238 }
239 } else {
240 Color::White
241 };
242 buf.set_string(
243 right_x,
244 inner.y + price_row,
245 &price_label,
246 Style::default()
247 .fg(Color::Black)
248 .bg(price_color)
249 .add_modifier(Modifier::BOLD),
250 );
251 }
252
253 let sma_x = chart_x_start + visible.len().min(chart_width).saturating_sub(1) as u16;
255 if sma_x < chart_x_end {
256 if let Some(fast) = self.fast_sma {
257 if fast >= min_price && fast <= max_price {
258 let y = inner.y + price_to_row(fast);
259 buf.set_string(
260 sma_x,
261 y,
262 "F",
263 Style::default()
264 .fg(Color::Green)
265 .add_modifier(Modifier::BOLD),
266 );
267 }
268 }
269 if let Some(slow) = self.slow_sma {
270 if slow >= min_price && slow <= max_price {
271 let y = inner.y + price_to_row(slow);
272 buf.set_string(
273 sma_x,
274 y,
275 "S",
276 Style::default()
277 .fg(Color::Yellow)
278 .add_modifier(Modifier::BOLD),
279 );
280 }
281 }
282 }
283 }
284}