Skip to main content

sandbox_quant/ui/
chart.rs

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        // Build visible candle list (finalized + in-progress)
63        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        // Build title with current price
67        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        // Reserve 10 chars on left for Y-axis labels, 12 on right for current price label
93        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        // Take the last `chart_width` candles
107        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        // Find data min/max from visible candle wicks
115        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        // Center Y-axis on current price
124        let current_price = visible.last().map(|c| c.close).unwrap_or(data_min);
125        let half_range = data_range / 2.0 * 1.1; // 10% padding
126        let min_price = current_price - half_range;
127        let max_price = current_price + half_range;
128        let range = max_price - min_price;
129
130        // Helper: convert price to row index (0 = top, chart_height-1 = bottom)
131        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        // Draw Y-axis labels (top, middle, bottom)
138        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        // Draw each candlestick
161        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            // Draw wick (from high to low)
183            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            // Draw body (from body_top to body_bot), overwrites wick
189            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        // Draw fill markers (BUY/SELL) on top of corresponding candles
201        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        // Draw current price label on the right side
229        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        // Draw SMA markers on the rightmost candle column
254        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}