Преглед на файлове

Footprint chart (#5)

* add crosshair tooltip, +adjs. on scale calc. logic

* add milliseconds to the crosshair tooltip time format

* exclude env conf

* add ticksize and timeframe controls for footprint chart

* ticksize&timeframe change support

* add raw trades getter from heatmap chart

* merged license with master
Berke преди 1 година
родител
ревизия
196cf7fe2b
променени са 5 файла, в които са добавени 1174 реда и са изтрити 40 реда
  1. 2 1
      .gitignore
  2. 2 1
      src/charts.rs
  3. 1023 0
      src/charts/footprint.rs
  4. 16 7
      src/charts/heatmap.rs
  5. 131 31
      src/main.rs

+ 2 - 1
.gitignore

@@ -1,3 +1,4 @@
 .DS_Store
 /target
-/.vscode
+/.vscode
+/.cargo

+ 2 - 1
src/charts.rs

@@ -1,2 +1,3 @@
 pub mod custom_line;
-pub mod heatmap;
+pub mod heatmap;
+pub mod footprint;

+ 1023 - 0
src/charts/footprint.rs

@@ -0,0 +1,1023 @@
+use std::collections::{BTreeMap, HashMap};
+use chrono::NaiveDateTime;
+use iced::{
+    alignment, color, mouse, widget::{button, canvas::{self, event::{self, Event}, stroke::Stroke, Cache, Canvas, Geometry, Path}}, window, Border, Color, Element, Length, Point, Rectangle, Renderer, Size, Theme, Vector
+};
+use iced::widget::{Column, Row, Container, Text};
+use crate::data_providers::binance::market_data::{Kline, Trade};
+
+#[derive(Debug, Clone)]
+pub enum Message {
+    Translated(Vector),
+    Scaled(f32, Option<Vector>),
+    ChartBounds(Rectangle),
+    AutoscaleToggle,
+    CrosshairToggle,
+    CrosshairMoved(Point),
+}
+
+#[derive(Debug)]
+pub struct Footprint {
+    heatmap_cache: Cache,
+    crosshair_cache: Cache,
+    x_labels_cache: Cache,
+    y_labels_cache: Cache,
+    y_croshair_cache: Cache,
+    x_crosshair_cache: Cache,
+    translation: Vector,
+    scaling: f32,
+    
+    data_points: BTreeMap<i64, (HashMap<i64, (f32, f32)>, (f32, f32, f32, f32, f32, f32))>,
+
+    autoscale: bool,
+    crosshair: bool,
+    crosshair_position: Point,
+    x_min_time: i64,
+    x_max_time: i64,
+    y_min_price: f32,
+    y_max_price: f32,
+    bounds: Rectangle,
+
+    timeframe: u16,
+    tick_size: f32,
+}
+impl Footprint {
+    const MIN_SCALING: f32 = 0.4;
+    const MAX_SCALING: f32 = 3.6;
+
+    pub fn new(timeframe: u16, tick_size: f32, klines_raw: Vec<(i64, f32, f32, f32, f32, f32, f32)>, trades_raw: Vec<Trade>) -> Footprint {
+        let _size = window::Settings::default().size;
+
+        let mut data_points = BTreeMap::new();
+        let aggregate_time = 1000 * 60 * timeframe as i64;
+
+        for kline in klines_raw {
+            let kline_raw = (kline.1, kline.2, kline.3, kline.4, kline.5, kline.6);
+            data_points.entry(kline.0).or_insert((HashMap::new(), kline_raw));
+        }
+        for trade in trades_raw {
+            let rounded_time = (trade.time / aggregate_time) * aggregate_time;
+            let price_level = (trade.price / tick_size).round() as i64 * (tick_size * 100.0) as i64;
+
+            let entry: &mut (HashMap<i64, (f32, f32)>, (f32, f32, f32, f32, f32, f32)) = data_points
+                .entry(rounded_time)
+                .or_insert((HashMap::new(), (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)));
+
+            if let Some((buy_qty, sell_qty)) = entry.0.get_mut(&price_level) {
+                if trade.is_sell {
+                    *sell_qty += trade.qty;
+                } else {
+                    *buy_qty += trade.qty;
+                }
+            } else {
+                if trade.is_sell {
+                    entry.0.insert(price_level, (0.0, trade.qty));
+                } else {
+                    entry.0.insert(price_level, (trade.qty, 0.0));
+                }
+            }
+        }
+    
+        Footprint {
+            bounds: Rectangle::default(),
+                
+            heatmap_cache: canvas::Cache::default(),
+            crosshair_cache: canvas::Cache::default(),
+            x_labels_cache: canvas::Cache::default(),
+            y_labels_cache: canvas::Cache::default(),
+            y_croshair_cache: canvas::Cache::default(),
+            x_crosshair_cache: canvas::Cache::default(),
+
+            translation: Vector::default(),
+            scaling: 1.0,
+            autoscale: true,
+            
+            crosshair: false,
+            crosshair_position: Point::new(0.0, 0.0),
+
+            x_min_time: 0,
+            x_max_time: 0,
+            y_min_price: 0.0,
+            y_max_price: 0.0,
+            
+            timeframe,
+            tick_size,
+            data_points,
+        }
+    }
+
+    pub fn insert_datapoint(&mut self, mut trades_buffer: Vec<Trade>, depth_update: i64) {
+        let aggregate_time = 1000 * 60 * self.timeframe as i64;
+        let rounded_depth_update = (depth_update / aggregate_time) * aggregate_time;
+    
+        self.data_points.entry(rounded_depth_update).or_insert((HashMap::new(), (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)));
+        
+        for trade in trades_buffer.drain(..) {
+            let price_level = (trade.price / self.tick_size).round() as i64 * (self.tick_size * 100.0) as i64;
+            if let Some((trades, _)) = self.data_points.get_mut(&rounded_depth_update) {     
+                if let Some((buy_qty, sell_qty)) = trades.get_mut(&price_level) {
+                    if trade.is_sell {
+                        *sell_qty += trade.qty;
+                    } else {
+                        *buy_qty += trade.qty;
+                    }
+                } else {
+                    if trade.is_sell {
+                        trades.insert(price_level, (0.0, trade.qty));
+                    } else {
+                        trades.insert(price_level, (trade.qty, 0.0));
+                    }
+                }
+            }
+        }
+    
+        self.render_start();
+    }
+
+    pub fn change_tick_size(&mut self, trades_raw: Vec<Trade>, new_tick_size: f32) {
+        let mut new_data_points = BTreeMap::new();
+        let aggregate_time = 1000 * 60 * self.timeframe as i64;
+
+        for (time, (_, kline_values)) in self.data_points.iter() {
+            new_data_points.entry(*time).or_insert((HashMap::new(), *kline_values));
+        }
+
+        for trade in trades_raw {
+            let rounded_time = (trade.time / aggregate_time) * aggregate_time;
+            let price_level = (trade.price / new_tick_size).round() as i64 * (new_tick_size * 100.0) as i64;
+
+            let entry = new_data_points
+                .entry(rounded_time)
+                .or_insert((HashMap::new(), (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)));
+
+            if let Some((buy_qty, sell_qty)) = entry.0.get_mut(&price_level) {
+                if trade.is_sell {
+                    *sell_qty += trade.qty;
+                } else {
+                    *buy_qty += trade.qty;
+                }
+            } else {
+                if trade.is_sell {
+                    entry.0.insert(price_level, (0.0, trade.qty));
+                } else {
+                    entry.0.insert(price_level, (trade.qty, 0.0));
+                }
+            }
+        }
+    
+        self.data_points = new_data_points;
+        self.tick_size = new_tick_size;
+    }
+
+    pub fn update_latest_kline(&mut self, kline: Kline) {
+        if let Some((_, kline_value)) = self.data_points.get_mut(&(kline.time as i64)) {
+            kline_value.0 = kline.open;
+            kline_value.1 = kline.high;
+            kline_value.2 = kline.low;
+            kline_value.3 = kline.close;
+            kline_value.4 = kline.taker_buy_base_asset_volume;
+            kline_value.5 = kline.volume - kline.taker_buy_base_asset_volume;
+        }
+    }
+    
+    pub fn render_start(&mut self) {
+        let timestamp_latest = self.data_points.keys().last().unwrap_or(&0);
+
+        let latest: i64 = *timestamp_latest as i64 - ((self.translation.x*1000.0)*(self.timeframe as f32)) as i64;
+        let earliest: i64 = latest - ((640000.0*self.timeframe as f32) / (self.scaling / (self.bounds.width/800.0))) as i64;
+    
+        let mut highest: f32 = 0.0;
+        let mut lowest: f32 = std::f32::MAX;
+    
+        for (_, (_, kline)) in self.data_points.range(earliest..=latest) {
+            if kline.1 > highest {
+                highest = kline.1;
+            }
+            if kline.2 < lowest {
+                lowest = kline.2;
+            }
+        }
+        if highest == 0.0 || lowest == std::f32::MAX || lowest == 0.0 {
+            return;
+        }
+        highest = highest + (highest - lowest) * 0.05;
+        lowest = lowest - (highest - lowest) * 0.05;
+    
+        if earliest != self.x_min_time || latest != self.x_max_time {            
+            self.x_labels_cache.clear();
+            self.x_crosshair_cache.clear();
+        }
+        if lowest != self.y_min_price || highest != self.y_max_price {            
+            self.y_labels_cache.clear();
+            self.y_croshair_cache.clear();
+        }
+    
+        self.x_min_time = earliest;
+        self.x_max_time = latest;
+        self.y_min_price = lowest;
+        self.y_max_price = highest;
+        
+        self.crosshair_cache.clear();   
+
+        self.heatmap_cache.clear();     
+    }
+
+    pub fn update(&mut self, message: Message) {
+        match message {
+            Message::Translated(translation) => {
+                if self.autoscale {
+                    self.translation.x = translation.x;
+                } else {
+                    self.translation = translation;
+                }
+                self.crosshair_position = Point::new(0.0, 0.0);
+
+                self.render_start();
+            }
+            Message::Scaled(scaling, translation) => {
+                self.scaling = scaling;
+                
+                if let Some(translation) = translation {
+                    if self.autoscale {
+                        self.translation.x = translation.x;
+                    } else {
+                        self.translation = translation;
+                    }
+                }
+                self.crosshair_position = Point::new(0.0, 0.0);
+
+                self.render_start();
+            }
+            Message::ChartBounds(bounds) => {
+                self.bounds = bounds;
+            }
+            Message::AutoscaleToggle => {
+                self.autoscale = !self.autoscale;
+            }
+            Message::CrosshairToggle => {
+                self.crosshair = !self.crosshair;
+            }
+            Message::CrosshairMoved(position) => {
+                self.crosshair_position = position;
+                if self.crosshair {
+                    self.crosshair_cache.clear();
+                    self.y_croshair_cache.clear();
+                    self.x_crosshair_cache.clear();
+                }
+            }
+        }
+    }
+
+    pub fn view(&self) -> Element<Message> {
+        let chart = Canvas::new(self)
+            .width(Length::FillPortion(10))
+            .height(Length::FillPortion(10));
+
+        let axis_labels_x = Canvas::new(
+            AxisLabelXCanvas { 
+                labels_cache: &self.x_labels_cache, 
+                min: self.x_min_time, 
+                max: self.x_max_time, 
+                crosshair_cache: &self.x_crosshair_cache, 
+                crosshair_position: self.crosshair_position, 
+                crosshair: self.crosshair,
+                timeframe: self.timeframe
+            })
+            .width(Length::FillPortion(10))
+            .height(Length::Fixed(26.0));
+
+        let axis_labels_y = Canvas::new(
+            AxisLabelYCanvas { 
+                labels_cache: &self.y_labels_cache, 
+                y_croshair_cache: &self.y_croshair_cache, 
+                min: self.y_min_price,
+                max: self.y_max_price,
+                crosshair_position: self.crosshair_position, 
+                crosshair: self.crosshair
+            })
+            .width(Length::Fixed(60.0))
+            .height(Length::FillPortion(10));
+
+        let autoscale_button = button(
+            Text::new("A")
+                .size(12)
+                .horizontal_alignment(alignment::Horizontal::Center)
+            )
+            .width(Length::Fill)
+            .height(Length::Fill)
+            .on_press(Message::AutoscaleToggle)
+            .style(|_theme: &Theme, _status: iced::widget::button::Status| chart_button(_theme, &_status, self.autoscale));
+        let crosshair_button = button(
+            Text::new("+")
+                .size(12)
+                .horizontal_alignment(alignment::Horizontal::Center)
+            ) 
+            .width(Length::Fill)
+            .height(Length::Fill)
+            .on_press(Message::CrosshairToggle)
+            .style(|_theme: &Theme, _status: iced::widget::button::Status| chart_button(_theme, &_status, self.crosshair));
+    
+        let chart_controls = Container::new(
+            Row::new()
+                .push(autoscale_button)
+                .push(crosshair_button).spacing(2)
+            ).padding([0, 2, 0, 2])
+            .width(Length::Fixed(60.0))
+            .height(Length::Fixed(26.0));
+
+        let chart_and_y_labels = Row::new()
+            .push(chart)
+            .push(axis_labels_y);
+    
+        let bottom_row = Row::new()
+            .push(axis_labels_x)
+            .push(chart_controls);
+    
+        let content = Column::new()
+            .push(chart_and_y_labels)
+            .push(bottom_row)
+            .spacing(0)
+            .padding(5);
+    
+        content.into()
+    }
+}
+
+fn chart_button(_theme: &Theme, _status: &button::Status, is_active: bool) -> button::Style {
+    button::Style {
+        background: Some(Color::from_rgba8(20, 20, 20, 1.0).into()),
+        border: Border {
+            color: {
+                if is_active {
+                    Color::from_rgba8(50, 50, 50, 1.0)
+                } else {
+                    Color::from_rgba8(20, 20, 20, 1.0)
+                }
+            },
+            width: 1.0,
+            radius: 2.0.into(),
+        },
+        text_color: Color::WHITE,
+        ..button::Style::default()
+    }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub enum Interaction {
+    None,
+    Drawing,
+    Erasing,
+    Panning { translation: Vector, start: Point },
+}
+
+impl Default for Interaction {
+    fn default() -> Self {
+        Self::None
+    }
+}
+impl canvas::Program<Message> for Footprint {
+    type State = Interaction;
+
+    fn update(
+        &self,
+        interaction: &mut Interaction,
+        event: Event,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> (event::Status, Option<Message>) {        
+        if bounds != self.bounds {
+            return (event::Status::Ignored, Some(Message::ChartBounds(bounds)));
+        } 
+        
+        if let Event::Mouse(mouse::Event::ButtonReleased(_)) = event {
+            *interaction = Interaction::None;
+        }
+
+        let Some(cursor_position) = cursor.position_in(bounds) else {
+            return (event::Status::Ignored, 
+                if self.crosshair {
+                    Some(Message::CrosshairMoved(Point::new(0.0, 0.0)))
+                } else {
+                    None
+                }
+                );
+        };
+
+        match event {
+            Event::Mouse(mouse_event) => match mouse_event {
+                mouse::Event::ButtonPressed(button) => {
+                    let message = match button {
+                        mouse::Button::Right => {
+                            *interaction = Interaction::Drawing;
+                            None
+                        }
+                        mouse::Button::Left => {
+                            *interaction = Interaction::Panning {
+                                translation: self.translation,
+                                start: cursor_position,
+                            };
+                            None
+                        }
+                        _ => None,
+                    };
+
+                    (event::Status::Captured, message)
+                }
+                mouse::Event::CursorMoved { .. } => {
+                    let message = match *interaction {
+                        Interaction::Drawing => None,
+                        Interaction::Erasing => None,
+                        Interaction::Panning { translation, start } => {
+                            Some(Message::Translated(
+                                translation
+                                    + (cursor_position - start)
+                                        * (1.0 / self.scaling),
+                            ))
+                        }
+                        Interaction::None => 
+                            if self.crosshair && cursor.is_over(bounds) {
+                                Some(Message::CrosshairMoved(cursor_position))
+                            } else {
+                                None
+                            },
+                    };
+
+                    let event_status = match interaction {
+                        Interaction::None => event::Status::Ignored,
+                        _ => event::Status::Captured,
+                    };
+
+                    (event_status, message)
+                }
+                mouse::Event::WheelScrolled { delta } => match delta {
+                    mouse::ScrollDelta::Lines { y, .. }
+                    | mouse::ScrollDelta::Pixels { y, .. } => {
+                        if y < 0.0 && self.scaling > Self::MIN_SCALING
+                            || y > 0.0 && self.scaling < Self::MAX_SCALING
+                        {
+                            //let old_scaling = self.scaling;
+
+                            let scaling = (self.scaling * (1.0 + y / 30.0))
+                                .clamp(
+                                    Self::MIN_SCALING,  // 0.1
+                                    Self::MAX_SCALING,  // 2.0
+                                );
+
+                            //let translation =
+                            //    if let Some(cursor_to_center) =
+                            //        cursor.position_from(bounds.center())
+                            //    {
+                            //        let factor = scaling - old_scaling;
+
+                            //        Some(
+                            //            self.translation
+                            //                - Vector::new(
+                            //                    cursor_to_center.x * factor
+                            //                        / (old_scaling
+                            //                            * old_scaling),
+                            //                    cursor_to_center.y * factor
+                            //                        / (old_scaling
+                            //                            * old_scaling),
+                            //                ),
+                            //        )
+                            //    } else {
+                            //        None
+                            //    };
+
+                            (
+                                event::Status::Captured,
+                                Some(Message::Scaled(scaling, None)),
+                            )
+                        } else {
+                            (event::Status::Captured, None)
+                        }
+                    }
+                },
+                _ => (event::Status::Ignored, None),
+            },
+            _ => (event::Status::Ignored, None),
+        }
+    }
+    
+    fn draw(
+        &self,
+        _state: &Self::State,
+        renderer: &Renderer,
+        _theme: &Theme,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> Vec<Geometry> {    
+        let (latest, earliest) = (self.x_max_time, self.x_min_time);    
+        let (lowest, highest) = (self.y_min_price, self.y_max_price);
+
+        let y_range: f32 = highest - lowest;
+
+        if y_range == 0.0 {
+            return vec![];
+        }
+
+        let volume_area_height: f32 = bounds.height / 8.0; 
+        let heatmap_area_height: f32 = bounds.height - volume_area_height;
+
+        let heatmap = self.heatmap_cache.draw(renderer, bounds.size(), |frame| {
+            let mut max_trade_qty: f32 = 0.0;
+            let mut max_volume: f32 = 0.0;
+
+            for (_, (trades, kline)) in self.data_points.range(earliest..=latest) {
+                for trade in trades {            
+                    max_trade_qty = max_trade_qty.max(trade.1.0.max(trade.1.1));
+                }
+                max_volume = max_volume.max(kline.4.max(kline.5));
+            }
+            
+            for (time, (trades, kline)) in self.data_points.range(earliest..=latest) {
+                let x_position: f32 = ((time - earliest) as f32 / (latest - earliest) as f32) * bounds.width as f32;
+
+                if x_position.is_nan() || x_position.is_infinite() {
+                    continue;
+                }
+
+                for trade in trades {
+                    let price = *trade.0 as f32 / 100.0;
+                    let y_position = heatmap_area_height - ((price - lowest) / y_range * heatmap_area_height);
+
+                    if trade.1.0 > 0.0 {
+                        let bar_width = (trade.1.0 / max_trade_qty) * bounds.width / 30.0 * self.scaling;
+                        let bar = Path::rectangle(
+                            Point::new(x_position + (5.0*self.scaling), y_position), 
+                            Size::new(bar_width, 1.0) 
+                        );
+                        frame.fill(&bar, Color::from_rgba8(81, 205, 160, 1.0));
+                    } 
+                    if trade.1.1 > 0.0 {
+                        let bar_width = -(trade.1.1 / max_trade_qty) * bounds.width / 30.0 * self.scaling;
+                        let bar = Path::rectangle(
+                            Point::new(x_position - (5.0*self.scaling), y_position), 
+                            Size::new(bar_width, 1.0) 
+                        );
+                        frame.fill(&bar, Color::from_rgba8(192, 80, 77, 1.0));
+                    };  
+                }
+
+                if max_volume > 0.0 {
+                    let buy_bar_height = (kline.4 / max_volume) * volume_area_height;
+                    let sell_bar_height = (kline.5 / max_volume) * volume_area_height;
+
+                    let sell_bar_width = 8.0 * self.scaling;
+                    let sell_bar_x_position = x_position - (5.0*self.scaling) - sell_bar_width;
+                    let sell_bar = Path::rectangle(
+                        Point::new(sell_bar_x_position, (bounds.height - sell_bar_height) as f32), 
+                        Size::new(sell_bar_width, sell_bar_height as f32)
+                    );
+                    frame.fill(&sell_bar, Color::from_rgb8(192, 80, 77)); 
+
+                    let buy_bar = Path::rectangle(
+                        Point::new(x_position + (5.0*self.scaling), (bounds.height - buy_bar_height) as f32), 
+                        Size::new(8.0 * self.scaling, buy_bar_height as f32)
+                    );
+                    frame.fill(&buy_bar, Color::from_rgb8(81, 205, 160));
+                }
+                
+                let y_open = heatmap_area_height - ((kline.0 - lowest) / y_range * heatmap_area_height);
+                let y_high = heatmap_area_height - ((kline.1 - lowest) / y_range * heatmap_area_height);
+                let y_low = heatmap_area_height - ((kline.2 - lowest) / y_range * heatmap_area_height);
+                let y_close = heatmap_area_height - ((kline.3 - lowest) / y_range * heatmap_area_height);
+                
+                let color = if kline.3 >= kline.0 { Color::from_rgba8(81, 205, 160, 0.6) } else { Color::from_rgba8(192, 80, 77, 0.6) };
+
+                let wick = Path::line(
+                    Point::new(x_position as f32, y_high), 
+                    Point::new(x_position as f32, y_low)
+                );
+                frame.stroke(&wick, Stroke::default().with_color(color).with_width(1.0));
+
+                let body = Path::rectangle(
+                    Point::new(x_position as f32 - (2.0 * self.scaling), y_open.min(y_close)), 
+                    Size::new(4.0 * self.scaling, (y_open - y_close).abs())
+                );                    
+                frame.fill(&body, color);
+            } 
+            
+            let text_size = 9.0;
+            let text_content = format!("{:.2}", max_volume);
+            let text_width = (text_content.len() as f32 * text_size) / 1.5;
+
+            let text_position = Point::new(bounds.width - text_width, bounds.height - volume_area_height);
+            
+            frame.fill_text(canvas::Text {
+                content: text_content,
+                position: text_position,
+                size: iced::Pixels(text_size),
+                color: Color::from_rgba8(81, 81, 81, 1.0),
+                ..canvas::Text::default()
+            });
+        });
+
+        if self.crosshair {
+            let crosshair = self.crosshair_cache.draw(renderer, bounds.size(), |frame| {
+                if let Some(cursor_position) = cursor.position_in(bounds) {
+                    let line = Path::line(
+                        Point::new(0.0, cursor_position.y), 
+                        Point::new(bounds.width as f32, cursor_position.y)
+                    );
+                    frame.stroke(&line, Stroke::default().with_color(Color::from_rgba8(200, 200, 200, 0.6)).with_width(1.0));
+
+                    let crosshair_ratio = cursor_position.x as f64 / bounds.width as f64;
+                    let crosshair_millis = earliest as f64 + crosshair_ratio * (latest - earliest) as f64;
+                    let rounded_timestamp = (crosshair_millis / (self.timeframe as f64 * 60.0 * 1000.0)).round() as i64 * self.timeframe as i64 * 60 * 1000;
+
+                    let snap_ratio = (rounded_timestamp as f64 - earliest as f64) / (latest as f64 - earliest as f64);
+                    let snap_x = snap_ratio * bounds.width as f64;
+
+                    let line = Path::line(
+                        Point::new(snap_x as f32, 0.0), 
+                        Point::new(snap_x as f32, bounds.height as f32)
+                    );
+                    frame.stroke(&line, Stroke::default().with_color(Color::from_rgba8(200, 200, 200, 0.6)).with_width(1.0));
+
+                    if let Some((_, kline)) = self.data_points.iter()
+                        .find(|(time, _)| **time == rounded_timestamp) {
+                            let tooltip_text = format!(
+                                "O: {} H: {} L: {} C: {}\nBuyV: {:.0} SellV: {:.0}",
+                                kline.1.0, kline.1.1, kline.1.2, kline.1.3, kline.1.4, kline.1.5
+                            );
+
+                            let text = canvas::Text {
+                                content: tooltip_text,
+                                position: Point::new(10.0, 10.0),
+                                size: iced::Pixels(12.0),
+                                color: Color::from_rgba8(120, 120, 120, 1.0),
+                                ..canvas::Text::default()
+                            };
+                            frame.fill_text(text);
+                    }
+                }
+            });
+
+            return vec![crosshair, heatmap];
+        }   else {
+            return vec![heatmap];
+        }
+    }
+
+    fn mouse_interaction(
+        &self,
+        interaction: &Interaction,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> mouse::Interaction {
+        match interaction {
+            Interaction::Drawing => mouse::Interaction::Crosshair,
+            Interaction::Erasing => mouse::Interaction::Crosshair,
+            Interaction::Panning { .. } => mouse::Interaction::Grabbing,
+            Interaction::None if cursor.is_over(bounds) => {
+                if self.crosshair {
+                    mouse::Interaction::Crosshair
+                } else {
+                    mouse::Interaction::default()
+                }
+            }
+            Interaction::None => { mouse::Interaction::default() }
+        }
+    }
+}
+
+const PRICE_STEPS: [f32; 15] = [
+    1000.0,
+    500.0,
+    200.0,
+    100.0,
+    50.0,
+    20.0,
+    10.0,
+    5.0,
+    2.0,
+    1.0,
+    0.5,
+    0.2,
+    0.1,
+    0.05,
+    0.01,
+];
+fn calculate_price_step(highest: f32, lowest: f32, labels_can_fit: i32) -> (f32, f32) {
+    let range = highest - lowest;
+    let mut step = 1000.0; 
+
+    for &s in PRICE_STEPS.iter().rev() {
+        if range / s <= labels_can_fit as f32 {
+            step = s;
+            break;
+        }
+    }
+    let rounded_lowest = (lowest / step).floor() * step;
+
+    (step, rounded_lowest)
+}
+
+const M1_TIME_STEPS: [i64; 5] = [
+    1000 * 60 * 30, // 30 minutes
+    1000 * 60 * 15, // 15 minutes
+    1000 * 60 * 5, // 5 minutes
+    1000 * 60 * 2, // 2 minutes
+    60 * 1000, // 1 minute
+];
+const M3_TIME_STEPS: [i64; 5] = [
+    1000 * 60 * 60, // 1 hour
+    1000 * 60 * 30, // 30 minutes
+    1000 * 60 * 15, // 15 minutes
+    1000 * 60 * 9, // 9 minutes
+    1000 * 60 * 3, // 3 minutes
+];
+const M5_TIME_STEPS: [i64; 5] = [
+    1000 * 60 * 60, // 1 hour
+    1000 * 60 * 30, // 30 minutes
+    1000 * 60 * 15, // 15 minutes
+    1000 * 60 * 5, // 5 minutes
+    1000 * 60 * 2, // 2 minutes
+];
+const M15_TIME_STEPS: [i64; 5] = [
+    1000 * 60 * 240, // 4 hour
+    1000 * 60 * 120, // 2 hour
+    1000 * 60 * 60, // 1 hour
+    1000 * 60 * 30, // 30 minutes
+    1000 * 60 * 15, // 15 minutes
+];
+const M30_TIME_STEPS: [i64; 5] = [
+    1000 * 60 * 480, // 8 hour
+    1000 * 60 * 240, // 4 hour
+    1000 * 60 * 120, // 2 hour
+    1000 * 60 * 60, // 1 hour
+    1000 * 60 * 30, // 30 minutes
+];
+
+fn calculate_time_step(earliest: i64, latest: i64, labels_can_fit: i32, timeframe: u16) -> (i64, i64) {
+    let duration = latest - earliest;
+
+    let time_steps = match timeframe {
+        1 => &M1_TIME_STEPS,
+        3 => &M3_TIME_STEPS,
+        5 => &M5_TIME_STEPS,
+        15 => &M15_TIME_STEPS,
+        30 => &M30_TIME_STEPS,
+        _ => &M1_TIME_STEPS,
+    };
+
+    let mut selected_step = time_steps[0];
+    for &step in time_steps.iter() {
+        if duration / step >= labels_can_fit as i64 {
+            selected_step = step;
+            break;
+        }
+        if step <= duration {
+            selected_step = step;
+        }
+    }
+
+    let rounded_earliest = (earliest / selected_step) * selected_step;
+
+    (selected_step, rounded_earliest)
+}
+pub struct AxisLabelXCanvas<'a> {
+    labels_cache: &'a Cache,
+    crosshair_cache: &'a Cache,
+    crosshair_position: Point,
+    crosshair: bool,
+    min: i64,
+    max: i64,
+    timeframe: u16,
+}
+impl canvas::Program<Message> for AxisLabelXCanvas<'_> {
+    type State = Interaction;
+
+    fn update(
+        &self,
+        _interaction: &mut Interaction,
+        _event: Event,
+        _bounds: Rectangle,
+        _cursor: mouse::Cursor,
+    ) -> (event::Status, Option<Message>) {
+        (event::Status::Ignored, None)
+    }
+    
+    fn draw(
+        &self,
+        _state: &Self::State,
+        renderer: &Renderer,
+        _theme: &Theme,
+        bounds: Rectangle,
+        _cursor: mouse::Cursor,
+    ) -> Vec<Geometry> {
+        if self.max == 0 {
+            return vec![];
+        }
+        let latest_in_millis = self.max; 
+        let earliest_in_millis = self.min; 
+
+        let x_labels_can_fit = (bounds.width / 120.0) as i32;
+        let (time_step, rounded_earliest) = calculate_time_step(self.min, self.max, x_labels_can_fit, self.timeframe);
+
+        let labels = self.labels_cache.draw(renderer, bounds.size(), |frame| {
+            frame.with_save(|frame| {
+                let mut time: i64 = rounded_earliest;
+                let latest_time: i64 = latest_in_millis;
+
+                while time <= latest_time {                    
+                    let x_position = ((time as i64 - earliest_in_millis as i64) as f64 / (latest_in_millis - earliest_in_millis) as f64) * bounds.width as f64;
+
+                    if x_position >= 0.0 && x_position <= bounds.width as f64 {
+                        let text_size = 12.0;
+                        let time_as_datetime = NaiveDateTime::from_timestamp((time / 1000) as i64, 0);
+                        let label = canvas::Text {
+                            content: time_as_datetime.format("%H:%M").to_string(),
+                            position: Point::new(x_position as f32 - text_size, bounds.height as f32 - 20.0),
+                            size: iced::Pixels(text_size),
+                            color: Color::from_rgba8(200, 200, 200, 1.0),
+                            ..canvas::Text::default()
+                        };  
+
+                        label.draw_with(|path, color| {
+                            frame.fill(&path, color);
+                        });
+                    }
+                    
+                    time = time + time_step;
+                }
+
+                let line = Path::line(
+                    Point::new(0.0, bounds.height as f32 - 30.0), 
+                    Point::new(bounds.width as f32, bounds.height as f32 - 30.0)
+                );
+                frame.stroke(&line, Stroke::default().with_color(Color::from_rgba8(81, 81, 81, 0.2)).with_width(1.0));
+            });
+        });
+        let crosshair = self.crosshair_cache.draw(renderer, bounds.size(), |frame| {
+            if self.crosshair && self.crosshair_position.x > 0.0 {
+                let crosshair_ratio = self.crosshair_position.x as f64 / bounds.width as f64;
+                let crosshair_millis = earliest_in_millis as f64 + crosshair_ratio * (latest_in_millis - earliest_in_millis) as f64;
+                let crosshair_time = NaiveDateTime::from_timestamp((crosshair_millis / 1000.0) as i64, 0);
+
+                let crosshair_timestamp = crosshair_time.timestamp();
+                let rounded_timestamp = (crosshair_timestamp as f64 / (self.timeframe as f64 * 60.0)).round() as i64 * self.timeframe as i64 * 60;
+                let rounded_time = NaiveDateTime::from_timestamp(rounded_timestamp, 0);
+
+                let snap_ratio = (rounded_timestamp as f64 * 1000.0 - earliest_in_millis as f64) / (latest_in_millis as f64 - earliest_in_millis as f64);
+                let snap_x = snap_ratio * bounds.width as f64;
+
+                let text_size = 12.0;
+                let text_content = rounded_time.format("%H:%M").to_string();
+                let growth_amount = 6.0; 
+                let rectangle_position = Point::new(snap_x as f32 - 14.0 - growth_amount, bounds.height as f32 - 20.0);
+                let text_position = Point::new(snap_x as f32 - 14.0, bounds.height as f32 - 20.0);
+
+                let text_background = canvas::Path::rectangle(rectangle_position, Size::new(text_content.len() as f32 * text_size/2.0 + 2.0 * growth_amount + 1.0, text_size + text_size/2.0));
+                frame.fill(&text_background, Color::from_rgba8(200, 200, 200, 1.0));
+
+                let crosshair_label = canvas::Text {
+                    content: text_content,
+                    position: text_position,
+                    size: iced::Pixels(text_size),
+                    color: Color::from_rgba8(0, 0, 0, 1.0),
+                    ..canvas::Text::default()
+                };
+
+                crosshair_label.draw_with(|path, color| {
+                    frame.fill(&path, color);
+                });
+            }
+        });
+
+        vec![labels, crosshair]
+    }
+
+    fn mouse_interaction(
+        &self,
+        interaction: &Interaction,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> mouse::Interaction {
+        match interaction {
+            Interaction::Drawing => mouse::Interaction::Crosshair,
+            Interaction::Erasing => mouse::Interaction::Crosshair,
+            Interaction::Panning { .. } => mouse::Interaction::ResizingHorizontally,
+            Interaction::None if cursor.is_over(bounds) => {
+                mouse::Interaction::ResizingHorizontally
+            }
+            Interaction::None => mouse::Interaction::default(),
+        }
+    }
+}
+
+pub struct AxisLabelYCanvas<'a> {
+    labels_cache: &'a Cache,
+    y_croshair_cache: &'a Cache,
+    min: f32,
+    max: f32,
+    crosshair_position: Point,
+    crosshair: bool,
+}
+impl canvas::Program<Message> for AxisLabelYCanvas<'_> {
+    type State = Interaction;
+
+    fn update(
+        &self,
+        _interaction: &mut Interaction,
+        _event: Event,
+        _bounds: Rectangle,
+        _cursor: mouse::Cursor,
+    ) -> (event::Status, Option<Message>) {
+        (event::Status::Ignored, None)
+    }
+    
+    fn draw(
+        &self,
+        _state: &Self::State,
+        renderer: &Renderer,
+        _theme: &Theme,
+        bounds: Rectangle,
+        _cursor: mouse::Cursor,
+    ) -> Vec<Geometry> {
+        if self.max == 0.0 {
+            return vec![];
+        }
+
+        let y_labels_can_fit = (bounds.height / 32.0) as i32;
+        let (step, rounded_lowest) = calculate_price_step(self.max, self.min, y_labels_can_fit);
+
+        let volume_area_height = bounds.height / 8.0; 
+        let candlesticks_area_height = bounds.height - volume_area_height;
+
+        let labels = self.labels_cache.draw(renderer, bounds.size(), |frame| {
+            frame.with_save(|frame| {
+                let y_range = self.max - self.min;
+                let mut y = rounded_lowest;
+
+                while y <= self.max {
+                    let y_position = candlesticks_area_height - ((y - self.min) / y_range * candlesticks_area_height);
+
+                    let text_size = 12.0;
+                    let decimal_places = if step < 0.5 { 2 } else if step < 1.0 { 1 } else { 0 };
+                    let label_content = format!("{:.*}", decimal_places, y);
+                    let label = canvas::Text {
+                        content: label_content,
+                        position: Point::new(10.0, y_position - text_size / 2.0),
+                        size: iced::Pixels(text_size),
+                        color: Color::from_rgba8(200, 200, 200, 1.0),
+                        ..canvas::Text::default()
+                    };  
+
+                    label.draw_with(|path, color| {
+                        frame.fill(&path, color);
+                    });
+
+                    y += step;
+                }
+            });
+        });
+        let crosshair = self.y_croshair_cache.draw(renderer, bounds.size(), |frame| {
+            if self.crosshair && self.crosshair_position.y > 0.0 {
+                let text_size = 12.0;
+                let y_range = self.max - self.min;
+                let decimal_places = if step < 1.0 { 2 } else { 1 };
+                let label_content = format!("{:.*}", decimal_places, self.min + (y_range * (candlesticks_area_height - self.crosshair_position.y) / candlesticks_area_height));
+                
+                let growth_amount = 3.0; 
+                let rectangle_position = Point::new(8.0 - growth_amount, self.crosshair_position.y - text_size / 2.0 - 3.0);
+                let text_position = Point::new(8.0, self.crosshair_position.y - text_size / 2.0 - 3.0);
+
+                let text_background = canvas::Path::rectangle(rectangle_position, Size::new(label_content.len() as f32 * text_size / 2.0 + 2.0 * growth_amount + 4.0, text_size + text_size / 1.8));
+                frame.fill(&text_background, Color::from_rgba8(200, 200, 200, 1.0));
+
+                let label = canvas::Text {
+                    content: label_content,
+                    position: text_position,
+                    size: iced::Pixels(text_size),
+                    color: Color::from_rgba8(0, 0, 0, 1.0),
+                    ..canvas::Text::default()
+                };
+
+                label.draw_with(|path, color| {
+                    frame.fill(&path, color);
+                });
+            }
+        });
+
+        vec![labels, crosshair]
+    }
+
+    fn mouse_interaction(
+        &self,
+        interaction: &Interaction,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> mouse::Interaction {
+        match interaction {
+            Interaction::Drawing => mouse::Interaction::Crosshair,
+            Interaction::Erasing => mouse::Interaction::Crosshair,
+            Interaction::Panning { .. } => mouse::Interaction::ResizingVertically,
+            Interaction::None if cursor.is_over(bounds) => {
+                mouse::Interaction::ResizingVertically
+            }
+            Interaction::None => mouse::Interaction::default(),
+        }
+    }
+}

+ 16 - 7
src/charts/heatmap.rs

@@ -98,6 +98,16 @@ impl Heatmap {
 
         self.render_start();
     }
+
+    pub fn get_raw_trades(&mut self) -> Vec<Trade> {
+        let mut trades_source = vec![];
+
+        for (_, (_, trades, _)) in self.data_points.iter() {
+            trades_source.extend(trades.iter().cloned());
+        }
+
+        trades_source
+    }
     
     pub fn render_start(&mut self) {    
         self.heatmap_cache.clear();
@@ -527,7 +537,7 @@ impl canvas::Program<Message> for Heatmap {
                         Point::new(x_position, y_position), 
                         Size::new(bar_width, 1.0) 
                     );
-                    frame.fill(&bar, Color::from_rgba8(0, 144, 144, 0.4));
+                    frame.fill(&bar, Color::from_rgba8(0, 144, 144, 0.5));
                 }
                 for (_, (price, qty)) in latest_asks.iter().enumerate() {
                     let y_position = heatmap_area_height - ((price - lowest) / y_range * heatmap_area_height);
@@ -537,7 +547,7 @@ impl canvas::Program<Message> for Heatmap {
                         Point::new(x_position, y_position), 
                         Size::new(bar_width, 1.0)
                     );
-                    frame.fill(&bar, Color::from_rgba8(192, 0, 192, 0.4));
+                    frame.fill(&bar, Color::from_rgba8(192, 0, 192, 0.5));
                 }
 
                 let line = Path::line(
@@ -776,18 +786,17 @@ impl canvas::Program<Message> for AxisLabelXCanvas<'_> {
                 let crosshair_time = NaiveDateTime::from_timestamp((crosshair_millis / 1000.0).floor() as i64, ((crosshair_millis % 1000.0) * 1_000_000.0).round() as u32);
                 
                 let crosshair_timestamp = crosshair_time.timestamp_millis() as i64;
-                let time = NaiveDateTime::from_timestamp(crosshair_timestamp / 1000, 0);
 
                 let snap_ratio = (crosshair_timestamp as f64 - earliest_in_millis as f64) / (latest_in_millis as f64 - earliest_in_millis as f64);
                 let snap_x = snap_ratio * bounds.width as f64;
 
                 let text_size = 12.0;
-                let text_content = time.format("%M:%S").to_string();
+                let text_content = crosshair_time.format("%M:%S:%3f").to_string().replace(".", "");
                 let growth_amount = 6.0; 
-                let rectangle_position = Point::new(snap_x as f32 - 14.0 - growth_amount, bounds.height as f32 - 20.0);
-                let text_position = Point::new(snap_x as f32 - 14.0, bounds.height as f32 - 20.0);
+                let rectangle_position = Point::new(snap_x as f32 - 26.0 - growth_amount, bounds.height as f32 - 20.0);
+                let text_position = Point::new(snap_x as f32 - 26.0, bounds.height as f32 - 20.0);
 
-                let text_background = canvas::Path::rectangle(rectangle_position, Size::new(text_content.len() as f32 * text_size/2.0 + 2.0 * growth_amount + 1.0, text_size + text_size/2.0));
+                let text_background = canvas::Path::rectangle(rectangle_position, Size::new(text_content.len() as f32 * text_size/2.0 + 2.0 * growth_amount, text_size + text_size/2.0));
                 frame.fill(&text_background, Color::from_rgba8(200, 200, 200, 1.0));
 
                 let crosshair_label = canvas::Text {

+ 131 - 31
src/main.rs

@@ -5,6 +5,7 @@ use data_providers::binance::{user_data, market_data};
 mod charts;
 use charts::custom_line::{self, CustomLine};
 use charts::heatmap::{self, Heatmap};
+use charts::footprint::{self, Footprint};
 
 use std::vec;
 use chrono::{NaiveDateTime, DateTime, Utc};
@@ -143,6 +144,7 @@ impl Default for UserWsState {
 #[derive(Eq, Hash, PartialEq)]
 pub enum PaneId {
     HeatmapChart,
+    FootprintChart,
     CandlestickChart,
     CustomChart,
     TimeAndSales,
@@ -185,6 +187,7 @@ pub enum Message {
     CustomLine(custom_line::Message),
     Candlestick(custom_line::Message),
     Heatmap(heatmap::Message),
+    Footprint(footprint::Message),
 
     // Market&User data stream
     UserKeySucceed(String),
@@ -221,6 +224,8 @@ pub enum Message {
 
     ShowLayoutModal,
     HideLayoutModal,
+
+    TicksizeSelected(f32),
 }
 
 struct State {
@@ -230,11 +235,11 @@ struct State {
     time_and_sales: Option<TimeAndSales>,
     custom_line: Option<CustomLine>,
     heatmap_chart: Option<Heatmap>,
+    footprint_chart: Option<Footprint>,
 
     // data streams
     listen_key: Option<String>,
     selected_ticker: Option<Ticker>,
-    selected_timeframe: Option<Timeframe>,
     selected_exchange: Option<&'static str>,
     ws_state: WsState,
     user_ws_state: UserWsState,
@@ -252,6 +257,7 @@ struct State {
     sync_heatmap: bool,
 
     kline_stream: bool,
+    tick_size: Option<f32>,
 }
 
 impl Application for State {
@@ -266,7 +272,8 @@ impl Application for State {
 
         let mut panes_open: HashMap<PaneId, (bool, Option<StreamType>)> = HashMap::new();
         panes_open.insert(PaneId::HeatmapChart, (true, Some(StreamType::DepthAndTrades(Ticker::BTCUSDT))));
-        panes_open.insert(PaneId::TimeAndSales, (false, None));
+        panes_open.insert(PaneId::FootprintChart, (false, Some(StreamType::Klines(Ticker::BTCUSDT, Timeframe::M3))));
+        panes_open.insert(PaneId::TimeAndSales, (false, Some(StreamType::DepthAndTrades(Ticker::BTCUSDT))));
         panes_open.insert(PaneId::CandlestickChart, (false, Some(StreamType::Klines(Ticker::BTCUSDT, Timeframe::M15))));
         panes_open.insert(PaneId::CustomChart, (true, Some(StreamType::Klines(Ticker::BTCUSDT, Timeframe::M1))));
         panes_open.insert(PaneId::TradePanel, (false, None));
@@ -283,9 +290,10 @@ impl Application for State {
                 time_and_sales: None,
                 custom_line: None,
                 heatmap_chart: None,
+                footprint_chart: None,
+
                 listen_key: None,
                 selected_ticker: None,
-                selected_timeframe: Some(Timeframe::M1),
                 selected_exchange: Some("Binance Futures"),
                 ws_state: WsState::Disconnected,
                 user_ws_state: UserWsState::Disconnected,
@@ -295,6 +303,7 @@ impl Application for State {
                 focus: None,
                 first_pane,
                 pane_lock: false,
+                tick_size: Some(1.0), 
             },
             Command::batch(vec![
                 font::load(ICON_BYTES).map(Message::FontLoaded),
@@ -351,6 +360,12 @@ impl Application for State {
                 }
                 Command::none()
             },
+            Message::Footprint(message) => {
+                if let Some(footprint) = &mut self.footprint_chart {
+                    footprint.update(message);
+                }
+                Command::none()
+            },
 
             Message::TickerSelected(ticker) => {
                 self.selected_ticker = Some(ticker.clone());
@@ -421,14 +436,6 @@ impl Application for State {
                 if self.ws_running {  
                     let mut commands = vec![];
 
-                    let selected_ticker = match &self.selected_ticker {
-                        Some(ticker) => ticker,
-                        None => {
-                            eprintln!("No ticker selected");
-                            return Command::none();
-                        }
-                    };
-
                     let first_pane = self.first_pane;
         
                     for (pane_id, (is_open, stream_type)) in &self.panes_open {
@@ -453,6 +460,7 @@ impl Application for State {
                                 self.time_and_sales = Some(TimeAndSales::new());
                             }
                         }
+
                         let selected_ticker = match stream_type {
                             Some(StreamType::DepthAndTrades(ticker)) => ticker,
                             Some(StreamType::Klines(ticker, _)) => ticker,
@@ -467,7 +475,7 @@ impl Application for State {
                                 dbg!("No timeframe selected", pane_id);
                                 continue;   
                             }
-                        };                            
+                        };
 
                         let pane_id = *pane_id;
                         let fetch_klines = Command::perform(
@@ -488,6 +496,7 @@ impl Application for State {
                     self.candlestick_chart = None;
                     self.time_and_sales = None;
                     self.custom_line = None;
+                    self.footprint_chart = None;
 
                     Command::none()
                 }
@@ -503,6 +512,32 @@ impl Application for State {
                                 PaneId::CandlestickChart => {
                                     self.candlestick_chart = Some(CustomLine::new(klines, *timeframe));
                                 },
+                                PaneId::FootprintChart => {
+                                    if let Some(heatmap_chart) = &mut self.heatmap_chart {
+                                        let copied_trades = heatmap_chart.get_raw_trades();
+
+                                        let mut klines_raw: Vec<(i64, f32, f32, f32, f32, f32, f32)> = vec![];
+                                        for kline in &klines {
+                                            let buy_volume = kline.taker_buy_base_asset_volume;
+                                            let sell_volume = kline.volume - buy_volume;
+
+                                            klines_raw.push((kline.time as i64, kline.open, kline.high, kline.low, kline.close, buy_volume, sell_volume));
+                                        }
+
+                                        // get the latest 20 klines
+                                        let copied_klines = klines_raw.iter().rev().take(20).rev().cloned().collect::<Vec<(i64, f32, f32, f32, f32, f32, f32)>>();
+
+                                        let timeframe_u16: u16 = match timeframe {
+                                            Timeframe::M1 => 1,
+                                            Timeframe::M3 => 3,
+                                            Timeframe::M5 => 5,
+                                            Timeframe::M15 => 15,
+                                            Timeframe::M30 => 30,
+                                        };
+
+                                        self.footprint_chart = Some(Footprint::new(timeframe_u16, self.tick_size.unwrap_or(1.0), copied_klines, copied_trades));
+                                    }
+                                },
                                 _ => {}
                             }
                         }
@@ -526,9 +561,15 @@ impl Application for State {
                         if let Some(time_and_sales) = &mut self.time_and_sales {
                             time_and_sales.update(&trades_buffer);
                         } 
+
+                        let trades_buffer_clone = trades_buffer.clone();
+
                         if let Some(chart) = &mut self.heatmap_chart {
                             chart.insert_datapoint(trades_buffer, depth_update, depth);
                         } 
+                        if let Some(chart) = &mut self.footprint_chart {
+                            chart.insert_datapoint(trades_buffer_clone, depth_update);
+                        }
                     }
                     market_data::Event::KlineReceived(kline, timeframe) => {
                         for (pane_id, (_, stream_type_option)) in &self.panes_open {
@@ -545,6 +586,11 @@ impl Application for State {
                                                 custom_line.insert_datapoint(kline.clone());
                                             }
                                         },
+                                        PaneId::FootprintChart => {
+                                            if let Some(footprint_chart) = &mut self.footprint_chart {
+                                                footprint_chart.update_latest_kline(kline.clone());
+                                            }
+                                        },
                                         _ => {}
                                     }
                                 }
@@ -634,6 +680,11 @@ impl Application for State {
                                 value.0 = false;
                             }
                         },
+                        PaneId::FootprintChart => {
+                            if let Some(value) = self.panes_open.get_mut(&PaneId::FootprintChart) {
+                                value.0 = false;
+                            }
+                        },
                         PaneId::CandlestickChart => {
                             if let Some(value) = self.panes_open.get_mut(&PaneId::CandlestickChart) {
                                 value.0 = false;
@@ -735,6 +786,20 @@ impl Application for State {
                 self.show_layout_modal = false;
                 Command::none()
             },
+
+            Message::TicksizeSelected(ticksize) => {
+                if let Some(heatmap_chart) = &mut self.heatmap_chart {
+                    let copied_trades = heatmap_chart.get_raw_trades();
+
+                    if let Some(footprint_chart) = &mut self.footprint_chart {
+                        footprint_chart.change_tick_size(copied_trades, ticksize);
+
+                        self.tick_size = Some(ticksize);
+                    }
+                }
+
+                Command::none()
+            },
         }
     }
 
@@ -754,6 +819,7 @@ impl Application for State {
                     self.sync_heatmap,
                     total_panes, 
                     size, 
+                    &self.footprint_chart,
                     &self.heatmap_chart,
                     &self.time_and_sales,
                     &self.candlestick_chart, 
@@ -774,6 +840,7 @@ impl Application for State {
             if is_focused {
                 let title = match pane.id {
                     PaneId::HeatmapChart => "Heatmap",
+                    PaneId::FootprintChart => "Footprint",
                     PaneId::CandlestickChart => "Candlesticks",
                     PaneId::CustomChart => "Candlesticks",
                     PaneId::TimeAndSales => "Time&Sales",
@@ -793,7 +860,8 @@ impl Application for State {
                         pane.id,
                         total_panes,
                         is_maximized,
-                        pane_timeframe
+                        pane_timeframe,
+                        self.tick_size.as_ref(),
                     ))
                     .padding(4)
                     .style(style::title_bar_focused);
@@ -920,6 +988,14 @@ impl Application for State {
                 buttons = buttons.push(time_and_sales_button);
             }
 
+            let footprint_chart_button = button("Footprint")
+                .width(iced::Pixels(200.0));
+            if let Some((false, _)) = self.panes_open.get(&PaneId::FootprintChart) {
+                buttons = buttons.push(footprint_chart_button.on_press(Message::Split(pane_grid::Axis::Vertical, self.first_pane, PaneId::FootprintChart)));
+            } else {
+                buttons = buttons.push(footprint_chart_button);
+            }
+
             let signup = container(
                 Column::new()
                     .spacing(10)
@@ -949,26 +1025,24 @@ impl Application for State {
 
     fn subscription(&self) -> Subscription<Message> {
         let mut subscriptions = Vec::new();
-    
+
         if self.ws_running {
-            self.selected_ticker.and_then(|ticker| {
-                self.selected_timeframe.map(|_timeframe| {
-                    let binance_market_stream = market_data::connect_market_stream(ticker).map(Message::MarketWsEvent);
-                    subscriptions.push(binance_market_stream);
+            if let Some(ticker) = &self.selected_ticker {
+                let binance_market_stream = market_data::connect_market_stream(*ticker).map(Message::MarketWsEvent);
+                subscriptions.push(binance_market_stream);
 
-                    let mut streams: Vec<(Ticker, Timeframe)> = vec![];
-                    
-                    for (_pane_id, (_is_open, stream_type)) in &self.panes_open {
-                        if let Some(StreamType::Klines(ticker, timeframe)) = stream_type {
-                            streams.push((*ticker, *timeframe));
-                        }
-                    }
-                    if !streams.is_empty() && self.kline_stream {
-                        let binance_kline_streams = market_data::connect_kline_stream(streams).map(Message::MarketWsEvent);
-                        subscriptions.push(binance_kline_streams);
+                let mut streams: Vec<(Ticker, Timeframe)> = vec![];
+                
+                for (_pane_id, (_is_open, stream_type)) in &self.panes_open {
+                    if let Some(StreamType::Klines(ticker, timeframe)) = stream_type {
+                        streams.push((*ticker, *timeframe));
                     }
-                })
-            });
+                }
+                if !streams.is_empty() && self.kline_stream {
+                    let binance_kline_streams = market_data::connect_kline_stream(streams).map(Message::MarketWsEvent);
+                    subscriptions.push(binance_kline_streams);
+                }
+            }
         }
         
         Subscription::batch(subscriptions)
@@ -1014,6 +1088,7 @@ fn view_content<'a, 'b: 'a>(
     sync_heatmap: bool,
     _total_panes: usize,
     _size: Size,
+    footprint_chart: &'a Option<Footprint>,
     heatmap_chart: &'a Option<Heatmap>,
     time_and_sales: &'a Option<TimeAndSales>,
     candlestick_chart: &'a Option<CustomLine>,
@@ -1074,6 +1149,22 @@ fn view_content<'a, 'b: 'a>(
                 underlay
             }
         }, 
+
+        PaneId::FootprintChart => { 
+            let underlay; 
+            if let Some(footprint_chart) = footprint_chart {
+                underlay =
+                    footprint_chart
+                        .view()
+                        .map(move |message| Message::Footprint(message));
+            } else {
+                underlay = Text::new("No data")
+                    .width(Length::Fill)
+                    .height(Length::Fill)
+                    .into();
+            }
+            underlay
+        },
         
         PaneId::CandlestickChart => { 
             let underlay; 
@@ -1176,6 +1267,7 @@ fn view_controls<'a>(
     total_panes: usize,
     is_maximized: bool,
     selected_timeframe: Option<&'a Timeframe>,
+    selected_ticksize: Option<&'a f32>,
 ) -> Element<'a, Message> {
     let mut row = row![].spacing(5);
 
@@ -1185,7 +1277,7 @@ fn view_controls<'a>(
         (Icon::ResizeFull, Message::Maximize(pane))
     };
 
-    if pane_id == PaneId::CandlestickChart || pane_id == PaneId::CustomChart {
+    if pane_id == PaneId::CandlestickChart || pane_id == PaneId::CustomChart || pane_id == PaneId::FootprintChart {
         let timeframe_picker = pick_list(
             &Timeframe::ALL[..],
             selected_timeframe,
@@ -1193,6 +1285,14 @@ fn view_controls<'a>(
         ).placeholder("Choose a timeframe...").text_size(11).width(iced::Pixels(80.0));
         row = row.push(timeframe_picker);
     }
+    if pane_id == PaneId::FootprintChart {
+        let ticksize_picker = pick_list(
+            [0.1, 0.5, 1.0, 5.0, 10.0, 25.0, 50.0],
+            selected_ticksize,
+            move |ticksize| Message::TicksizeSelected(ticksize),
+        ).placeholder("Choose a ticksize...").text_size(11).width(iced::Pixels(80.0));
+        row = row.push(ticksize_picker);
+    }
 
     let mut buttons = vec![
         (container(text(char::from(Icon::Cog).to_string()).font(ICON).size(14)).width(25).center_x(iced::Pixels(25.0)), Message::OpenModal(pane)),