Berke 10 сар өмнө
parent
commit
e1a73e2f8a

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 298 - 329
Cargo.lock


+ 28 - 25
Cargo.toml

@@ -1,44 +1,47 @@
 [package]
 [package]
-name = "iced-trade"
-version = "0.4.0"
+name = "flowsurface"
+version = "0.5.0"
 edition = "2021"
 edition = "2021"
 
 
-# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
-
 [dependencies]
 [dependencies]
-iced = { git = "https://github.com/iced-rs/iced.git", features = ["canvas", "tokio"] }
-chrono = "0.4.37"
-tokio = { version = "1.37.0", features = ["full", "macros"] }
+iced = { version = "0.14.0-dev", features = ["canvas", "tokio", "image", "advanced", "lazy"] }
+iced_futures = "0.13.2"
+chrono = "0.4.38"
+tokio = { version = "1.41.1", features = ["full", "macros"] }
 tokio-tungstenite = "0.21.0"
 tokio-tungstenite = "0.21.0"
-url = "2.5.0"
+url = "2.5.3"
 tokio-native-tls = "0.3.1"
 tokio-native-tls = "0.3.1"
-base64 = "0.22.0"
-native-tls = "0.2.11"
+base64 = "0.22.1"
+native-tls = "0.2.12"
 tungstenite = "0.21.0"
 tungstenite = "0.21.0"
-futures = "0.3.30"
-futures-util = "0.3.30"
-serde_json = "1.0.115"
+futures = "0.3.31"
+futures-util = "0.3.31"
+serde_json = "1.0.132"
 serde = { version = "1.0", features = ["derive"] }
 serde = { version = "1.0", features = ["derive"] }
-reqwest = { version = "0.12.2", features = ["json"] }
+reqwest = { version = "0.12.9", features = ["json"] }
 hmac = "0.12.1"
 hmac = "0.12.1"
 sha2 = "0.10.8"
 sha2 = "0.10.8"
 hex = "0.4.3"
 hex = "0.4.3"
-iced_table = "0.12.0"
-iced_futures = "0.12.0"
-anyhow = "1.0.86"
-bytes = "1.6.0"
-sonic-rs = "0.3.7"
-fastwebsockets = { version = "0.7.2", features = ["upgrade"] }
+bytes = "1.8.0"
+sonic-rs = "0.3.17"
+fastwebsockets = { version = "0.8.0", features = ["upgrade"] }
 http-body-util = "0.1.2"
 http-body-util = "0.1.2"
 hyper = { version = "1", features = ["http1", "server", "client"] }
 hyper = { version = "1", features = ["http1", "server", "client"] }
-hyper-util = { version = "0.1.0", features = ["tokio"] }
-tokio-rustls = "0.24.0"
-webpki-roots = "0.23.0"
-uuid = { version = "1.10.0", features = ["v4"] }
+hyper-util = { version = "0.1.10", features = ["tokio"] }
+tokio-rustls = "0.24.1"
+webpki-roots = "0.23.1"
 rustc-hash = "2.0.0"
 rustc-hash = "2.0.0"
 fern = "0.6.2"
 fern = "0.6.2"
 log = "0.4.22"
 log = "0.4.22"
-thiserror = "1.0.63"
+thiserror = "1.0.68"
+ordered-float = "4.5.0"
+regex = "1.11.1"
+rust_decimal = "1.36.0"
+uuid = { version = "1.11.0", features = ["v4"] }
+
 [dependencies.async-tungstenite]
 [dependencies.async-tungstenite]
 version = "0.25"
 version = "0.25"
 features = ["tokio-rustls-webpki-roots"]
 features = ["tokio-rustls-webpki-roots"]
+
+[patch.crates-io]
+iced = { git = "https://github.com/iced-rs/iced", rev = "a687a837653a576cb0599f7bc8ecd9c6054213a9" }

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 860 - 295
src/charts.rs


+ 455 - 440
src/charts/candlestick.rs

@@ -1,236 +1,394 @@
 use std::collections::BTreeMap;
 use std::collections::BTreeMap;
-use iced::{
-    alignment, mouse, widget::{button, canvas::{self, event::{self, Event}, stroke::Stroke, Canvas, Geometry, Path}}, Color, Element, Length, Point, Rectangle, Renderer, Size, Theme
+
+use iced::widget::canvas::{LineDash, Path, Stroke};
+use iced::{mouse, Element, Point, Rectangle, Renderer, Size, Task, Theme, Vector};
+use iced::widget::{canvas::{self, Event, Geometry}, column};
+
+use crate::screen::UserTimezone;
+use crate::data_providers::{
+    fetcher::{FetchRange, RequestHandler},
+    Kline, OpenInterest as OIData, Timeframe
 };
 };
-use iced::widget::{Column, Row, Container, Text};
-use crate::data_providers::Kline;
 
 
-use super::{Chart, CommonChartData, Message, Interaction, AxisLabelXCanvas, AxisLabelYCanvas};
-use super::{chart_button, calculate_price_step, calculate_time_step};
+use super::indicators::{self, CandlestickIndicator, Indicator};
 
 
-pub struct CandlestickChart {
-    chart: CommonChartData,
-    data_points: BTreeMap<i64, Kline>,
-    timeframe: u16,
-}
+use super::{request_fetch, Caches, Chart, ChartConstants, CommonChartData, Interaction, Message, PriceInfoLabel};
+use super::{canvas_interaction, view_chart, update_chart, count_decimals};
 
 
 impl Chart for CandlestickChart {
 impl Chart for CandlestickChart {
-    type DataPoint = BTreeMap<i64, Kline>;
-
     fn get_common_data(&self) -> &CommonChartData {
     fn get_common_data(&self) -> &CommonChartData {
         &self.chart
         &self.chart
     }
     }
+
     fn get_common_data_mut(&mut self) -> &mut CommonChartData {
     fn get_common_data_mut(&mut self) -> &mut CommonChartData {
         &mut self.chart
         &mut self.chart
     }
     }
+
+    fn update_chart(&mut self, message: &Message) -> Task<Message> {
+        let task = update_chart(self, message);
+        self.render_start();
+
+        task
+    }
+
+    fn canvas_interaction(
+        &self,
+        interaction: &mut Interaction,
+        event: Event,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> Option<canvas::Action<Message>> {
+        canvas_interaction(self, interaction, event, bounds, cursor)
+    }
+
+    fn view_indicator<I: Indicator>(&self, enabled: &[I]) -> Element<Message> {
+        self.view_indicators(enabled)
+    }
+
+    fn get_visible_timerange(&self) -> (i64, i64) {
+        let chart = self.get_common_data();
+
+        let visible_region = chart.visible_region(chart.bounds.size());
+
+        let earliest = chart.x_to_time(visible_region.x);
+        let latest = chart.x_to_time(visible_region.x + visible_region.width);
+
+        (earliest, latest)
+    }
 }
 }
 
 
-impl CandlestickChart {
-    const MIN_SCALING: f32 = 0.1;
-    const MAX_SCALING: f32 = 2.0;
+impl ChartConstants for CandlestickChart {
+    const MIN_SCALING: f32 = 0.6;
+    const MAX_SCALING: f32 = 2.5;
+
+    const MAX_CELL_WIDTH: f32 = 16.0;
+    const MIN_CELL_WIDTH: f32 = 1.0;
 
 
-    pub fn new(klines: Vec<Kline>, timeframe: u16) -> CandlestickChart {
-        let mut klines_raw = BTreeMap::new();
+    const MAX_CELL_HEIGHT: f32 = 8.0;
+    const MIN_CELL_HEIGHT: f32 = 1.0;
+
+    const DEFAULT_CELL_WIDTH: f32 = 4.0;
+}
 
 
-        for kline in klines {
-            klines_raw.insert(kline.time as i64, kline);
+#[allow(dead_code)]
+enum Indicators {
+    Volume(Caches, BTreeMap<i64, (f32, f32)>),
+    OpenInterest(Caches, BTreeMap<i64, f32>),
+}
+
+impl Indicators {
+    fn clear_cache(&mut self) {
+        match self {
+            Indicators::Volume(caches, _) 
+            | Indicators::OpenInterest(caches, _) => {
+                caches.clear_all();
+            }
         }
         }
+    }
+}
+
+pub struct CandlestickChart {
+    chart: CommonChartData,
+    data_points: BTreeMap<i64, Kline>,
+    indicators: Vec<Indicators>,
+    request_handler: RequestHandler,
+    fetching_oi: bool,
+}
+
+impl CandlestickChart {
+    pub fn new(
+        klines_raw: Vec<Kline>,
+        timeframe: Timeframe,
+        tick_size: f32,
+        timezone: UserTimezone,
+    ) -> CandlestickChart {
+        let mut data_points = BTreeMap::new();
+        let mut volume_data = BTreeMap::new();
+
+        let base_price_y = klines_raw.last().unwrap_or(&Kline::default()).close;
+
+        for kline in klines_raw {
+            volume_data.insert(kline.time as i64, (kline.volume.0, kline.volume.1));
+            data_points.entry(kline.time as i64).or_insert(kline);
+        }
+
+        let mut latest_x = 0;
+        let (mut scale_high, mut scale_low) = (0.0f32, f32::MAX);
+        data_points.iter().rev().for_each(|(time, kline)| {
+            scale_high = scale_high.max(kline.high);
+            scale_low = scale_low.min(kline.low);
+
+            latest_x = latest_x.max(*time);
+        });
+
+        let y_ticks = (scale_high - scale_low) / tick_size;
 
 
         CandlestickChart {
         CandlestickChart {
-            chart: CommonChartData::default(),
-            data_points: klines_raw,
-            timeframe,
+            chart: CommonChartData {
+                cell_width: Self::DEFAULT_CELL_WIDTH,
+                cell_height: 200.0 / y_ticks,
+                base_range: 100.0 / y_ticks,
+                base_price_y,
+                latest_x,
+                timeframe: timeframe.to_milliseconds(),
+                tick_size,
+                timezone,
+                indicators_height: 30,
+                decimals: count_decimals(tick_size),
+                ..Default::default()
+            },
+            data_points,
+            indicators: vec![
+                Indicators::Volume(Caches::default(), volume_data.clone()),
+                Indicators::OpenInterest(Caches::default(), BTreeMap::new()),
+            ],
+            request_handler: RequestHandler::new(),
+            fetching_oi: false,
         }
         }
     }
     }
 
 
-    pub fn update_latest_kline(&mut self, kline: &Kline) {
+    pub fn change_timezone(&mut self, timezone: UserTimezone) {
+        let chart = self.get_common_data_mut();
+        chart.timezone = timezone;
+    }
+
+    pub fn get_tick_size(&self) -> f32 {
+        self.chart.tick_size
+    }
+
+    pub fn update_latest_kline(&mut self, kline: &Kline) -> Task<Message> {
         self.data_points.insert(kline.time as i64, *kline);
         self.data_points.insert(kline.time as i64, *kline);
 
 
+        self.indicators.iter_mut().for_each(|indicator| {
+            if let Indicators::Volume(_, data) = indicator {
+                data.insert(kline.time as i64, (kline.volume.0, kline.volume.1));
+            }
+        });
+
+        let chart = self.get_common_data_mut();
+
+        if (kline.time as i64) > chart.latest_x {
+            chart.latest_x = kline.time as i64;
+        }
+
+        chart.last_price = if kline.close > kline.open {
+            Some(PriceInfoLabel::Up(kline.close))
+        } else {
+            Some(PriceInfoLabel::Down(kline.close))
+        };
+        
+        if !chart.already_fetching {
+            return self.get_missing_data_task();
+        }
+
         self.render_start();
         self.render_start();
+        Task::none()
     }
     }
 
 
-    pub fn render_start(&mut self) {
-        let (latest, earliest, highest, lowest) = self.calculate_range();
+    fn get_missing_data_task(&mut self) -> Task<Message> {
+        let mut task = Task::none();
 
 
-        if latest == 0 || highest == 0.0 {
-            return;
-        }
+        let (visible_earliest, visible_latest) = self.get_visible_timerange();
+        let (kline_earliest, kline_latest) = self.get_kline_timerange();
+
+        let earliest = visible_earliest - (visible_latest - visible_earliest);
 
 
-        let chart_state = self.get_common_data_mut();
+        if visible_earliest < kline_earliest {
+            let latest = kline_earliest;
 
 
-        if earliest != chart_state.x_min_time || latest != chart_state.x_max_time || lowest != chart_state.y_min_price || highest != chart_state.y_max_price {
-            chart_state.x_labels_cache.clear();
-            chart_state.mesh_cache.clear();
+            if let Some(task) = request_fetch(
+                &mut self.request_handler, FetchRange::Kline(earliest, latest)
+            ) {
+                self.get_common_data_mut().already_fetching = true;
+                return task;
+            }
         }
         }
 
 
-        chart_state.x_min_time = earliest;
-        chart_state.x_max_time = latest;
-        chart_state.y_min_price = lowest;
-        chart_state.y_max_price = highest;
+        for indicator in &self.indicators {
+            if let Indicators::OpenInterest(_, _) = indicator {
+                if !self.fetching_oi {
+                    let (oi_earliest, oi_latest) = self.get_oi_timerange(kline_latest);
 
 
-        chart_state.y_labels_cache.clear();
-        chart_state.crosshair_cache.clear();
+                    if visible_earliest < oi_earliest {
+                        let latest = oi_earliest;
 
 
-        chart_state.main_cache.clear();
+                        if let Some(fetch_task) = request_fetch(
+                            &mut self.request_handler, FetchRange::OpenInterest(earliest, latest)
+                        ) {
+                            self.fetching_oi = true;
+                            task = fetch_task;
+                        }
+                    } else if oi_latest < kline_latest {
+                        let latest = visible_latest;
+
+                        if let Some(fetch_task) = request_fetch(
+                            &mut self.request_handler, FetchRange::OpenInterest(oi_latest, latest)
+                        ) {
+                            self.fetching_oi = true;
+                            task = fetch_task;
+                        }
+                    }
+                }
+            }
+        };
+
+        self.render_start();
+        task
     }
     }
 
 
-    fn calculate_range(&self) -> (i64, i64, f32, f32) {
-        let chart = self.get_common_data();
+    pub fn insert_new_klines(&mut self, req_id: uuid::Uuid, klines_raw: &Vec<Kline>) {
+        let mut volume_data = BTreeMap::new();
 
 
-        let timestamp_latest = self.data_points.keys().last().map_or(0, |time| *time);
-    
-        let latest: i64 = timestamp_latest - ((chart.translation.x*8000.0)*(self.timeframe as f32)) as i64;
-        let earliest: i64 = latest - ((6400000.0*self.timeframe as f32) / (chart.scaling / (chart.bounds.width/800.0))) as i64;
-    
-        let visible_klines = self.data_points.range(earliest..=latest);
-    
-        let (highest, lowest, avg_body_height, count) = visible_klines.fold((f32::MIN, f32::MAX, 0.0f32, 0), |(highest, lowest, total_body_height, count), (_, kline)| {
-            let body_height = (kline.open - kline.close).abs();
-            (
-                highest.max(kline.high),
-                lowest.min(kline.low),
-                total_body_height + body_height,
-                count + 1,
-            )
+        for kline in klines_raw {
+            volume_data.insert(kline.time as i64, (kline.volume.0, kline.volume.1));
+            self.data_points.entry(kline.time as i64).or_insert(*kline);
+        }
+
+        self.indicators.iter_mut().for_each(|indicator| {
+            if let Indicators::Volume(_, data) = indicator {
+                data.extend(volume_data.clone());
+            }
         });
         });
-    
-        if count <= 1 {
-            return (0, 0, 0.0, 0.0);
+
+        if klines_raw.len() > 1 {
+            self.request_handler.mark_completed(req_id);
+        } else {
+            self.request_handler
+                .mark_failed(req_id, "No data received".to_string());
         }
         }
+
+        self.get_common_data_mut().already_fetching = false;
+
+        self.render_start();
+    }
+
+    pub fn insert_open_interest(&mut self, _req_id: Option<uuid::Uuid>, oi_data: Vec<OIData>) {
+        self.indicators.iter_mut().for_each(|indicator| {
+            if let Indicators::OpenInterest(_, data) = indicator {
+                data.extend(oi_data
+                    .iter().map(|oi| (oi.time, oi.value))
+                );
+            }
+        });
     
     
-        let avg_body_height = if count > 1 { avg_body_height / (count - 1) as f32 } else { 0.0 };
-        let (highest, lowest) = (highest + avg_body_height, lowest - avg_body_height);
-    
-        (latest, earliest, highest, lowest)
+        self.fetching_oi = false;
     }
     }
 
 
-    pub fn update(&mut self, message: &Message) {
-        match message {
-            Message::Translated(translation) => {
-                let chart = self.get_common_data_mut();
+    fn get_kline_timerange(&self) -> (i64, i64) {
+        let mut from_time = i64::MAX;
+        let mut to_time = i64::MIN;
 
 
-                if chart.autoscale {
-                    chart.translation.x = translation.x;
-                } else {
-                    chart.translation = *translation;
-                }
-                chart.crosshair_position = Point::new(0.0, 0.0);
+        self.data_points.iter().for_each(|(time, _)| {
+            from_time = from_time.min(*time);
+            to_time = to_time.max(*time);
+        });
 
 
-                self.render_start();
-            },
-            Message::Scaled(scaling, translation) => {
-                let chart = self.get_common_data_mut();
-
-                chart.scaling = *scaling;
-                
-                if let Some(translation) = translation {
-                    if chart.autoscale {
-                        chart.translation.x = translation.x;
+        (from_time, to_time)
+    }
+
+    fn get_oi_timerange(&self, latest_kline: i64) -> (i64, i64) {
+        let mut from_time = latest_kline;
+        let mut to_time = i64::MIN;
+
+        self.indicators.iter().for_each(|indicator| {
+            if let Indicators::OpenInterest(_, data) = indicator {
+                data.iter().for_each(|(time, _)| {
+                    from_time = from_time.min(*time);
+                    to_time = to_time.max(*time);
+                });
+            }
+        });
+
+        (from_time, to_time)
+    }
+
+    fn render_start(&mut self) {
+        let chart_state = &mut self.chart;
+
+        if chart_state.autoscale {
+            chart_state.translation =
+                Vector::new(0.4 * chart_state.bounds.width / chart_state.scaling, {
+                    if let Some((_, kline)) = self.data_points.last_key_value() {
+                        let y_low = chart_state.price_to_y(kline.low);
+                        let y_high = chart_state.price_to_y(kline.high);
+
+                        -(y_low + y_high) / 2.0
                     } else {
                     } else {
-                        chart.translation = *translation;
+                        0.0
                     }
                     }
-                }
-                chart.crosshair_position = Point::new(0.0, 0.0);
+                });
+        }
 
 
-                self.render_start();
-            },
-            Message::ChartBounds(bounds) => {
-                self.chart.bounds = *bounds;
-            },
-            Message::AutoscaleToggle => {
-                self.chart.autoscale = !self.chart.autoscale;
-            },
-            Message::CrosshairToggle => {
-                self.chart.crosshair = !self.chart.crosshair;
-            },
-            Message::CrosshairMoved(position) => {
-                let chart = self.get_common_data_mut();
-
-                chart.crosshair_position = *position;
-                if chart.crosshair {
-                    chart.crosshair_cache.clear();
-                    chart.y_crosshair_cache.clear();
-                    chart.x_crosshair_cache.clear();
+        chart_state.cache.clear_all();
+
+        self.indicators.iter_mut().for_each(|indicator| {
+            indicator.clear_cache();
+        });
+    }
+
+    fn get_volume_indicator(&self) -> Option<(&Caches, &BTreeMap<i64, (f32, f32)>)> {
+        for indicator in &self.indicators {
+            if let Indicators::Volume(cache, data) = indicator {
+                return Some((cache, data));
+            }
+        }
+
+        None
+    }
+
+    fn get_oi_indicator(&self) -> Option<(&Caches, &BTreeMap<i64, f32>)> {
+        for indicator in &self.indicators {
+            if let Indicators::OpenInterest(cache, data) = indicator {
+                return Some((cache, data));
+            }
+        }
+
+        None
+    }
+
+    pub fn view_indicators<I: Indicator>(&self, enabled: &[I]) -> Element<Message> {
+        let chart_state: &CommonChartData = self.get_common_data();
+
+        let visible_region = chart_state.visible_region(chart_state.bounds.size());
+
+        let earliest = chart_state.x_to_time(visible_region.x);
+        let latest = chart_state.x_to_time(visible_region.x + visible_region.width);
+
+        let mut indicators: iced::widget::Column<'_, Message> = column![];
+
+        for indicator in I::get_enabled(enabled) {
+            if let Some(candlestick_indicator) = indicator
+                .as_any()
+                .downcast_ref::<CandlestickIndicator>() 
+            {
+                match candlestick_indicator {
+                    CandlestickIndicator::Volume => {
+                        if let Some((cache, data)) = self.get_volume_indicator() {
+                            indicators = indicators.push(
+                                indicators::volume::create_indicator_elem(chart_state, cache, data, earliest, latest)
+                            );
+                        }
+                    },
+                    CandlestickIndicator::OpenInterest => {
+                        if let Some((cache, data)) = self.get_oi_indicator() {
+                            indicators = indicators.push(
+                                indicators::open_interest::create_indicator_elem(chart_state, cache, data, earliest, latest)
+                            );
+                        }
+                    }
                 }
                 }
-            },
-            _ => {}
+            }
         }
         }
+
+        indicators.into()
     }
     }
 
 
-    pub fn view(&self) -> Element<Message> {
-        let chart = Canvas::new(self)
-            .width(Length::FillPortion(10))
-            .height(Length::FillPortion(10));
+    pub fn update(&mut self, message: &Message) -> Task<Message> {
+        self.update_chart(message)
+    }
 
 
-        let chart_state = self.get_common_data();
-    
-        let axis_labels_x = Canvas::new(
-            AxisLabelXCanvas { 
-                labels_cache: &chart_state.x_labels_cache, 
-                min: chart_state.x_min_time, 
-                max: chart_state.x_max_time, 
-                crosshair_cache: &chart_state.x_crosshair_cache, 
-                crosshair_position: chart_state.crosshair_position, 
-                crosshair: chart_state.crosshair,
-                timeframe: Some(self.timeframe)
-            })
-            .width(Length::FillPortion(10))
-            .height(Length::Fixed(26.0));
-    
-        let axis_labels_y = Canvas::new(
-            AxisLabelYCanvas { 
-                labels_cache: &chart_state.y_labels_cache, 
-                y_croshair_cache: &chart_state.y_crosshair_cache, 
-                min: chart_state.y_min_price,
-                max: chart_state.y_max_price,
-                crosshair_position: chart_state.crosshair_position, 
-                crosshair: chart_state.crosshair
-            })
-            .width(Length::Fixed(60.0))
-            .height(Length::FillPortion(10));
-
-        let autoscale_button = button(
-            Text::new("A")
-                .size(12)
-                .align_x(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, chart_state.autoscale));
-        let crosshair_button = button(
-            Text::new("+")
-                .size(12)
-                .align_x(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, chart_state.crosshair));
-    
-        let chart_controls = Container::new(
-            Row::new()
-                .push(autoscale_button)
-                .push(crosshair_button).spacing(2)
-            ).padding([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()
+    pub fn view<'a, I: Indicator>(&'a self, indicators: &'a [I]) -> Element<Message> {
+        view_chart(self, indicators)
     }
     }
 }
 }
 
 
@@ -243,285 +401,128 @@ impl canvas::Program<Message> for CandlestickChart {
         event: Event,
         event: Event,
         bounds: Rectangle,
         bounds: Rectangle,
         cursor: mouse::Cursor,
         cursor: mouse::Cursor,
-    ) -> (event::Status, Option<Message>) {  
-        let chart_state = self.get_common_data();
-
-        if bounds != chart_state.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 chart_state.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::Left => {
-                            *interaction = Interaction::Panning {
-                                translation: chart_state.translation,
-                                start: cursor_position,
-                            };
-                            None
-                        }
-                        _ => None,
-                    };
-
-                    (event::Status::Captured, message)
-                }
-                mouse::Event::CursorMoved { .. } => {
-                    let message = match *interaction {
-                        Interaction::Panning { translation, start } => {
-                            Some(Message::Translated(
-                                translation
-                                    + (cursor_position - start)
-                                        * (1.0 / chart_state.scaling),
-                            ))
-                        }
-                        Interaction::None => 
-                            if chart_state.crosshair && cursor.is_over(bounds) {
-                                Some(Message::CrosshairMoved(cursor_position))
-                            } else {
-                                None
-                            },
-                        _ => 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 && chart_state.scaling > Self::MIN_SCALING
-                            || y > 0.0 && chart_state.scaling < Self::MAX_SCALING
-                        {
-                            //let old_scaling = self.scaling;
-
-                            let scaling = (chart_state.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),
-        }
+    ) -> Option<canvas::Action<Message>> {
+        self.canvas_interaction(interaction, event, bounds, cursor)
     }
     }
-    
+
     fn draw(
     fn draw(
         &self,
         &self,
         _state: &Self::State,
         _state: &Self::State,
         renderer: &Renderer,
         renderer: &Renderer,
-        _theme: &Theme,
+        theme: &Theme,
         bounds: Rectangle,
         bounds: Rectangle,
         cursor: mouse::Cursor,
         cursor: mouse::Cursor,
-    ) -> Vec<Geometry> {    
+    ) -> Vec<Geometry> {
+        if self.data_points.is_empty() {
+            return vec![];
+        }
+
         let chart = self.get_common_data();
         let chart = self.get_common_data();
 
 
-        let (latest, earliest) = (chart.x_max_time, chart.x_min_time);    
-        let (lowest, highest) = (chart.y_min_price, chart.y_max_price);
+        let center = Vector::new(bounds.width / 2.0, bounds.height / 2.0);
+        let bounds_size = bounds.size();
 
 
-        let y_range = highest - lowest;
+        let palette = theme.extended_palette();
 
 
-        let volume_area_height = bounds.height / 8.0; 
-        let candlesticks_area_height = bounds.height - volume_area_height;
+        let candlesticks = chart.cache.main.draw(renderer, bounds_size, |frame| {
+            frame.with_save(|frame| {
+                frame.translate(center);
+                frame.scale(chart.scaling);
+                frame.translate(chart.translation);
 
 
-        let y_labels_can_fit = (bounds.height / 32.0) as i32;
-        let (step, rounded_lowest) = calculate_price_step(highest, lowest, y_labels_can_fit);
+                let region = chart.visible_region(frame.size());
 
 
-        let x_labels_can_fit = (bounds.width / 90.0) as i32;
-        let (time_step, rounded_earliest) = calculate_time_step(earliest, latest, x_labels_can_fit, Some(self.timeframe));
+                let earliest = chart.x_to_time(region.x);
+                let latest = chart.x_to_time(region.x + region.width);
 
 
-        let background = chart.mesh_cache.draw(renderer, bounds.size(), |frame| {
-            frame.with_save(|frame| {
-                let mut time = rounded_earliest;
+                let candle_width = chart.cell_width * 0.8;
 
 
-                while time <= latest {                    
-                    let x_position = ((time - earliest) as f64 / (latest - earliest) as f64) * bounds.width as f64;
+                self.data_points.range(earliest..=latest)
+                    .for_each(|(timestamp, kline)| {
+                        let x_position = chart.time_to_x(*timestamp);
 
 
-                    if x_position >= 0.0 && x_position <= bounds.width as f64 {
-                        let line = Path::line(
-                            Point::new(x_position as f32, 0.0), 
-                            Point::new(x_position as f32, bounds.height)
-                        );
-                        frame.stroke(&line, Stroke::default().with_color(Color::from_rgba8(27, 27, 27, 1.0)).with_width(1.0))
-                    };
-                    
-                    time += time_step;
-                }
-            });
-            
-            frame.with_save(|frame| {
-                let mut y = rounded_lowest;
-
-                while y <= highest {
-                    let y_position = candlesticks_area_height - ((y - lowest) / y_range * candlesticks_area_height);
-                    let line = Path::line(
-                        Point::new(0.0, y_position), 
-                        Point::new(bounds.width, y_position)
-                    );
-                    frame.stroke(&line, Stroke::default().with_color(Color::from_rgba8(27, 27, 27, 1.0)).with_width(1.0));
-                    y += step;
-                }
-            });
-        });
+                        let y_open = chart.price_to_y(kline.open);
+                        let y_high = chart.price_to_y(kline.high);
+                        let y_low = chart.price_to_y(kline.low);
+                        let y_close = chart.price_to_y(kline.close);
 
 
-        let candlesticks = chart.main_cache.draw(renderer, bounds.size(), |frame| {
-            let mut max_volume: f32 = 0.0;
+                        let body_color = if kline.close >= kline.open {
+                            palette.success.base.color
+                        } else {
+                            palette.danger.base.color
+                        };
+                        frame.fill_rectangle(
+                            Point::new(x_position - (candle_width / 2.0), y_open.min(y_close)),
+                            Size::new(candle_width, (y_open - y_close).abs()),
+                            body_color,
+                        );
 
 
-            for (_, kline) in self.data_points.range(earliest..=latest) {
-                max_volume = max_volume.max(kline.volume.0.max(kline.volume.1));
-            }
+                        let wick_color = if kline.close >= kline.open {
+                            palette.success.base.color
+                        } else {
+                            palette.danger.base.color
+                        };
+                        frame.fill_rectangle(
+                            Point::new(x_position - (candle_width / 8.0), y_high),
+                            Size::new(candle_width / 4.0, (y_high - y_low).abs()),
+                            wick_color,
+                        );
+                    });
 
 
-            for (time, kline) in self.data_points.range(earliest..=latest) {
-                let x_position: f64 = ((time - earliest) as f64 / (latest - earliest) as f64) * bounds.width as f64;
+                // last price line
+                chart.last_price.map(|price| {
+                    let (line_color, y_pos) = match price {
+                        PriceInfoLabel::Up(p) => (palette.success.weak.color, chart.price_to_y(p)),
+                        PriceInfoLabel::Down(p) => (palette.danger.weak.color, chart.price_to_y(p)),
+                    };
 
 
-                if x_position.is_nan() {
-                    continue;
-                }
-                
-                let y_open = candlesticks_area_height - ((kline.open - lowest) / y_range * candlesticks_area_height);
-                let y_high = candlesticks_area_height - ((kline.high - lowest) / y_range * candlesticks_area_height);
-                let y_low = candlesticks_area_height - ((kline.low - lowest) / y_range * candlesticks_area_height);
-                let y_close = candlesticks_area_height - ((kline.close - lowest) / y_range * candlesticks_area_height);
-                
-                let color = if kline.close >= kline.open { Color::from_rgb8(81, 205, 160) } else { Color::from_rgb8(192, 80, 77) };
-
-                let body = Path::rectangle(
-                    Point::new(x_position as f32 - (2.0 * chart.scaling), y_open.min(y_close)), 
-                    Size::new(4.0 * chart.scaling, (y_open - y_close).abs())
-                );                    
-                frame.fill(&body, color);
-                
-                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));
-
-                if kline.volume.0 != -1.0 {
-                    let buy_bar_height = (kline.volume.0 / max_volume) * volume_area_height;
-                    let sell_bar_height = (kline.volume.1 / max_volume) * volume_area_height;
-                    
-                    let buy_bar = Path::rectangle(
-                        Point::new(x_position as f32, bounds.height - buy_bar_height), 
-                        Size::new(2.0 * chart.scaling, buy_bar_height)
-                    );
-                    frame.fill(&buy_bar, Color::from_rgb8(81, 205, 160)); 
-                    
-                    let sell_bar = Path::rectangle(
-                        Point::new(x_position as f32 - (2.0 * chart.scaling), bounds.height - sell_bar_height), 
-                        Size::new(2.0 * chart.scaling, sell_bar_height)
+                    let marker_line = Stroke::with_color(
+                        Stroke {
+                            width: 1.0,
+                            line_dash: LineDash {
+                                segments: &[2.0, 2.0],
+                                offset: 4,
+                            },
+                            ..Default::default()
+                        },
+                        line_color.scale_alpha(0.5),
                     );
                     );
-                    frame.fill(&sell_bar, Color::from_rgb8(192, 80, 77)); 
-                } else {
-                    let bar_height = ((kline.volume.1) / max_volume) * volume_area_height;
-                    
-                    let bar = Path::rectangle(
-                        Point::new(x_position as f32 - (2.0 * chart.scaling), bounds.height - bar_height), 
-                        Size::new(4.0 * chart.scaling, bar_height)
+    
+                    frame.stroke(
+                        &Path::line(
+                            Point::new(0.0, y_pos),
+                            Point::new(region.x + region.width, y_pos),
+                        ),
+                        marker_line,
                     );
                     );
-                    let color = if kline.close >= kline.open { Color::from_rgba8(81, 205, 160, 0.8) } else { Color::from_rgba8(192, 80, 77, 0.8) };
-
-                    frame.fill(&bar, color);
-                }
-            }
+                });
+            });
         });
         });
 
 
         if chart.crosshair {
         if chart.crosshair {
-            let crosshair = chart.crosshair_cache.draw(renderer, bounds.size(), |frame| {
+            let crosshair = chart.cache.crosshair.draw(renderer, bounds_size, |frame| {
                 if let Some(cursor_position) = cursor.position_in(bounds) {
                 if let Some(cursor_position) = cursor.position_in(bounds) {
-                    let line = Path::line(
-                        Point::new(0.0, cursor_position.y), 
-                        Point::new(bounds.width, 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;
-
-                    if snap_x.is_nan() {
-                        return;
-                    }
-
-                    let line = Path::line(
-                        Point::new(snap_x as f32, 0.0), 
-                        Point::new(snap_x as f32, bounds.height)
-                    );
-                    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: String = if kline.volume.0 != -1.0 {
-                            format!(
-                                "O: {} H: {} L: {} C: {}\nBuyV: {:.0} SellV: {:.0}",
-                                kline.open, kline.high, kline.low, kline.close, kline.volume.0, kline.volume.1
-                            )
-                        } else {
-                            format!(
-                                "O: {} H: {} L: {} C: {}\nVolume: {:.0}",
-                                kline.open, kline.high, kline.low, kline.close, kline.volume.1
-                            )
-                        };
+                    let (_, rounded_timestamp) =
+                        chart.draw_crosshair(frame, theme, bounds_size, cursor_position);
+
+                    if let Some((_, kline)) = self
+                        .data_points
+                        .iter()
+                        .find(|(time, _)| **time == rounded_timestamp)
+                    {
+                        let tooltip_text = format!(
+                            "O: {}   H: {}   L: {}   C: {}",
+                            kline.open,
+                            kline.high,
+                            kline.low,
+                            kline.close,
+                        );
 
 
                         let text = canvas::Text {
                         let text = canvas::Text {
                             content: tooltip_text,
                             content: tooltip_text,
-                            position: Point::new(10.0, 10.0),
+                            position: Point::new(8.0, 8.0),
                             size: iced::Pixels(12.0),
                             size: iced::Pixels(12.0),
-                            color: Color::from_rgba8(120, 120, 120, 1.0),
+                            color: palette.background.base.text,
                             ..canvas::Text::default()
                             ..canvas::Text::default()
                         };
                         };
                         frame.fill_text(text);
                         frame.fill_text(text);
@@ -529,9 +530,9 @@ impl canvas::Program<Message> for CandlestickChart {
                 }
                 }
             });
             });
 
 
-            vec![background, crosshair, candlesticks]
-        }   else {
-            vec![background, candlesticks]
+            vec![candlesticks, crosshair]
+        } else {
+            vec![candlesticks]
         }
         }
     }
     }
 
 
@@ -544,14 +545,28 @@ impl canvas::Program<Message> for CandlestickChart {
         match interaction {
         match interaction {
             Interaction::Panning { .. } => mouse::Interaction::Grabbing,
             Interaction::Panning { .. } => mouse::Interaction::Grabbing,
             Interaction::Zoomin { .. } => mouse::Interaction::ZoomIn,
             Interaction::Zoomin { .. } => mouse::Interaction::ZoomIn,
-            Interaction::None if cursor.is_over(bounds) => {
-                if self.chart.crosshair {
-                    mouse::Interaction::Crosshair
-                } else {
-                    mouse::Interaction::default()
+            Interaction::None => {
+                if cursor.is_over(Rectangle {
+                    x: bounds.x,
+                    y: bounds.y,
+                    width: bounds.width,
+                    height: bounds.height - 8.0,
+                }) {
+                    if self.chart.crosshair {
+                        return mouse::Interaction::Crosshair;
+                    }
+                } else if cursor.is_over(Rectangle {
+                    x: bounds.x,
+                    y: bounds.y + bounds.height - 8.0,
+                    width: bounds.width,
+                    height: 8.0,
+                }) {
+                    return mouse::Interaction::ResizingVertically;
                 }
                 }
+
+                mouse::Interaction::default()
             }
             }
-            Interaction::None => { mouse::Interaction::default() }
+            _ => mouse::Interaction::default(),
         }
         }
     }
     }
-}
+}

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 608 - 501
src/charts/footprint.rs


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 554 - 634
src/charts/heatmap.rs


+ 109 - 0
src/charts/indicators.rs

@@ -0,0 +1,109 @@
+pub mod volume;
+pub mod open_interest;
+
+use std::{any::Any, fmt::{self, Debug, Display}};
+
+use serde::{Deserialize, Serialize};
+
+pub trait Indicator: PartialEq + Display + ToString + Debug + 'static  {
+    fn get_available() -> &'static [Self] where Self: Sized;
+    fn get_enabled(indicators: &[Self]) -> impl Iterator<Item = &Self> 
+    where
+        Self: Sized,
+    {
+        Self::get_available()
+            .iter()
+            .filter(move |indicator| indicators.contains(indicator))
+    }
+    fn as_any(&self) -> &dyn Any;
+}
+
+/// Candlestick chart indicators
+#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
+pub enum CandlestickIndicator {
+    Volume,
+    OpenInterest,
+}
+
+impl Indicator for CandlestickIndicator {
+    fn get_available() -> &'static [Self] {
+        &Self::ALL
+    }
+
+    fn as_any(&self) -> &dyn Any {
+        self
+    }
+}
+
+impl CandlestickIndicator {
+    const ALL: [CandlestickIndicator; 2] = [CandlestickIndicator::Volume, CandlestickIndicator::OpenInterest];
+}
+
+impl Display for CandlestickIndicator {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            CandlestickIndicator::Volume => write!(f, "Volume"),
+            CandlestickIndicator::OpenInterest => write!(f, "Open Interest"),
+        }
+    }
+}
+
+/// Heatmap chart indicators
+#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
+pub enum HeatmapIndicator {
+    Volume,
+    Spread,
+}
+
+impl Indicator for HeatmapIndicator {
+    fn get_available() -> &'static [Self] {
+        &Self::ALL
+    }
+
+    fn as_any(&self) -> &dyn Any {
+        self
+    }
+}
+
+impl HeatmapIndicator {
+    const ALL: [HeatmapIndicator; 2] = [HeatmapIndicator::Volume, HeatmapIndicator::Spread];
+}
+
+impl Display for HeatmapIndicator {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            HeatmapIndicator::Volume => write!(f, "Volume"),
+            HeatmapIndicator::Spread => write!(f, "Spread"),
+        }
+    }
+}
+
+/// Footprint chart indicators
+#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
+pub enum FootprintIndicator {
+    Volume,
+    OpenInterest,
+}
+
+impl Indicator for FootprintIndicator {
+    fn get_available() -> &'static [Self] {
+        &Self::ALL
+    }
+
+    fn as_any(&self) -> &dyn Any {
+        self
+    }
+}
+
+impl FootprintIndicator {
+    const ALL: [FootprintIndicator; 2] = [FootprintIndicator::Volume, FootprintIndicator::OpenInterest];
+}
+
+impl Display for FootprintIndicator {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            FootprintIndicator::Volume => write!(f, "Volume"),
+            FootprintIndicator::OpenInterest => write!(f, "Open Interest"),
+        }
+    }
+}

+ 514 - 0
src/charts/indicators/open_interest.rs

@@ -0,0 +1,514 @@
+use std::collections::BTreeMap;
+
+use iced::widget::{container, row, Canvas};
+use iced::{mouse, Element, Length, Point, Rectangle, Renderer, Size, Theme, Vector};
+use iced::widget::canvas::{self, Cache, Event, Geometry, LineDash, Path, Stroke};
+
+use crate::charts::{
+    calc_price_step, convert_to_qty_abbr, round_to_tick, AxisLabel, Caches, CommonChartData, Interaction, Label, Message
+};
+use crate::data_providers::format_with_commas;
+
+pub fn create_indicator_elem<'a>(
+    chart_state: &'a CommonChartData,
+    cache: &'a Caches, 
+    data: &'a BTreeMap<i64, f32>,
+    earliest: i64,
+    latest: i64,
+) -> Element<'a, Message> {
+    let indi_chart = Canvas::new(OpenInterest {
+        indicator_cache: &cache.main,
+        crosshair_cache: &cache.crosshair,
+        crosshair: chart_state.crosshair,
+        max: chart_state.latest_x,
+        scaling: chart_state.scaling,
+        translation_x: chart_state.translation.x,
+        timeframe: chart_state.timeframe as u32,
+        cell_width: chart_state.cell_width,
+        data_points: data,
+        chart_bounds: chart_state.bounds,
+    })
+    .height(Length::Fill)
+    .width(Length::Fill);
+
+    let mut max_value: f32 = f32::MIN;
+    let mut min_value: f32 = f32::MAX;
+
+    data.range(earliest..=latest)
+        .for_each(|(_, value)| {
+            max_value = max_value.max(*value);
+            min_value = min_value.min(*value);
+        });
+
+    let value_range = max_value - min_value;
+    let padding = value_range * 0.01;
+    max_value += padding;
+    min_value -= padding;
+
+    let indi_labels = Canvas::new(OpenInterestLabels {
+        label_cache: &cache.y_labels,
+        max: max_value,
+        min: min_value,
+        crosshair: chart_state.crosshair,
+        chart_bounds: chart_state.bounds,
+    })
+    .height(Length::Fill)
+    .width(Length::Fixed(60.0 + (chart_state.decimals as f32 * 2.0)));
+
+    row![
+        indi_chart,
+        container(indi_labels),
+    ].into()
+}
+
+pub struct OpenInterest<'a> {
+    pub indicator_cache: &'a Cache,
+    pub crosshair_cache: &'a Cache,
+    pub crosshair: bool,
+    pub max: i64,
+    pub scaling: f32,
+    pub translation_x: f32,
+    pub timeframe: u32,
+    pub cell_width: f32,
+    pub data_points: &'a BTreeMap<i64, f32>,
+    pub chart_bounds: Rectangle,
+}
+
+impl OpenInterest<'_> {
+    fn visible_region(&self, size: Size) -> Rectangle {
+        let width = size.width / self.scaling;
+        let height = size.height / self.scaling;
+
+        Rectangle {
+            x: -self.translation_x - width / 2.0,
+            y: 0.0,
+            width,
+            height,
+        }
+    }
+
+    fn x_to_time(&self, x: f32) -> i64 {
+        let time_per_cell = self.timeframe;
+        self.max + ((x / self.cell_width) * time_per_cell as f32) as i64
+    }
+
+    fn time_to_x(&self, time: i64) -> f32 {
+        let time_per_cell = self.timeframe;
+        let x = (time - self.max) as f32 / time_per_cell as f32;
+        x * self.cell_width
+    }
+}
+
+impl canvas::Program<Message> for OpenInterest<'_> {
+    type State = Interaction;
+
+    fn update(
+        &self,
+        interaction: &mut Interaction,
+        event: Event,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> Option<canvas::Action<Message>> {
+        match event {
+            Event::Mouse(mouse::Event::CursorMoved { .. }) => {
+                let message = match *interaction {
+                    Interaction::None => {
+                        if self.crosshair && cursor.is_over(bounds) {
+                            Some(Message::CrosshairMoved)
+                        } else {
+                            None
+                        }
+                    }
+                    _ => None,
+                };
+
+                let action =
+                    message.map_or(canvas::Action::request_redraw(), canvas::Action::publish);
+
+                Some(match interaction {
+                    Interaction::None => action,
+                    _ => action.and_capture(),
+                })
+            }
+            _ => None,
+        }
+    }
+
+    fn draw(
+        &self,
+        _state: &Self::State,
+        renderer: &Renderer,
+        theme: &Theme,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> Vec<Geometry> {
+        if self.data_points.is_empty() {
+            return vec![];
+        }
+
+        let center = Vector::new(bounds.width / 2.0, bounds.height / 2.0);
+
+        let palette = theme.extended_palette();
+
+        let indicator = self.indicator_cache.draw(renderer, bounds.size(), |frame| {
+            frame.translate(center);
+            frame.scale(self.scaling);
+            frame.translate(Vector::new(
+                self.translation_x,
+                (-bounds.height / self.scaling) / 2.0,
+            ));
+
+            let region = self.visible_region(frame.size());
+
+            frame.fill_rectangle(
+                Point::new(region.x, 0.0),
+                Size::new(region.width, 1.0 / self.scaling),
+                if palette.is_dark {
+                    palette.background.weak.color.scale_alpha(0.2)
+                } else {
+                    palette.background.strong.color.scale_alpha(0.2)
+                },
+            );
+
+            let (earliest, latest) = (
+                self.x_to_time(region.x) - i64::from(self.timeframe / 2),
+                self.x_to_time(region.x + region.width) + i64::from(self.timeframe / 2),
+            );
+
+            let mut max_value: f32 = f32::MIN;
+            let mut min_value: f32 = f32::MAX;
+
+            self.data_points
+                .range(earliest..=latest)
+                .for_each(|(_, value)| {
+                    max_value = max_value.max(*value);
+                    min_value = min_value.min(*value);
+                });
+
+            let padding = (max_value - min_value) * 0.08;
+            max_value += padding;
+            min_value -= padding;
+
+            let points: Vec<Point> = self.data_points
+                .range(earliest..=latest)
+                .map(|(timestamp, value)| {
+                    let x_position = self.time_to_x(*timestamp);
+                    let normalized_height = if max_value > min_value {
+                        (value - min_value) / (max_value - min_value)
+                    } else {
+                        0.0
+                    };
+                    let y_position = (bounds.height / self.scaling) - 
+                        (normalized_height * (bounds.height / self.scaling));
+                    
+                    Point::new(x_position - (self.cell_width / 2.0), y_position)
+                })
+                .collect();
+
+            if points.len() >= 2 {
+                for points in points.windows(2) {
+                    let stroke = Stroke {
+                        width: 1.0,
+                        ..Stroke::default()
+                    };
+                    frame.stroke(
+                        &Path::line(points[0], points[1]), 
+                        Stroke::with_color(stroke, palette.secondary.strong.color)
+                    )
+                }
+            }
+
+            let radius = (self.cell_width * 0.2).min(5.0);
+            for point in points {
+                frame.fill(
+                    &Path::circle(Point::new(point.x, point.y), radius),
+                    palette.secondary.strong.color,
+                );
+            }
+        });
+
+        if self.crosshair {
+            let crosshair = self.crosshair_cache.draw(renderer, bounds.size(), |frame| {
+                let dashed_line = Stroke::with_color(
+                    Stroke {
+                        width: 1.0,
+                        line_dash: LineDash {
+                            segments: &[4.0, 4.0],
+                            offset: 8,
+                        },
+                        ..Default::default()
+                    },
+                    palette.secondary.strong.color
+                        .scale_alpha(
+                            if palette.is_dark {
+                                0.6
+                            } else {
+                                1.0
+                            },
+                        ),
+                );
+
+                if let Some(cursor_position) = cursor.position_in(self.chart_bounds) {
+                    let region = self.visible_region(frame.size());
+                    
+                    // Vertical time line
+                    let earliest = self.x_to_time(region.x) as f64;
+                    let latest = self.x_to_time(region.x + region.width) as f64;
+
+                    let crosshair_ratio = f64::from(cursor_position.x / bounds.width);
+                    let crosshair_millis = earliest + crosshair_ratio * (latest - earliest);
+
+                    let rounded_timestamp =
+                        (crosshair_millis / (self.timeframe as f64)).round() as i64 * self.timeframe as i64;
+                    let snap_ratio = ((rounded_timestamp as f64 - earliest) / (latest - earliest)) as f32;
+
+                    frame.stroke(
+                        &Path::line(
+                            Point::new(snap_ratio * bounds.width, 0.0),
+                            Point::new(snap_ratio * bounds.width, bounds.height),
+                        ),
+                        dashed_line,
+                    );
+
+                    if let Some((_, oi_value)) = self
+                        .data_points
+                        .iter()
+                        .find(|(time, _)| **time == rounded_timestamp)
+                    {
+                        let next_value = self
+                            .data_points
+                            .range((rounded_timestamp + (self.timeframe as i64))..=i64::MAX)
+                            .next()
+                            .map(|(_, val)| *val);
+
+                        let change_text = if let Some(next_oi) = next_value {
+                            let difference = next_oi - *oi_value;
+                            let sign = if difference >= 0.0 { "+" } else { "" };
+                            format!("Change: {}{}", sign, format_with_commas(difference))
+                        } else {
+                            "Change: N/A".to_string()
+                        };
+
+                        let tooltip_text = format!(
+                            "Open Interest: {}\n{}",
+                            format_with_commas(*oi_value),
+                            change_text,
+                        );
+
+                        let text = canvas::Text {
+                            content: tooltip_text,
+                            position: Point::new(8.0, 2.0),
+                            size: iced::Pixels(10.0),
+                            color: palette.background.base.text,
+                            ..canvas::Text::default()
+                        };
+                        frame.fill_text(text);
+
+                        frame.fill_rectangle(
+                            Point::new(4.0, 0.0),
+                            Size::new(140.0, 28.0),
+                            palette.background.base.color,
+                        );
+                    }
+                } else if let Some(cursor_position) = cursor.position_in(bounds) {
+                    // Horizontal price line
+                    let highest = self.max as f32;
+                    let lowest = 0.0;
+
+                    let crosshair_ratio = cursor_position.y / bounds.height;
+                    let crosshair_price = highest + crosshair_ratio * (lowest - highest);
+
+                    let rounded_price = round_to_tick(crosshair_price, 1.0);
+                    let snap_ratio = (rounded_price - highest) / (lowest - highest);
+
+                    frame.stroke(
+                        &Path::line(
+                            Point::new(0.0, snap_ratio * bounds.height),
+                            Point::new(bounds.width, snap_ratio * bounds.height),
+                        ),
+                        dashed_line,
+                    );
+                }
+            });
+
+            vec![indicator, crosshair]
+        } else {
+            vec![indicator]
+        }
+    }
+
+    fn mouse_interaction(
+        &self,
+        interaction: &Interaction,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> mouse::Interaction {
+        match interaction {
+            Interaction::Panning { .. } => mouse::Interaction::Grabbing,
+            Interaction::Zoomin { .. } => mouse::Interaction::ZoomIn,
+            Interaction::None if cursor.is_over(bounds) => {
+                if self.crosshair {
+                    mouse::Interaction::Crosshair
+                } else {
+                    mouse::Interaction::default()
+                }
+            }
+            _ => mouse::Interaction::default(),
+        }
+    }
+}
+
+pub struct OpenInterestLabels<'a> {
+    pub label_cache: &'a Cache,
+    pub crosshair: bool,
+    pub max: f32,
+    pub min: f32,
+    pub chart_bounds: Rectangle,
+}
+
+impl canvas::Program<Message> for OpenInterestLabels<'_> {
+    type State = Interaction;
+
+    fn update(
+        &self,
+        _state: &mut Self::State,
+        _event: Event,
+        _bounds: Rectangle,
+        _cursor: mouse::Cursor,
+    ) -> Option<canvas::Action<Message>> {
+        None
+    }
+
+    fn draw(
+        &self,
+        _state: &Self::State,
+        renderer: &Renderer,
+        theme: &Theme,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> Vec<Geometry> {
+        let palette = theme.extended_palette();
+
+        let highest = self.max;
+        let lowest = self.min;
+
+        let text_size = 12.0;
+
+        let labels = self.label_cache.draw(renderer, bounds.size(), |frame| {
+            frame.fill_rectangle(
+                Point::new(0.0, 0.0),
+                Size::new(bounds.width, 1.0),
+                if palette.is_dark {
+                    palette.background.weak.color.scale_alpha(0.2)
+                } else {
+                    palette.background.strong.color.scale_alpha(0.2)
+                },
+            );
+
+            frame.fill_rectangle(
+                Point::new(0.0, 0.0),
+                Size::new(1.0, bounds.height),
+                if palette.is_dark {
+                    palette.background.weak.color.scale_alpha(0.4)
+                } else {
+                    palette.background.strong.color.scale_alpha(0.4)
+                },
+            );
+
+            let y_range = highest - lowest;
+
+            let y_labels_can_fit: i32 = (bounds.height / (text_size * 2.0)) as i32;
+
+            let mut all_labels: Vec<AxisLabel> =
+                Vec::with_capacity((y_labels_can_fit + 2) as usize); // +2 for last_price and crosshair
+
+            let rect = |y_pos: f32, label_amt: i16| {
+                let label_offset = text_size + (f32::from(label_amt) * (text_size / 2.0) + 2.0);
+
+                Rectangle {
+                    x: 6.0,
+                    y: y_pos - label_offset / 2.0,
+                    width: bounds.width - 8.0,
+                    height: label_offset,
+                }
+            };
+
+            // Regular price labels (priority 1)
+            let (step, rounded_lowest) = calc_price_step(highest, lowest, y_labels_can_fit, 1.0);
+
+            let mut y = rounded_lowest;
+
+            while y <= highest {
+                let y_position = bounds.height - ((y - lowest) / y_range * bounds.height);
+
+                if y > 0.0 {
+                    let text_content = convert_to_qty_abbr(y);
+
+                    let label = Label {
+                        content: text_content,
+                        background_color: None,
+                        marker_color: if palette.is_dark {
+                            palette.background.weak.color.scale_alpha(0.6)
+                        } else {
+                            palette.background.strong.color.scale_alpha(0.6)
+                        },
+                        text_color: palette.background.base.text,
+                        text_size: 12.0,
+                    };
+
+                    all_labels.push(AxisLabel::Y(rect(y_position, 1), label, None));
+                }
+
+                y += step;
+            }
+
+            // Crosshair price (priority 3)
+            if self.crosshair {
+                let common_bounds = Rectangle {
+                    x: self.chart_bounds.x,
+                    y: bounds.y,
+                    width: self.chart_bounds.width,
+                    height: bounds.height,
+                };
+
+                if let Some(crosshair_pos) = cursor.position_in(common_bounds) {
+                    let raw_price =
+                        lowest + (y_range * (bounds.height - crosshair_pos.y) / bounds.height);
+                    let rounded_price = round_to_tick(raw_price, 1.0);
+                    let y_position =
+                        bounds.height - ((rounded_price - lowest) / y_range * bounds.height);
+
+                    let text_content = convert_to_qty_abbr(rounded_price);
+
+                    let label = Label {
+                        content: text_content,
+                        background_color: Some(palette.secondary.base.color),
+                        marker_color: palette.background.strong.color,
+                        text_color: palette.secondary.base.text,
+                        text_size: 12.0,
+                    };
+
+                    all_labels.push(AxisLabel::Y(rect(y_position, 1), label, None));
+                }
+            }
+
+            AxisLabel::filter_and_draw(&all_labels, frame);
+        });
+
+        vec![labels]
+    }
+
+    fn mouse_interaction(
+        &self,
+        interaction: &Interaction,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> mouse::Interaction {
+        match interaction {
+            Interaction::Zoomin { .. } => mouse::Interaction::ResizingVertically,
+            Interaction::Panning { .. } => mouse::Interaction::None,
+            Interaction::None if cursor.is_over(bounds) => mouse::Interaction::ResizingVertically,
+            _ => mouse::Interaction::default(),
+        }
+    }
+}

+ 507 - 0
src/charts/indicators/volume.rs

@@ -0,0 +1,507 @@
+use std::collections::BTreeMap;
+
+use iced::widget::{container, row, Canvas};
+use iced::{Element, Length};
+use iced::{mouse, Point, Rectangle, Renderer, Size, Theme, Vector};
+use iced::widget::canvas::{self, Cache, Event, Geometry, LineDash, Path, Stroke};
+
+use crate::charts::{
+    calc_price_step, convert_to_qty_abbr, round_to_tick, AxisLabel, Caches, CommonChartData, Interaction, Label, Message
+};
+use crate::data_providers::format_with_commas;
+
+pub fn create_indicator_elem<'a>(
+    chart_state: &'a CommonChartData,
+    cache: &'a Caches, 
+    data: &'a BTreeMap<i64, (f32, f32)>,
+    earliest: i64,
+    latest: i64,
+) -> Element<'a, Message> {
+    let indi_chart = Canvas::new(VolumeIndicator {
+        indicator_cache: &cache.main,
+        crosshair_cache: &cache.crosshair,
+        crosshair: chart_state.crosshair,
+        max: chart_state.latest_x,
+        scaling: chart_state.scaling,
+        translation_x: chart_state.translation.x,
+        timeframe: chart_state.timeframe as u32,
+        cell_width: chart_state.cell_width,
+        data_points: data,
+        chart_bounds: chart_state.bounds,
+    })
+    .height(Length::Fill)
+    .width(Length::Fill);
+
+    let max_volume = data
+        .range(earliest..=latest)
+        .map(|(_, (buy, sell))| buy.max(*sell))
+        .max_by(|a, b| a.partial_cmp(b).unwrap())
+        .unwrap_or(0.0);
+
+    let indi_labels = Canvas::new(VolumeLabels {
+        label_cache: &cache.y_labels,
+        max: max_volume,
+        min: 0.0,
+        crosshair: chart_state.crosshair,
+        chart_bounds: chart_state.bounds,
+    })
+    .height(Length::Fill)
+    .width(Length::Fixed(60.0 + (chart_state.decimals as f32 * 2.0)));
+
+    row![
+        indi_chart,
+        container(indi_labels),
+    ].into()
+}
+
+pub struct VolumeIndicator<'a> {
+    pub indicator_cache: &'a Cache,
+    pub crosshair_cache: &'a Cache,
+    pub crosshair: bool,
+    pub max: i64,
+    pub scaling: f32,
+    pub translation_x: f32,
+    pub timeframe: u32,
+    pub cell_width: f32,
+    pub data_points: &'a BTreeMap<i64, (f32, f32)>,
+    pub chart_bounds: Rectangle,
+}
+
+impl VolumeIndicator<'_> {
+    fn visible_region(&self, size: Size) -> Rectangle {
+        let width = size.width / self.scaling;
+        let height = size.height / self.scaling;
+
+        Rectangle {
+            x: -self.translation_x - width / 2.0,
+            y: 0.0,
+            width,
+            height,
+        }
+    }
+
+    fn x_to_time(&self, x: f32) -> i64 {
+        let time_per_cell = self.timeframe;
+        self.max + ((x / self.cell_width) * time_per_cell as f32) as i64
+    }
+
+    fn time_to_x(&self, time: i64) -> f32 {
+        let time_per_cell = self.timeframe;
+        let x = (time - self.max) as f32 / time_per_cell as f32;
+        x * self.cell_width
+    }
+}
+
+impl canvas::Program<Message> for VolumeIndicator<'_> {
+    type State = Interaction;
+
+    fn update(
+        &self,
+        interaction: &mut Interaction,
+        event: Event,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> Option<canvas::Action<Message>> {
+        match event {
+            Event::Mouse(mouse::Event::CursorMoved { .. }) => {
+                let message = match *interaction {
+                    Interaction::None => {
+                        if self.crosshair && cursor.is_over(bounds) {
+                            Some(Message::CrosshairMoved)
+                        } else {
+                            None
+                        }
+                    }
+                    _ => None,
+                };
+
+                let action =
+                    message.map_or(canvas::Action::request_redraw(), canvas::Action::publish);
+
+                Some(match interaction {
+                    Interaction::None => action,
+                    _ => action.and_capture(),
+                })
+            }
+            _ => None,
+        }
+    }
+
+    fn draw(
+        &self,
+        _state: &Self::State,
+        renderer: &Renderer,
+        theme: &Theme,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> Vec<Geometry> {
+        if self.data_points.is_empty() {
+            return vec![];
+        }
+
+        let center = Vector::new(bounds.width / 2.0, bounds.height / 2.0);
+
+        let palette = theme.extended_palette();
+
+        let indicator = self.indicator_cache.draw(renderer, bounds.size(), |frame| {
+            frame.translate(center);
+            frame.scale(self.scaling);
+            frame.translate(Vector::new(
+                self.translation_x,
+                (-bounds.height / self.scaling) / 2.0,
+            ));
+
+            let region = self.visible_region(frame.size());
+
+            frame.fill_rectangle(
+                Point::new(region.x, 0.0),
+                Size::new(region.width, 1.0 / self.scaling),
+                if palette.is_dark {
+                    palette.background.weak.color.scale_alpha(0.2)
+                } else {
+                    palette.background.strong.color.scale_alpha(0.2)
+                },
+            );
+
+            let (earliest, latest) = (
+                self.x_to_time(region.x) - i64::from(self.timeframe / 2),
+                self.x_to_time(region.x + region.width) + i64::from(self.timeframe / 2),
+            );
+
+            let mut max_volume: f32 = 0.0;
+
+            self.data_points
+                .range(earliest..=latest)
+                .for_each(|(_, (buy_volume, sell_volume))| {
+                    max_volume = max_volume.max(buy_volume.max(*sell_volume));
+                });
+
+            self.data_points.range(earliest..=latest).for_each(
+                |(timestamp, (buy_volume, sell_volume))| {
+                    let x_position = self.time_to_x(*timestamp);
+
+                    if max_volume > 0.0 {
+                        if *buy_volume != -1.0 {
+                            let buy_bar_height =
+                                (buy_volume / max_volume) * (bounds.height / self.scaling);
+                            let sell_bar_height =
+                                (sell_volume / max_volume) * (bounds.height / self.scaling);
+
+                            let bar_width = (self.cell_width / 2.0) * 0.9;
+
+                            frame.fill_rectangle(
+                                Point::new(
+                                    x_position - bar_width,
+                                    (region.y + region.height) - sell_bar_height,
+                                ),
+                                Size::new(bar_width, sell_bar_height),
+                                palette.danger.base.color,
+                            );
+
+                            frame.fill_rectangle(
+                                Point::new(x_position, (region.y + region.height) - buy_bar_height),
+                                Size::new(bar_width, buy_bar_height),
+                                palette.success.base.color,
+                            );
+                        } else {
+                            let bar_height =
+                                (sell_volume / max_volume) * (bounds.height / self.scaling);
+
+                            let bar_width = self.cell_width * 0.9;
+
+                            frame.fill_rectangle(
+                                Point::new(
+                                    x_position - (bar_width / 2.0),
+                                    (bounds.height / self.scaling) - bar_height,
+                                ),
+                                Size::new(bar_width, bar_height),
+                                palette.secondary.strong.color,
+                            );
+                        }
+                    }
+                },
+            );
+        });
+
+        if self.crosshair {
+            let crosshair = self.crosshair_cache.draw(renderer, bounds.size(), |frame| {
+                let dashed_line = Stroke::with_color(
+                    Stroke {
+                        width: 1.0,
+                        line_dash: LineDash {
+                            segments: &[4.0, 4.0],
+                            offset: 8,
+                        },
+                        ..Default::default()
+                    },
+                    palette.secondary.strong.color
+                        .scale_alpha(
+                            if palette.is_dark {
+                                0.6
+                            } else {
+                                1.0
+                            },
+                        ),
+                );
+
+                if let Some(cursor_position) = cursor.position_in(self.chart_bounds) {
+                    let region = self.visible_region(frame.size());
+
+                    // Vertical time line
+                    let earliest = self.x_to_time(region.x) as f64;
+                    let latest = self.x_to_time(region.x + region.width) as f64;
+
+                    let crosshair_ratio = f64::from(cursor_position.x / bounds.width);
+                    let crosshair_millis = earliest + crosshair_ratio * (latest - earliest);
+
+                    let rounded_timestamp =
+                        (crosshair_millis / (self.timeframe as f64)).round() as i64 * self.timeframe as i64;
+                    let snap_ratio = ((rounded_timestamp as f64 - earliest) / (latest - earliest)) as f32;
+
+                    frame.stroke(
+                        &Path::line(
+                            Point::new(snap_ratio * bounds.width, 0.0),
+                            Point::new(snap_ratio * bounds.width, bounds.height),
+                        ),
+                        dashed_line,
+                    );
+
+                    if let Some((_, (buy_v, sell_v))) = self
+                        .data_points
+                        .iter()
+                        .find(|(time, _)| **time == rounded_timestamp)
+                    {
+                        let mut tooltip_bg_height = 28.0;
+
+                        let tooltip_text: String = if *buy_v != -1.0 {
+                            format!(
+                                "Buy Volume: {}\nSell Volume: {}",
+                                format_with_commas(*buy_v),
+                                format_with_commas(*sell_v),
+                            )
+                        } else {
+                            tooltip_bg_height = 14.0;
+
+                            format!(
+                                "Volume: {}",
+                                format_with_commas(*sell_v),
+                            )
+                        };
+
+                        let text = canvas::Text {
+                            content: tooltip_text,
+                            position: Point::new(8.0, 2.0),
+                            size: iced::Pixels(10.0),
+                            color: palette.background.base.text,
+                            ..canvas::Text::default()
+                        };
+                        frame.fill_text(text);
+
+                        frame.fill_rectangle(
+                            Point::new(4.0, 0.0),
+                            Size::new(140.0, tooltip_bg_height),
+                            palette.background.base.color,
+                        );
+                    }
+                } else if let Some(cursor_position) = cursor.position_in(bounds) {
+                    // Horizontal price line
+                    let highest = self.max as f32;
+                    let lowest = 0.0;
+
+                    let crosshair_ratio = cursor_position.y / bounds.height;
+                    let crosshair_price = highest + crosshair_ratio * (lowest - highest);
+
+                    let rounded_price = round_to_tick(crosshair_price, 1.0);
+                    let snap_ratio = (rounded_price - highest) / (lowest - highest);
+
+                    frame.stroke(
+                        &Path::line(
+                            Point::new(0.0, snap_ratio * bounds.height),
+                            Point::new(bounds.width, snap_ratio * bounds.height),
+                        ),
+                        dashed_line,
+                    );
+                }
+            });
+
+            vec![indicator, crosshair]
+        } else {
+            vec![indicator]
+        }
+    }
+
+    fn mouse_interaction(
+        &self,
+        interaction: &Interaction,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> mouse::Interaction {
+        match interaction {
+            Interaction::Panning { .. } => mouse::Interaction::Grabbing,
+            Interaction::Zoomin { .. } => mouse::Interaction::ZoomIn,
+            Interaction::None if cursor.is_over(bounds) => {
+                if self.crosshair {
+                    mouse::Interaction::Crosshair
+                } else {
+                    mouse::Interaction::default()
+                }
+            }
+            _ => mouse::Interaction::default(),
+        }
+    }
+}
+
+pub struct VolumeLabels<'a> {
+    pub label_cache: &'a Cache,
+    pub crosshair: bool,
+    pub max: f32,
+    pub min: f32,
+    pub chart_bounds: Rectangle,
+}
+
+impl canvas::Program<Message> for VolumeLabels<'_> {
+    type State = Interaction;
+
+    fn update(
+        &self,
+        _state: &mut Self::State,
+        _event: Event,
+        _bounds: Rectangle,
+        _cursor: mouse::Cursor,
+    ) -> Option<canvas::Action<Message>> {
+        None
+    }
+
+    fn draw(
+        &self,
+        _state: &Self::State,
+        renderer: &Renderer,
+        theme: &Theme,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> Vec<Geometry> {
+        let palette = theme.extended_palette();
+
+        let highest = self.max;
+        let lowest = self.min;
+
+        let text_size = 12.0;
+
+        let labels = self.label_cache.draw(renderer, bounds.size(), |frame| {
+            frame.fill_rectangle(
+                Point::new(0.0, 0.0),
+                Size::new(bounds.width, 1.0),
+                if palette.is_dark {
+                    palette.background.weak.color.scale_alpha(0.2)
+                } else {
+                    palette.background.strong.color.scale_alpha(0.2)
+                },
+            );
+
+            frame.fill_rectangle(
+                Point::new(0.0, 0.0),
+                Size::new(1.0, bounds.height),
+                if palette.is_dark {
+                    palette.background.weak.color.scale_alpha(0.4)
+                } else {
+                    palette.background.strong.color.scale_alpha(0.4)
+                },
+            );
+
+            let y_range = highest - lowest;
+
+            let y_labels_can_fit: i32 = (bounds.height / (text_size * 2.0)) as i32;
+
+            let mut all_labels: Vec<AxisLabel> =
+                Vec::with_capacity((y_labels_can_fit + 2) as usize); // +2 for last_price and crosshair
+
+            let rect = |y_pos: f32, label_amt: i16| {
+                let label_offset = text_size + (f32::from(label_amt) * (text_size / 2.0) + 2.0);
+
+                Rectangle {
+                    x: 6.0,
+                    y: y_pos - label_offset / 2.0,
+                    width: bounds.width - 8.0,
+                    height: label_offset,
+                }
+            };
+
+            // Regular price labels (priority 1)
+            let (step, rounded_lowest) = calc_price_step(highest, lowest, y_labels_can_fit, 1.0);
+
+            let mut y = rounded_lowest;
+
+            while y <= highest {
+                let y_position = bounds.height - ((y - lowest) / y_range * bounds.height);
+
+                if y > 0.0 {
+                    let text_content = convert_to_qty_abbr(y);
+
+                    let label = Label {
+                        content: text_content,
+                        background_color: None,
+                        marker_color: if palette.is_dark {
+                            palette.background.weak.color.scale_alpha(0.6)
+                        } else {
+                            palette.background.strong.color.scale_alpha(0.6)
+                        },
+                        text_color: palette.background.base.text,
+                        text_size: 12.0,
+                    };
+
+                    all_labels.push(AxisLabel::Y(rect(y_position, 1), label, None));
+                }
+
+                y += step;
+            }
+
+            // Crosshair price (priority 3)
+            if self.crosshair {
+                let common_bounds = Rectangle {
+                    x: self.chart_bounds.x,
+                    y: bounds.y,
+                    width: self.chart_bounds.width,
+                    height: bounds.height,
+                };
+
+                if let Some(crosshair_pos) = cursor.position_in(common_bounds) {
+                    let raw_price =
+                        lowest + (y_range * (bounds.height - crosshair_pos.y) / bounds.height);
+                    let rounded_price = round_to_tick(raw_price, 1.0);
+                    let y_position =
+                        bounds.height - ((rounded_price - lowest) / y_range * bounds.height);
+
+                    let text_content = convert_to_qty_abbr(rounded_price);
+
+                    let label = Label {
+                        content: text_content,
+                        background_color: Some(palette.secondary.base.color),
+                        marker_color: palette.background.strong.color,
+                        text_color: palette.secondary.base.text,
+                        text_size: 12.0,
+                    };
+
+                    all_labels.push(AxisLabel::Y(rect(y_position, 1), label, None));
+                }
+            }
+
+            AxisLabel::filter_and_draw(&all_labels, frame);
+        });
+
+        vec![labels]
+    }
+
+    fn mouse_interaction(
+        &self,
+        interaction: &Interaction,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> mouse::Interaction {
+        match interaction {
+            Interaction::Zoomin { .. } => mouse::Interaction::ResizingVertically,
+            Interaction::Panning { .. } => mouse::Interaction::None,
+            Interaction::None if cursor.is_over(bounds) => mouse::Interaction::ResizingVertically,
+            _ => mouse::Interaction::default(),
+        }
+    }
+}

+ 79 - 76
src/charts/timeandsales.rs

@@ -1,13 +1,12 @@
-use chrono::NaiveDateTime;
-use iced::{
-    alignment, Element, Length
-};
-use iced::widget::{Column, Row, Container, Text, container, Space};
+use chrono::DateTime;
+use iced::{alignment, padding, Element, Length};
+use iced::widget::{column, container, responsive, row, text, Space};
 use crate::screen::dashboard::pane::Message;
 use crate::screen::dashboard::pane::Message;
-use crate::{style, data_providers::Trade};
+use crate::style::ts_table_container;
+use crate::data_providers::Trade;
 
 
 struct ConvertedTrade {
 struct ConvertedTrade {
-    time: NaiveDateTime,
+    time_str: String,
     price: f32,
     price: f32,
     qty: f32,
     qty: f32,
     is_sell: bool,
     is_sell: bool,
@@ -15,95 +14,99 @@ struct ConvertedTrade {
 pub struct TimeAndSales {
 pub struct TimeAndSales {
     recent_trades: Vec<ConvertedTrade>,
     recent_trades: Vec<ConvertedTrade>,
     size_filter: f32,
     size_filter: f32,
-    filter_sync_heatmap: bool,
+    max_filtered_qty: f32,
+    max_size: usize,
+    target_size: usize,
 }
 }
+
 impl TimeAndSales {
 impl TimeAndSales {
     pub fn new() -> Self {
     pub fn new() -> Self {
         Self {
         Self {
             recent_trades: Vec::new(),
             recent_trades: Vec::new(),
             size_filter: 0.0,
             size_filter: 0.0,
-            filter_sync_heatmap: false,
+            max_filtered_qty: 0.0,
+            max_size: 900,
+            target_size: 700,
         }
         }
     }
     }
-    
+
     pub fn set_size_filter(&mut self, value: f32) {
     pub fn set_size_filter(&mut self, value: f32) {
         self.size_filter = value;
         self.size_filter = value;
     }
     }
+
     pub fn get_size_filter(&self) -> f32 {
     pub fn get_size_filter(&self) -> f32 {
         self.size_filter
         self.size_filter
     }
     }
 
 
-    pub fn set_filter_sync_heatmap(&mut self, value: bool) {
-        self.filter_sync_heatmap = value;
-    }
-    pub fn get_filter_sync_heatmap(&self) -> bool {
-        self.filter_sync_heatmap
-    }
-
     pub fn update(&mut self, trades_buffer: &[Trade]) {
     pub fn update(&mut self, trades_buffer: &[Trade]) {
         for trade in trades_buffer {
         for trade in trades_buffer {
-            let trade_time = NaiveDateTime::from_timestamp(trade.time / 1000, (trade.time % 1000) as u32 * 1_000_000);
-            let converted_trade = ConvertedTrade {
-                time: trade_time,
-                price: trade.price,
-                qty: trade.qty,
-                is_sell: trade.is_sell,
-            };
-            self.recent_trades.push(converted_trade);
+            if let Some(trade_time) =
+                DateTime::from_timestamp(trade.time / 1000, (trade.time % 1000) as u32 * 1_000_000)
+            {
+                let converted_trade = ConvertedTrade {
+                    time_str: trade_time.format("%M:%S.%3f").to_string(),
+                    price: trade.price,
+                    qty: trade.qty,
+                    is_sell: trade.is_sell,
+                };
+
+                if (converted_trade.qty * converted_trade.price) >= self.size_filter {
+                    self.max_filtered_qty = self.max_filtered_qty.max(converted_trade.qty);
+                }
+
+                self.recent_trades.push(converted_trade);
+            }
         }
         }
 
 
-        if self.recent_trades.len() > 2000 {
-            let drain_to = self.recent_trades.len() - 2000;
-            self.recent_trades.drain(0..drain_to);
+        if self.recent_trades.len() > self.max_size {
+            let drain_amount = self.recent_trades.len() - self.target_size;
+
+            self.max_filtered_qty = self.recent_trades[drain_amount..]
+                .iter()
+                .filter(|t| (t.qty * t.price) >= self.size_filter)
+                .map(|t| t.qty)
+                .fold(0.0, f32::max);
+
+            self.recent_trades.drain(0..drain_amount);
         }
         }
     }
     }
+
     pub fn view(&self) -> Element<'_, Message> {
     pub fn view(&self) -> Element<'_, Message> {
-        let mut trades_column = Column::new()
-            .height(Length::Fill)
-            .padding(10);
-
-        let filtered_trades: Vec<_> = self.recent_trades.iter().filter(|trade| (trade.qty*trade.price) >= self.size_filter).collect();
-
-        let max_qty = filtered_trades.iter().map(|trade| trade.qty).fold(0.0, f32::max);
-    
-        if filtered_trades.is_empty() {
-            trades_column = trades_column.push(
-                Text::new("No trades")
-                    .width(Length::Fill)
-                    .height(Length::Fill)
-                    .size(16)
-            );
-        } else {
-            for trade in filtered_trades.iter().rev().take(80) {
-                let trade: &ConvertedTrade = trade;
-
-                let trade_row = Row::new()
-                    .push(
-                        container(Text::new(format!("{}", trade.time.format("%M:%S.%3f"))).size(14))
-                            .width(Length::FillPortion(8)).align_x(alignment::Horizontal::Center)
-                    )
-                    .push(
-                        container(Text::new(format!("{}", trade.price)).size(14))
-                            .width(Length::FillPortion(6))
-                    )
-                    .push(
-                        container(Text::new(if trade.is_sell { "Sell" } else { "Buy" }).size(14))
-                            .width(Length::FillPortion(4)).align_x(alignment::Horizontal::Left)
-                    )
-                    .push(
-                        container(Text::new(format!("{}", trade.qty)).size(14))
-                            .width(Length::FillPortion(4))
-                    );
-
-                let color_alpha = trade.qty / max_qty;
-    
-                trades_column = trades_column.push(container(trade_row)
-                    .style( move |_| if trade.is_sell { style::sell_side_red(color_alpha) } else { style::buy_side_green(color_alpha) }));
-    
-                trades_column = trades_column.push(Container::new(Space::new(Length::Fixed(0.0), Length::Fixed(5.0))));
+        responsive(move |size| {
+            let mut column = column![]
+                .padding(padding::top(4).left(4).right(4))
+                .height(Length::Fill);
+
+            let row_height = 16.0;
+            let rows_can_fit = size.height / row_height;
+
+            let filtered_trades_iter = self
+                .recent_trades
+                .iter()
+                .filter(|t| (t.qty * t.price) >= self.size_filter);
+
+            for trade in filtered_trades_iter.rev().take(rows_can_fit as usize) {
+                column = column.push(container(Space::new(
+                    Length::Fixed(0.0),
+                    Length::Fixed(2.0),
+                )));
+
+                let trade_row = row![
+                    container(text(&trade.time_str))
+                        .width(Length::FillPortion(8))
+                        .align_x(alignment::Horizontal::Center),
+                    container(text(trade.price)).width(Length::FillPortion(6)),
+                    container(text(trade.qty)).width(Length::FillPortion(4))
+                ]
+                .height(Length::Fixed(row_height));
+
+                column = column.push(container(trade_row).style(move |theme| {
+                    ts_table_container(theme, trade.is_sell, trade.qty / self.max_filtered_qty)
+                }));
             }
             }
-        }
-    
-        trades_column.into()  
-    }    
-}
+
+            column.into()
+        })
+        .into()
+    }
+}

+ 457 - 111
src/data_providers.rs

@@ -1,7 +1,58 @@
-use serde::{Deserialize, Serialize};
+use std::{
+    collections::BTreeMap,
+    fmt::{self, Write},
+    hash::Hash,
+};
+
+use ordered_float::OrderedFloat;
+use rust_decimal::{
+    prelude::{FromPrimitive, ToPrimitive},
+    Decimal,
+};
+use serde::{Deserialize, Deserializer, Serialize};
+use serde_json::Value;
 
 
 pub mod binance;
 pub mod binance;
 pub mod bybit;
 pub mod bybit;
+pub mod fetcher;
+
+#[allow(clippy::large_enum_variant)]
+pub enum State {
+    Disconnected,
+    Connected(FragmentCollector<TokioIo<Upgraded>>),
+}
+
+#[derive(Debug, Clone)]
+pub enum Event {
+    Connected(Connection),
+    Disconnected(String),
+    DepthReceived(Ticker, i64, Depth, Vec<Trade>),
+    KlineReceived(Ticker, Kline, Timeframe),
+}
+
+#[derive(Debug, Clone)]
+pub struct Connection;
+
+#[allow(dead_code)]
+#[derive(thiserror::Error, Debug)]
+pub enum StreamError {
+    #[error("Fetchrror: {0}")]
+    FetchError(#[from] reqwest::Error),
+    #[error("Parsing error: {0}")]
+    ParseError(String),
+    #[error("Stream error: {0}")]
+    WebsocketError(String),
+    #[error("Invalid request: {0}")]
+    InvalidRequest(String),
+    #[error("{0}")]
+    UnknownError(String),
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
+pub struct TickerInfo {
+    #[serde(rename = "tickSize")]
+    pub tick_size: f32,
+}
 
 
 #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
 #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
 pub enum StreamType {
 pub enum StreamType {
@@ -19,83 +70,105 @@ pub enum StreamType {
 
 
 // data types
 // data types
 #[derive(Debug, Clone, Copy, Default)]
 #[derive(Debug, Clone, Copy, Default)]
-pub struct Order {
-    pub price: f32,
-    pub qty: f32,
+struct Order {
+    price: f32,
+    qty: f32,
 }
 }
+
+impl<'de> Deserialize<'de> for Order {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        let arr: Vec<&str> = Vec::<&str>::deserialize(deserializer)?;
+        let price: f32 = arr[0].parse::<f32>().map_err(serde::de::Error::custom)?;
+        let qty: f32 = arr[1].parse::<f32>().map_err(serde::de::Error::custom)?;
+        Ok(Order { price, qty })
+    }
+}
+
 #[derive(Debug, Clone, Default)]
 #[derive(Debug, Clone, Default)]
 pub struct Depth {
 pub struct Depth {
-    pub time: i64,
-    pub bids: Vec<Order>,
-    pub asks: Vec<Order>,
+    pub bids: BTreeMap<OrderedFloat<f32>, f32>,
+    pub asks: BTreeMap<OrderedFloat<f32>, f32>,
 }
 }
 
 
 #[derive(Debug, Clone, Default)]
 #[derive(Debug, Clone, Default)]
-pub struct LocalDepthCache {
-    pub last_update_id: i64,
-    pub time: i64,
-    pub bids: Vec<Order>,
-    pub asks: Vec<Order>,
+struct VecLocalDepthCache {
+    last_update_id: i64,
+    time: i64,
+    bids: Vec<Order>,
+    asks: Vec<Order>,
+}
+
+#[derive(Debug, Clone, Default)]
+struct LocalDepthCache {
+    last_update_id: i64,
+    time: i64,
+    bids: BTreeMap<OrderedFloat<f32>, f32>,
+    asks: BTreeMap<OrderedFloat<f32>, f32>,
 }
 }
 
 
 impl LocalDepthCache {
 impl LocalDepthCache {
-    pub fn new() -> Self {
+    fn new() -> Self {
         Self {
         Self {
             last_update_id: 0,
             last_update_id: 0,
             time: 0,
             time: 0,
-            bids: Vec::new(),
-            asks: Vec::new(),
+            bids: BTreeMap::new(),
+            asks: BTreeMap::new(),
         }
         }
     }
     }
 
 
-    pub fn fetched(&mut self, new_depth: LocalDepthCache) {
-        self.last_update_id = new_depth.last_update_id;        
+    fn fetched(&mut self, new_depth: &VecLocalDepthCache) {
+        self.last_update_id = new_depth.last_update_id;
         self.time = new_depth.time;
         self.time = new_depth.time;
 
 
-        self.bids = new_depth.bids;
-        self.asks = new_depth.asks;
+        self.bids.clear();
+        new_depth.bids.iter().for_each(|order| {
+            self.bids.insert(OrderedFloat(order.price), order.qty);
+        });
+        self.asks.clear();
+        new_depth.asks.iter().for_each(|order| {
+            self.asks.insert(OrderedFloat(order.price), order.qty);
+        });
     }
     }
 
 
-    pub fn update_depth_cache(&mut self, new_depth: LocalDepthCache) {
+    fn update_depth_cache(&mut self, new_depth: &VecLocalDepthCache) {
         self.last_update_id = new_depth.last_update_id;
         self.last_update_id = new_depth.last_update_id;
         self.time = new_depth.time;
         self.time = new_depth.time;
 
 
-        for order in new_depth.bids.iter() {
+        new_depth.bids.iter().for_each(|order| {
             if order.qty == 0.0 {
             if order.qty == 0.0 {
-                self.bids.retain(|x| x.price != order.price);
-            } else if let Some(existing_order) = self.bids.iter_mut().find(|x| x.price == order.price) {
-                existing_order.qty = order.qty;
+                self.bids.remove((&order.price).into());
             } else {
             } else {
-                self.bids.push(*order);
+                self.bids.insert(OrderedFloat(order.price), order.qty);
             }
             }
-        }
-        for order in new_depth.asks.iter() {
+        });
+        new_depth.asks.iter().for_each(|order| {
             if order.qty == 0.0 {
             if order.qty == 0.0 {
-                self.asks.retain(|x| x.price != order.price);
-            } else if let Some(existing_order) = self.asks.iter_mut().find(|x| x.price == order.price) {
-                existing_order.qty = order.qty;
+                self.asks.remove((&order.price).into());
             } else {
             } else {
-                self.asks.push(*order);
+                self.asks.insert(OrderedFloat(order.price), order.qty);
             }
             }
-        }
+        });
     }
     }
 
 
-    pub fn get_depth(&self) -> Depth {
+    fn get_depth(&self) -> Depth {
         Depth {
         Depth {
-            time: self.time,
             bids: self.bids.clone(),
             bids: self.bids.clone(),
             asks: self.asks.clone(),
             asks: self.asks.clone(),
         }
         }
     }
     }
 
 
-    pub fn get_fetch_id(&self) -> i64 {
+    fn get_fetch_id(&self) -> i64 {
         self.last_update_id
         self.last_update_id
     }
     }
 }
 }
 
 
-#[derive(Default, Debug, Clone, Copy)]
+#[derive(Default, Debug, Clone, Copy, Deserialize)]
 pub struct Trade {
 pub struct Trade {
     pub time: i64,
     pub time: i64,
+    #[serde(deserialize_with = "bool_from_int")]
     pub is_sell: bool,
     pub is_sell: bool,
     pub price: f32,
     pub price: f32,
     pub qty: f32,
     pub qty: f32,
@@ -111,14 +184,13 @@ pub struct Kline {
     pub volume: (f32, f32),
     pub volume: (f32, f32),
 }
 }
 
 
-#[derive(Default, Debug, Clone, Copy)]
-pub struct FeedLatency {
-    pub time: i64,
-    pub depth_latency: i64,
-    pub trade_latency: Option<i64>,
+#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
+pub struct TickerStats {
+    pub mark_price: f32,
+    pub daily_price_chg: f32,
+    pub daily_volume: f32,
 }
 }
 
 
-
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
 pub struct TickMultiplier(pub u16);
 pub struct TickMultiplier(pub u16);
 
 
@@ -129,16 +201,67 @@ impl std::fmt::Display for TickMultiplier {
 }
 }
 
 
 impl TickMultiplier {
 impl TickMultiplier {
+    pub const ALL: [TickMultiplier; 8] = [
+        TickMultiplier(1),
+        TickMultiplier(2),
+        TickMultiplier(5),
+        TickMultiplier(10),
+        TickMultiplier(25),
+        TickMultiplier(50),
+        TickMultiplier(100),
+        TickMultiplier(200),
+    ];
+
+    /// Returns the final tick size after applying the user selected multiplier
+    ///
+    /// Usually used for price steps in chart scales
     pub fn multiply_with_min_tick_size(&self, min_tick_size: f32) -> f32 {
     pub fn multiply_with_min_tick_size(&self, min_tick_size: f32) -> f32 {
-        self.0 as f32 * min_tick_size
+        let multiplier = if let Some(m) = Decimal::from_f32(f32::from(self.0)) {
+            m
+        } else {
+            log::error!("Failed to convert multiplier: {}", self.0);
+            return f32::from(self.0) * min_tick_size;
+        };
+
+        let decimal_min_tick_size = if let Some(d) = Decimal::from_f32(min_tick_size) {
+            d
+        } else {
+            log::error!("Failed to convert min_tick_size: {}", min_tick_size);
+            return f32::from(self.0) * min_tick_size;
+        };
+
+        let normalized = multiplier * decimal_min_tick_size.normalize();
+        if let Some(tick_size) = normalized.to_f32() {
+            let decimal_places = calculate_decimal_places(min_tick_size);
+            round_to_decimal_places(tick_size, decimal_places)
+        } else {
+            log::error!("Failed to calculate tick size for multiplier: {}", self.0);
+            f32::from(self.0) * min_tick_size
+        }
     }
     }
 }
 }
 
 
+// ticksize rounding helpers
+fn calculate_decimal_places(value: f32) -> u32 {
+    let s = value.to_string();
+    if let Some(decimal_pos) = s.find('.') {
+        (s.len() - decimal_pos - 1) as u32
+    } else {
+        0
+    }
+}
+fn round_to_decimal_places(value: f32, places: u32) -> f32 {
+    let factor = 10.0f32.powi(places as i32);
+    (value * factor).round() / factor
+}
+
 // connection types
 // connection types
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
 pub enum Exchange {
 pub enum Exchange {
     BinanceFutures,
     BinanceFutures,
+    BinanceSpot,
     BybitLinear,
     BybitLinear,
+    BybitSpot,
 }
 }
 
 
 impl std::fmt::Display for Exchange {
 impl std::fmt::Display for Exchange {
@@ -148,51 +271,111 @@ impl std::fmt::Display for Exchange {
             "{}",
             "{}",
             match self {
             match self {
                 Exchange::BinanceFutures => "Binance Futures",
                 Exchange::BinanceFutures => "Binance Futures",
+                Exchange::BinanceSpot => "Binance Spot",
                 Exchange::BybitLinear => "Bybit Linear",
                 Exchange::BybitLinear => "Bybit Linear",
+                Exchange::BybitSpot => "Bybit Spot",
             }
             }
         )
         )
     }
     }
 }
 }
 impl Exchange {
 impl Exchange {
-    pub const ALL: [Exchange; 2] = [Exchange::BinanceFutures, Exchange::BybitLinear];
+    pub const ALL: [Exchange; 4] = [
+        Exchange::BinanceFutures,
+        Exchange::BybitLinear,
+        Exchange::BybitSpot,
+        Exchange::BinanceSpot,
+    ];
 }
 }
 
 
-impl std::fmt::Display for Ticker {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(
-            f,
-            "{}",
-            match self {
-                Ticker::BTCUSDT => "BTCUSDT",
-                Ticker::ETHUSDT => "ETHUSDT",
-                Ticker::SOLUSDT => "SOLUSDT",
-                Ticker::LTCUSDT => "LTCUSDT",
-            }
-        )
-    }
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
+pub enum MarketType {
+    Spot,
+    LinearPerps,
 }
 }
+
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
-pub enum Ticker {
-    BTCUSDT,
-    ETHUSDT,
-    SOLUSDT,
-    LTCUSDT,
+pub struct Ticker {
+    data: [u64; 2],
+    len: u8,
+    market_type: MarketType,
 }
 }
-impl Ticker {
-    pub const ALL: [Ticker; 4] = [Ticker::BTCUSDT, Ticker::ETHUSDT, Ticker::SOLUSDT, Ticker::LTCUSDT];
+
+impl Default for Ticker {
+    fn default() -> Self {
+        Ticker::new("", MarketType::Spot)
+    }
 }
 }
 
 
 impl Ticker {
 impl Ticker {
-    /// Returns the string representation of the ticker in lowercase
-    /// 
-    /// e.g. BTCUSDT -> "btcusdt"
-    pub fn get_string(&self) -> String {
-        match self {
-            Ticker::BTCUSDT => "btcusdt".to_string(),
-            Ticker::ETHUSDT => "ethusdt".to_string(),
-            Ticker::SOLUSDT => "solusdt".to_string(),
-            Ticker::LTCUSDT => "ltcusdt".to_string(),
+    pub fn new<S: AsRef<str>>(ticker: S, market_type: MarketType) -> Self {
+        let ticker = ticker.as_ref();
+        let base_len = ticker.len();
+        
+        assert!(base_len <= 20, "Ticker too long");
+        assert!(
+            ticker.chars().all(|c| c.is_ascii_alphanumeric()),
+            "Invalid character in ticker: {ticker:?}"
+        );
+
+        let mut data = [0u64; 2];
+        let mut len = 0;
+
+        for (i, c) in ticker.bytes().enumerate() {
+            let value = match c {
+                b'0'..=b'9' => c - b'0',
+                b'A'..=b'Z' => c - b'A' + 10,
+                _ => unreachable!(),
+            };
+            let shift = (i % 10) * 6;
+            data[i / 10] |= u64::from(value) << shift;
+            len += 1;
         }
         }
+
+        Ticker { data, len, market_type }
+    }
+
+    pub fn get_string(&self) -> (String, MarketType) {
+        let mut result = String::with_capacity(self.len as usize);
+        for i in 0..self.len {
+            let value = (self.data[i as usize / 10] >> ((i % 10) * 6)) & 0x3F;
+            let c = match value {
+                0..=9 => (b'0' + value as u8) as char,
+                10..=35 => (b'A' + (value as u8 - 10)) as char,
+                _ => unreachable!(),
+            };
+            result.push(c);
+        }
+
+        (result, self.market_type)
+    }
+}
+
+impl fmt::Display for Ticker {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        // Direct formatting without intermediate String allocation
+        for i in 0..self.len {
+            let value = (self.data[i as usize / 10] >> ((i % 10) * 6)) & 0x3F;
+            let c = match value {
+                0..=9 => (b'0' + value as u8) as char,
+                10..=35 => (b'A' + (value as u8 - 10)) as char,
+                _ => unreachable!(),
+            };
+            f.write_char(c)?;
+        }
+
+        Ok(())
+    }
+}
+
+impl From<(String, MarketType)> for Ticker {
+    fn from((s, market_type): (String, MarketType)) -> Self {
+        Ticker::new(s, market_type)
+    }
+}
+
+impl From<(&str, MarketType)> for Ticker {
+    fn from((s, market_type): (&str, MarketType)) -> Self {
+        Ticker::new(s, market_type)
     }
     }
 }
 }
 
 
@@ -207,6 +390,9 @@ impl std::fmt::Display for Timeframe {
                 Timeframe::M5 => "5m",
                 Timeframe::M5 => "5m",
                 Timeframe::M15 => "15m",
                 Timeframe::M15 => "15m",
                 Timeframe::M30 => "30m",
                 Timeframe::M30 => "30m",
+                Timeframe::H1 => "1h",
+                Timeframe::H2 => "2h",
+                Timeframe::H4 => "4h",
             }
             }
         )
         )
     }
     }
@@ -218,67 +404,227 @@ pub enum Timeframe {
     M5,
     M5,
     M15,
     M15,
     M30,
     M30,
+    H1,
+    H2,
+    H4,
 }
 }
 impl Timeframe {
 impl Timeframe {
-    pub const ALL: [Timeframe; 5] = [Timeframe::M1, Timeframe::M3, Timeframe::M5, Timeframe::M15, Timeframe::M30];
+    pub const ALL: [Timeframe; 8] = [
+        Timeframe::M1,
+        Timeframe::M3,
+        Timeframe::M5,
+        Timeframe::M15,
+        Timeframe::M30,
+        Timeframe::H1,
+        Timeframe::H2,
+        Timeframe::H4,
+    ];
 
 
-    pub fn to_minutes(&self) -> u16 {
+    pub fn to_minutes(self) -> u16 {
         match self {
         match self {
             Timeframe::M1 => 1,
             Timeframe::M1 => 1,
             Timeframe::M3 => 3,
             Timeframe::M3 => 3,
             Timeframe::M5 => 5,
             Timeframe::M5 => 5,
             Timeframe::M15 => 15,
             Timeframe::M15 => 15,
             Timeframe::M30 => 30,
             Timeframe::M30 => 30,
+            Timeframe::H1 => 60,
+            Timeframe::H2 => 120,
+            Timeframe::H4 => 240,
         }
         }
     }
     }
-}
 
 
-#[derive(Debug)]
-pub enum BinanceWsState {
-    Connected(binance::market_data::Connection),
-    Disconnected,
+    pub fn to_milliseconds(self) -> u64 {
+        u64::from(self.to_minutes()) * 60_000
+    }
 }
 }
-impl Default for BinanceWsState {
-    fn default() -> Self {
-        Self::Disconnected
+
+fn bool_from_int<'de, D>(deserializer: D) -> Result<bool, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    let value = Value::deserialize(deserializer)?;
+    match value.as_i64() {
+        Some(0) => Ok(false),
+        Some(1) => Ok(true),
+        _ => Err(serde::de::Error::custom("expected 0 or 1")),
     }
     }
 }
 }
 
 
-#[derive(Debug)]
-pub enum BybitWsState {
-    Connected(bybit::market_data::Connection),
-    Disconnected,
+fn deserialize_string_to_f32<'de, D>(deserializer: D) -> Result<f32, D::Error>
+where
+    D: serde::Deserializer<'de>,
+{
+    let s: String = serde::Deserialize::deserialize(deserializer)?;
+    s.parse::<f32>().map_err(serde::de::Error::custom)
 }
 }
-impl Default for BybitWsState {
-    fn default() -> Self {
-        Self::Disconnected
-    }
+
+fn deserialize_string_to_i64<'de, D>(deserializer: D) -> Result<i64, D::Error>
+where
+    D: serde::Deserializer<'de>,
+{
+    let s: String = serde::Deserialize::deserialize(deserializer)?;
+    s.parse::<i64>().map_err(serde::de::Error::custom)
 }
 }
 
 
-pub enum UserWsState {
-    Connected(binance::user_data::Connection),
-    Disconnected,
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct OpenInterest {
+    pub time: i64,
+    pub value: f32,
 }
 }
-impl Default for UserWsState {
-    fn default() -> Self {
-        Self::Disconnected
+
+// other helpers
+pub fn format_with_commas(num: f32) -> String {
+    let s = format!("{num:.0}");
+    
+    // Handle special case for small numbers
+    if s.len() <= 4 && s.starts_with('-') {
+        return s;  // Return as-is if it's a small negative number
+    }
+    
+    let mut result = String::with_capacity(s.len() + (s.len() - 1) / 3);
+    let (sign, digits) = if s.starts_with('-') {
+        ("-", &s[1..])  // Split into sign and digits
+    } else {
+        ("", &s[..])
+    };
+    
+    let mut i = digits.len();
+    while i > 0 {
+        if !result.is_empty() {
+            result.insert(0, ',');
+        }
+        let start = if i >= 3 { i - 3 } else { 0 };
+        result.insert_str(0, &digits[start..i]);
+        i = start;
     }
     }
+    
+    // Add sign at the start if negative
+    if !sign.is_empty() {
+        result.insert_str(0, sign);
+    }
+    
+    result
 }
 }
 
 
-#[derive(Debug, Clone)]
-pub enum MarketEvents {
-    Binance(binance::market_data::Event),
-    Bybit(bybit::market_data::Event),
+// websocket
+use bytes::Bytes;
+use tokio::net::TcpStream;
+use http_body_util::Empty;
+use hyper_util::rt::TokioIo;
+use fastwebsockets::FragmentCollector;
+use hyper::{
+    header::{CONNECTION, UPGRADE},
+    upgrade::Upgraded,
+    Request,
+};
+use tokio_rustls::{
+    rustls::{ClientConfig, OwnedTrustAnchor},
+    TlsConnector,
+};
+
+struct SpawnExecutor;
+
+impl<Fut> hyper::rt::Executor<Fut> for SpawnExecutor
+where
+    Fut: std::future::Future + Send + 'static,
+    Fut::Output: Send + 'static,
+{
+    fn execute(&self, fut: Fut) {
+        tokio::task::spawn(fut);
+    }
 }
 }
 
 
-#[derive(thiserror::Error, Debug)]
-pub enum StreamError {
-    #[error("FetchError: {0}")]
-    FetchError(#[from] reqwest::Error),
-    #[error("ParseError: {0}")]
-    ParseError(String),
-    #[error("StreamError: {0}")]
-    WebsocketError(String),
-    #[error("UnknownError: {0}")]
-    UnknownError(String),
+pub fn tls_connector() -> Result<TlsConnector, StreamError> {
+    let mut root_store = tokio_rustls::rustls::RootCertStore::empty();
+
+    root_store.add_trust_anchors(webpki_roots::TLS_SERVER_ROOTS.0.iter().map(|ta| {
+        OwnedTrustAnchor::from_subject_spki_name_constraints(
+            ta.subject,
+            ta.spki,
+            ta.name_constraints,
+        )
+    }));
+
+    let config = ClientConfig::builder()
+        .with_safe_defaults()
+        .with_root_certificates(root_store)
+        .with_no_client_auth();
+
+    Ok(TlsConnector::from(std::sync::Arc::new(config)))
+}
+
+async fn setup_tcp_connection(domain: &str) -> Result<TcpStream, StreamError> {
+    let addr = format!("{domain}:443");
+    TcpStream::connect(&addr)
+        .await
+        .map_err(|e| StreamError::WebsocketError(e.to_string()))
+}
+
+async fn setup_tls_connection(
+    domain: &str,
+    tcp_stream: TcpStream,
+) -> Result<tokio_rustls::client::TlsStream<TcpStream>, StreamError> {
+    let tls_connector: TlsConnector = tls_connector()?;
+    let domain: tokio_rustls::rustls::ServerName =
+        tokio_rustls::rustls::ServerName::try_from(domain)
+            .map_err(|_| StreamError::ParseError("invalid dnsname".to_string()))?;
+    tls_connector
+        .connect(domain, tcp_stream)
+        .await
+        .map_err(|e| StreamError::WebsocketError(e.to_string()))
+}
+
+async fn setup_websocket_connection(
+    domain: &str,
+    tls_stream: tokio_rustls::client::TlsStream<TcpStream>,
+    url: &str,
+) -> Result<FragmentCollector<TokioIo<Upgraded>>, StreamError> {
+    let req: Request<Empty<Bytes>> = Request::builder()
+        .method("GET")
+        .uri(url)
+        .header("Host", domain)
+        .header(UPGRADE, "websocket")
+        .header(CONNECTION, "upgrade")
+        .header(
+            "Sec-WebSocket-Key",
+            fastwebsockets::handshake::generate_key(),
+        )
+        .header("Sec-WebSocket-Version", "13")
+        .body(Empty::<Bytes>::new())
+        .map_err(|e| StreamError::WebsocketError(e.to_string()))?;
+
+    let (ws, _) = fastwebsockets::handshake::client(&SpawnExecutor, req, tls_stream)
+        .await
+        .map_err(|e| StreamError::WebsocketError(e.to_string()))?;
+
+    Ok(FragmentCollector::new(ws))
+}
+
+#[allow(unused_imports)]
+mod tests {
+    use super::*;
+
+    #[tokio::test]
+    async fn fetch_bybit_tickers_with_rate_limits() -> Result<(), StreamError> {
+        let url = "https://api.bybit.com/v5/market/tickers?category=spot".to_string();
+        let response = reqwest::get(&url).await.map_err(StreamError::FetchError)?;
+
+        println!("{:?}", response.headers());
+
+        let _text = response.text().await.map_err(StreamError::FetchError)?;
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn fetch_binance_tickers_with_rate_limits() -> Result<(), StreamError> {
+        let url = "https://fapi.binance.com/fapi/v1/ticker/24hr".to_string();
+        let response = reqwest::get(&url).await.map_err(StreamError::FetchError)?;
+
+        println!("{:?}", response.headers());
+
+        let _text = response.text().await.map_err(StreamError::FetchError)?;
+
+        Ok(())
+    }
 }
 }

+ 908 - 2
src/data_providers/binance.rs

@@ -1,2 +1,908 @@
-pub mod market_data;
-pub mod user_data;
+use std::collections::HashMap;
+
+use fastwebsockets::{FragmentCollector, OpCode};
+use ::futures::{SinkExt, Stream};
+use hyper::upgrade::Upgraded;
+use hyper_util::rt::TokioIo;
+use iced_futures::stream;
+use regex::Regex;
+use serde::{Deserialize, Serialize};
+use sonic_rs::{to_object_iter_unchecked, FastStr};
+
+use super::{
+    deserialize_string_to_f32,
+    setup_tcp_connection, setup_tls_connection, setup_websocket_connection, 
+    Connection, Event, Kline, LocalDepthCache, MarketType, OpenInterest, Order, State, 
+    StreamError, Ticker, TickerInfo, TickerStats, Timeframe, Trade, VecLocalDepthCache,
+};
+
+async fn connect(
+    domain: &str,
+    streams: &str,
+) -> Result<FragmentCollector<TokioIo<Upgraded>>, StreamError> {
+    let tcp_stream = setup_tcp_connection(domain).await?;
+    let tls_stream = setup_tls_connection(domain, tcp_stream).await?;
+    let url = format!("wss://{domain}/stream?streams={streams}");
+    setup_websocket_connection(domain, tls_stream, &url).await
+}
+
+mod string_to_f32 {
+    use serde::{self, Deserialize, Deserializer};
+
+    pub fn deserialize<'de, D>(deserializer: D) -> Result<f32, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        let s: &str = <&str>::deserialize(deserializer)?;
+        s.parse::<f32>().map_err(serde::de::Error::custom)
+    }
+}
+
+fn str_f32_parse(s: &str) -> f32 {
+    s.parse::<f32>().unwrap_or_else(|e| {
+        log::error!("Failed to parse float: {}, error: {}", s, e);
+        0.0
+    })
+}
+
+#[derive(Debug, Deserialize, Clone)]
+pub struct FetchedPerpDepth {
+    #[serde(rename = "lastUpdateId")]
+    update_id: i64,
+    #[serde(rename = "T")]
+    time: i64,
+    #[serde(rename = "bids")]
+    bids: Vec<Order>,
+    #[serde(rename = "asks")]
+    asks: Vec<Order>,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+pub struct FetchedSpotDepth {
+    #[serde(rename = "lastUpdateId")]
+    update_id: i64,
+    #[serde(rename = "bids")]
+    bids: Vec<Order>,
+    #[serde(rename = "asks")]
+    asks: Vec<Order>,
+}
+
+#[derive(Deserialize, Debug, Clone)]
+struct SonicKline {
+    #[serde(rename = "t")]
+    time: u64,
+    #[serde(rename = "o")]
+    open: String,
+    #[serde(rename = "h")]
+    high: String,
+    #[serde(rename = "l")]
+    low: String,
+    #[serde(rename = "c")]
+    close: String,
+    #[serde(rename = "v")]
+    volume: String,
+    #[serde(rename = "V")]
+    taker_buy_base_asset_volume: String,
+    #[serde(rename = "i")]
+    interval: String,
+}
+
+#[derive(Deserialize, Debug, Clone)]
+struct SonicKlineWrap {
+    #[serde(rename = "s")]
+    symbol: String,
+    #[serde(rename = "k")]
+    kline: SonicKline,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct BidAsk {
+    #[serde(rename = "0")]
+    price: String,
+    #[serde(rename = "1")]
+    qty: String,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct SonicTrade {
+    #[serde(rename = "T")]
+    time: u64,
+    #[serde(rename = "p")]
+    price: String,
+    #[serde(rename = "q")]
+    qty: String,
+    #[serde(rename = "m")]
+    is_sell: bool,
+}
+
+#[derive(Debug)]
+enum SonicDepth {
+    Spot(SpotDepth),
+    LinearPerp(LinearPerpDepth),
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct SpotDepth {
+    #[serde(rename = "E")]
+    time: u64,
+    #[serde(rename = "U")]
+    first_id: u64,
+    #[serde(rename = "u")]
+    final_id: u64,
+    #[serde(rename = "b")]
+    bids: Vec<BidAsk>,
+    #[serde(rename = "a")]
+    asks: Vec<BidAsk>,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct LinearPerpDepth {
+    #[serde(rename = "T")]
+    time: u64,
+    #[serde(rename = "U")]
+    first_id: u64,
+    #[serde(rename = "u")]
+    final_id: u64,
+    #[serde(rename = "pu")]
+    prev_final_id: u64,
+    #[serde(rename = "b")]
+    bids: Vec<BidAsk>,
+    #[serde(rename = "a")]
+    asks: Vec<BidAsk>,
+}
+
+#[derive(Debug)]
+enum StreamData {
+    Trade(SonicTrade),
+    Depth(SonicDepth),
+    Kline(Ticker, SonicKline),
+}
+
+enum StreamWrapper {
+    Trade,
+    Depth,
+    Kline,
+}
+
+impl StreamWrapper {
+    fn from_stream_type(stream_type: &FastStr) -> Option<Self> {
+        stream_type.split('@').nth(1).and_then(|after_at| {
+            match after_at {
+                s if s.starts_with("de") => Some(StreamWrapper::Depth),
+                s if s.starts_with("ag") => Some(StreamWrapper::Trade),
+                s if s.starts_with("kl") => Some(StreamWrapper::Kline),
+                _ => None,
+            }
+        })
+    }
+}
+
+fn feed_de(slice: &[u8], market: MarketType) -> Result<StreamData, StreamError> {
+    let mut stream_type: Option<StreamWrapper> = None;
+    let iter: sonic_rs::ObjectJsonIter = unsafe { to_object_iter_unchecked(slice) };
+
+    for elem in iter {
+        let (k, v) = elem
+            .map_err(|e| StreamError::ParseError(e.to_string()))?;
+
+        if k == "stream" {
+            if let Some(s) = StreamWrapper::from_stream_type(&v.as_raw_faststr()) {
+                stream_type = Some(s);
+            }
+        } else if k == "data" {
+            match stream_type {
+                Some(StreamWrapper::Trade) => {
+                    let trade: SonicTrade = sonic_rs::from_str(&v.as_raw_faststr())
+                        .map_err(|e| StreamError::ParseError(e.to_string()))?;
+
+                    return Ok(StreamData::Trade(trade));
+                }
+                Some(StreamWrapper::Depth) => {
+                    match market {
+                        MarketType::Spot => {
+                            let depth: SpotDepth = sonic_rs::from_str(&v.as_raw_faststr())
+                                .map_err(|e| StreamError::ParseError(e.to_string()))?;
+
+                            return Ok(StreamData::Depth(SonicDepth::Spot(depth)));
+                        }
+                        MarketType::LinearPerps => {
+                            let depth: LinearPerpDepth = sonic_rs::from_str(&v.as_raw_faststr())
+                                .map_err(|e| StreamError::ParseError(e.to_string()))?;
+
+                            return Ok(StreamData::Depth(SonicDepth::LinearPerp(depth)));
+                        }
+                    }
+                }
+                Some(StreamWrapper::Kline) => {
+                    let kline_wrap: SonicKlineWrap = sonic_rs::from_str(&v.as_raw_faststr())
+                        .map_err(|e| StreamError::ParseError(e.to_string()))?;
+
+                    return Ok(StreamData::Kline(
+                        Ticker::new(kline_wrap.symbol, market),
+                        kline_wrap.kline,
+                    ));
+                }
+                _ => {
+                    log::error!("Unknown stream type");
+                }
+            }
+        } else {
+            log::error!("Unknown data: {:?}", k);
+        }
+    }
+
+    Err(StreamError::ParseError(
+        "Failed to parse ws data".to_string(),
+    ))
+}
+
+#[allow(unused_assignments)]
+pub fn connect_market_stream(ticker: Ticker) -> impl Stream<Item = Event> {
+    stream::channel(100, move |mut output| async move {
+        let mut state = State::Disconnected;
+
+        let (symbol_str, market) = ticker.get_string();
+    
+        let stream_1 = format!("{}@aggTrade", symbol_str.to_lowercase());
+        let stream_2 = format!("{}@depth@100ms", symbol_str.to_lowercase());
+
+        let mut orderbook: LocalDepthCache = LocalDepthCache::new();
+        let mut trades_buffer: Vec<Trade> = Vec::new();
+        let mut already_fetching: bool = false;
+        let mut prev_id: u64 = 0;
+
+        let streams = format!("{stream_1}/{stream_2}");
+
+        let domain = match market {
+            MarketType::Spot => "stream.binance.com",
+            MarketType::LinearPerps => "fstream.binance.com",
+        };
+
+        loop {
+            match &mut state {
+                State::Disconnected => {
+                    if let Ok(websocket) = connect(domain, streams.as_str()).await {
+                        let (tx, rx) = tokio::sync::oneshot::channel();
+
+                        tokio::spawn(async move {
+                            let result = fetch_depth(&ticker).await;
+                            let _ = tx.send(result);
+                        });
+                        match rx.await {
+                            Ok(Ok(depth)) => {
+                                orderbook.fetched(&depth);
+                                prev_id = 0;
+
+                                state = State::Connected(websocket);
+
+                                let _ = output
+                                    .send(Event::Connected(Connection)).await;
+                            }
+                            Ok(Err(e)) => {
+                                let _ = output
+                                    .send(Event::Disconnected(format!("Depth fetch failed: {}", e))).await;
+                            }
+                            Err(e) => {
+                                let _ = output
+                                    .send(Event::Disconnected(format!("Channel error: {}", e))).await;
+                            }
+                        }
+                    } else {
+                        tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
+
+                        let _ = output
+                            .send(Event::Disconnected(
+                                "Failed to connect to websocket".to_string(),
+                            ))
+                            .await;
+                    }
+                }
+                State::Connected(ws) => {
+                    match ws.read_frame().await {
+                        Ok(msg) => match msg.opcode {
+                            OpCode::Text => {
+                                if let Ok(data) = feed_de(&msg.payload[..], market) {            
+                                    match data {
+                                        StreamData::Trade(de_trade) => {
+                                            let trade = Trade {
+                                                time: de_trade.time as i64,
+                                                is_sell: de_trade.is_sell,
+                                                price: str_f32_parse(&de_trade.price),
+                                                qty: str_f32_parse(&de_trade.qty),
+                                            };
+
+                                            trades_buffer.push(trade);
+                                        }
+                                        StreamData::Depth(depth_type) => {
+                                            if already_fetching {
+                                                log::warn!("Already fetching...\n");
+                                                continue;
+                                            }
+
+                                            let last_update_id = orderbook.get_fetch_id() as u64;
+
+                                            match depth_type {
+                                                SonicDepth::LinearPerp(ref de_depth) => {
+                                                    if (de_depth.final_id <= last_update_id)
+                                                        || last_update_id == 0
+                                                    {
+                                                        continue;
+                                                    }
+
+                                                    if prev_id == 0
+                                                        && (de_depth.first_id > last_update_id + 1)
+                                                        || (last_update_id + 1 > de_depth.final_id)
+                                                    {
+                                                        log::warn!("Out of sync at first event. Trying to resync...\n");
+
+                                                        let (tx, rx) = tokio::sync::oneshot::channel();
+                                                        already_fetching = true;
+
+                                                        tokio::spawn(async move {
+                                                            let result = fetch_depth(&ticker).await;
+                                                            let _ = tx.send(result);
+                                                        });
+                                                        match rx.await {
+                                                            Ok(Ok(depth)) => {
+                                                                orderbook.fetched(&depth);
+                                                            }
+                                                            Ok(Err(e)) => {
+                                                                let _ = output
+                                                                    .send(Event::Disconnected(format!("Depth fetch failed: {}", e))).await;
+                                                            }
+                                                            Err(e) => {
+                                                                state = State::Disconnected;
+                                                                output.send(Event::Disconnected(
+                                                                        format!("Failed to send fetched depth for {symbol_str}, error: {e}")
+                                                                    )).await.expect("Trying to send disconnect event...");
+                                                            }
+                                                        }
+                                                        already_fetching = false;
+                                                    }
+
+                                                    if (prev_id == 0) || (prev_id == de_depth.prev_final_id)
+                                                    {
+                                                        let time = de_depth.time as i64;
+
+                                                        orderbook.update_depth_cache(
+                                                            &new_depth_cache(&depth_type)
+                                                        );
+
+                                                        let _ = output
+                                                            .send(Event::DepthReceived(
+                                                                ticker,
+                                                                time,
+                                                                orderbook.get_depth(),
+                                                                std::mem::take(&mut trades_buffer),
+                                                            ))
+                                                            .await;
+
+                                                        prev_id = de_depth.final_id;
+                                                    } else {
+                                                        state = State::Disconnected;
+                                                        let _ = output.send(
+                                                                Event::Disconnected(
+                                                                    format!("Out of sync. Expected update_id: {}, got: {}", de_depth.prev_final_id, prev_id)
+                                                                )
+                                                            ).await;
+                                                    }
+                                                }
+                                                SonicDepth::Spot(ref de_depth) => {
+                                                    if (de_depth.final_id <= last_update_id)
+                                                        || last_update_id == 0
+                                                    {
+                                                        continue;
+                                                    }
+
+                                                    if prev_id == 0
+                                                        && (de_depth.first_id > last_update_id + 1)
+                                                        || (last_update_id + 1 > de_depth.final_id)
+                                                    {
+                                                        log::warn!("Out of sync at first event. Trying to resync...\n");
+
+                                                        let (tx, rx) = tokio::sync::oneshot::channel();
+                                                        already_fetching = true;
+
+                                                        tokio::spawn(async move {
+                                                            let result = fetch_depth(&ticker).await;
+                                                            let _ = tx.send(result);
+                                                        });
+                                                        match rx.await {
+                                                            Ok(Ok(depth)) => {
+                                                                orderbook.fetched(&depth);
+                                                            }
+                                                            Ok(Err(e)) => {
+                                                                let _ = output
+                                                                    .send(Event::Disconnected(format!("Depth fetch failed: {}", e))).await;
+                                                            }
+                                                            Err(e) => {
+                                                                state = State::Disconnected;
+                                                                output.send(Event::Disconnected(
+                                                                        format!("Failed to send fetched depth for {symbol_str}, error: {e}")
+                                                                    )).await.expect("Trying to send disconnect event...");
+                                                            }
+                                                        }
+                                                        already_fetching = false;
+                                                    }
+
+                                                    if (prev_id == 0) || (prev_id == de_depth.first_id - 1)
+                                                    {
+                                                        let time = de_depth.time as i64;
+
+                                                        orderbook.update_depth_cache(
+                                                            &new_depth_cache(&depth_type)
+                                                        );
+
+                                                        let _ = output
+                                                            .send(Event::DepthReceived(
+                                                                ticker,
+                                                                time,
+                                                                orderbook.get_depth(),
+                                                                std::mem::take(&mut trades_buffer),
+                                                            ))
+                                                            .await;
+
+                                                        prev_id = de_depth.final_id;
+                                                    } else {
+                                                        state = State::Disconnected;
+                                                        let _ = output.send(
+                                                                Event::Disconnected(
+                                                                    format!("Out of sync. Expected update_id: {}, got: {}", de_depth.final_id, prev_id)
+                                                                )
+                                                            ).await;
+                                                    }
+                                                }
+                                            }
+                                        }
+                                        _ => {}
+                                    }
+                                }
+                            }
+                            OpCode::Close => {
+                                state = State::Disconnected;
+                                let _ = output
+                                    .send(Event::Disconnected("Connection closed".to_string()))
+                                    .await;
+                            }
+                            _ => {}
+                        },
+                        Err(e) => {
+                            state = State::Disconnected;
+                            let _ = output
+                                .send(Event::Disconnected(
+                                    "Error reading frame: ".to_string() + &e.to_string(),
+                                ))
+                                .await;
+                        }
+                    };
+                }
+            }
+        }
+    })
+}
+
+pub fn connect_kline_stream(
+    streams: Vec<(Ticker, Timeframe)>,
+    market: MarketType,
+) -> impl Stream<Item = super::Event> {
+    stream::channel(100, move |mut output| async move {
+        let mut state = State::Disconnected;
+
+        let stream_str = streams
+            .iter()
+            .map(|(ticker, timeframe)| {
+                let timeframe_str = timeframe.to_string();
+                format!(
+                    "{}@kline_{timeframe_str}",
+                    ticker.get_string().0.to_lowercase()
+                )
+            })
+            .collect::<Vec<String>>()
+            .join("/");
+
+        loop {
+            match &mut state {
+                State::Disconnected => {
+                    let domain = match market {
+                        MarketType::Spot => "stream.binance.com",
+                        MarketType::LinearPerps => "fstream.binance.com",
+                    };
+
+                    if let Ok(websocket) = connect(domain, stream_str.as_str()).await {
+                        state = State::Connected(websocket);
+                        let _ = output.send(Event::Connected(Connection)).await;
+                    } else {
+                        tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
+
+                        let _ = output
+                            .send(Event::Disconnected(
+                                "Failed to connect to websocket".to_string(),
+                            ))
+                            .await;
+                    }
+                }
+                State::Connected(ws) => match ws.read_frame().await {
+                    Ok(msg) => match msg.opcode {
+                        OpCode::Text => {
+                            if let Ok(StreamData::Kline(ticker, de_kline)) = feed_de(&msg.payload[..], market) {
+                                let buy_volume =
+                                    str_f32_parse(&de_kline.taker_buy_base_asset_volume);
+                                let sell_volume = str_f32_parse(&de_kline.volume) - buy_volume;
+
+                                let kline = Kline {
+                                    time: de_kline.time,
+                                    open: str_f32_parse(&de_kline.open),
+                                    high: str_f32_parse(&de_kline.high),
+                                    low: str_f32_parse(&de_kline.low),
+                                    close: str_f32_parse(&de_kline.close),
+                                    volume: (buy_volume, sell_volume),
+                                };
+
+                                if let Some(timeframe) = streams
+                                    .iter()
+                                    .find(|(_, tf)| tf.to_string() == de_kline.interval)
+                                {
+                                    let _ = output
+                                        .send(Event::KlineReceived(ticker, kline, timeframe.1))
+                                        .await;
+                                }
+                            }
+                        }
+                        OpCode::Close => {
+                            state = State::Disconnected;
+                            let _ = output
+                                .send(Event::Disconnected("Connection closed".to_string()))
+                                .await;
+                        }
+                        _ => {}
+                    },
+                    Err(e) => {
+                        state = State::Disconnected;
+                        let _ = output
+                            .send(Event::Disconnected(
+                                "Error reading frame: ".to_string() + &e.to_string(),
+                            ))
+                            .await;
+                    }
+                },
+            }
+        }
+    })
+}
+
+fn new_depth_cache(depth: &SonicDepth) -> VecLocalDepthCache {
+    match depth {
+        SonicDepth::Spot(de) => VecLocalDepthCache {
+            last_update_id: de.final_id as i64,
+            time: de.time as i64,
+            bids: de.bids.iter().map(|x| Order {
+                price: str_f32_parse(&x.price),
+                qty: str_f32_parse(&x.qty),
+            }).collect(),
+            asks: de.asks.iter().map(|x| Order {
+                price: str_f32_parse(&x.price),
+                qty: str_f32_parse(&x.qty),
+            }).collect(),
+        },
+        SonicDepth::LinearPerp(de) => VecLocalDepthCache {
+            last_update_id: de.final_id as i64,
+            time: de.time as i64,
+            bids: de.bids.iter().map(|x| Order {
+                price: str_f32_parse(&x.price),
+                qty: str_f32_parse(&x.qty),
+            }).collect(),
+            asks: de.asks.iter().map(|x| Order {
+                price: str_f32_parse(&x.price),
+                qty: str_f32_parse(&x.qty),
+            }).collect(),
+        }
+    }
+}
+
+async fn fetch_depth(ticker: &Ticker) -> Result<VecLocalDepthCache, StreamError> {
+    let (symbol_str, market_type) = ticker.get_string();
+
+    let base_url = match market_type {
+        MarketType::Spot => "https://api.binance.com/api/v3/depth",
+        MarketType::LinearPerps => "https://fapi.binance.com/fapi/v1/depth",
+    };
+
+    let url = format!(
+        "{}?symbol={}&limit=1000", 
+        base_url,
+        symbol_str.to_uppercase()
+    );
+
+    let response = reqwest::get(&url).await.map_err(StreamError::FetchError)?;
+    let text = response.text().await.map_err(StreamError::FetchError)?;
+
+    match market_type {
+        MarketType::Spot => {
+            let fetched_depth: FetchedSpotDepth = serde_json::from_str(&text)
+                .map_err(|e| StreamError::ParseError(e.to_string()))?;
+
+            let depth: VecLocalDepthCache = VecLocalDepthCache {
+                last_update_id: fetched_depth.update_id,
+                time: chrono::Utc::now().timestamp_millis(),
+                bids: fetched_depth.bids,
+                asks: fetched_depth.asks,
+            };
+
+            Ok(depth)
+        }
+        MarketType::LinearPerps => {
+            let fetched_depth: FetchedPerpDepth = serde_json::from_str(&text)
+                .map_err(|e| StreamError::ParseError(e.to_string()))?;
+
+            let depth: VecLocalDepthCache = VecLocalDepthCache {
+                last_update_id: fetched_depth.update_id,
+                time: fetched_depth.time,
+                bids: fetched_depth.bids,
+                asks: fetched_depth.asks,
+            };
+
+            Ok(depth)
+        }
+    }
+}
+
+#[allow(dead_code)]
+#[derive(Deserialize, Debug, Clone)]
+struct FetchedKlines(
+    u64,
+    #[serde(with = "string_to_f32")] f32,
+    #[serde(with = "string_to_f32")] f32,
+    #[serde(with = "string_to_f32")] f32,
+    #[serde(with = "string_to_f32")] f32,
+    #[serde(with = "string_to_f32")] f32,
+    u64,
+    String,
+    u32,
+    #[serde(with = "string_to_f32")] f32,
+    String,
+    String,
+);
+
+impl From<FetchedKlines> for Kline {
+    fn from(fetched: FetchedKlines) -> Self {
+        let sell_volume = fetched.5 - fetched.9;
+
+        Self {
+            time: fetched.0,
+            open: fetched.1,
+            high: fetched.2,
+            low: fetched.3,
+            close: fetched.4,
+            volume: (fetched.9, sell_volume),
+        }
+    }
+}
+
+pub async fn fetch_klines(
+    ticker: Ticker,
+    timeframe: Timeframe,
+    range: Option<(i64, i64)>,
+) -> Result<Vec<Kline>, StreamError> {
+    let (symbol_str, market_type) = ticker.get_string();
+    let timeframe_str = timeframe.to_string();
+
+    let base_url = match market_type {
+        MarketType::Spot => "https://api.binance.com/api/v3/klines",
+        MarketType::LinearPerps => "https://fapi.binance.com/fapi/v1/klines",
+    };
+
+    let mut url = format!(
+        "{base_url}?symbol={symbol_str}&interval={timeframe_str}"
+    );
+
+    if let Some((start, end)) = range {
+        let interval_ms = timeframe.to_milliseconds() as i64;
+        let num_intervals = ((end - start) / interval_ms).min(1000);
+
+        url.push_str(&format!(
+            "&startTime={start}&endTime={end}&limit={num_intervals}"
+        ));
+    } else {
+        url.push_str(&format!("&limit={}", 200));
+    }
+
+    let response = reqwest::get(&url).await.map_err(StreamError::FetchError)?;
+    let text = response.text().await.map_err(StreamError::FetchError)?;
+
+    let fetched_klines: Vec<FetchedKlines> = serde_json::from_str(&text)
+        .map_err(|e| StreamError::ParseError(format!("Failed to parse klines: {e}")))?;
+
+    let klines: Vec<_> = fetched_klines.into_iter().map(Kline::from).collect();
+
+    Ok(klines)
+}
+
+pub async fn fetch_ticksize(market: MarketType) -> Result<HashMap<Ticker, Option<TickerInfo>>, StreamError> {
+    let url = match market {
+        MarketType::Spot => "https://api.binance.com/api/v3/exchangeInfo".to_string(),
+        MarketType::LinearPerps => "https://fapi.binance.com/fapi/v1/exchangeInfo".to_string(),
+    };
+    let response = reqwest::get(&url).await.map_err(StreamError::FetchError)?;
+    let text = response.text().await.map_err(StreamError::FetchError)?;
+
+    let exchange_info: serde_json::Value = serde_json::from_str(&text)
+        .map_err(|e| StreamError::ParseError(format!("Failed to parse exchange info: {e}")))?;
+
+    let symbols = exchange_info["symbols"]
+        .as_array()
+        .ok_or_else(|| StreamError::ParseError("Missing symbols array".to_string()))?;
+
+    let mut ticker_info_map = HashMap::new();
+
+    let re = Regex::new(r"^[a-zA-Z0-9]+$").unwrap();
+
+    for symbol in symbols {
+        let ticker = symbol["symbol"]
+            .as_str()
+            .ok_or_else(|| StreamError::ParseError("Missing symbol".to_string()))?
+            .to_string();
+
+        if !re.is_match(&ticker) {
+            continue;
+        }
+        
+        if !ticker.ends_with("USDT") {
+            continue;
+        }
+
+        let filters = symbol["filters"]
+            .as_array()
+            .ok_or_else(|| StreamError::ParseError("Missing filters array".to_string()))?;
+
+        let price_filter = filters
+            .iter()
+            .find(|x| x["filterType"].as_str().unwrap_or_default() == "PRICE_FILTER");
+
+        if let Some(price_filter) = price_filter {
+            let tick_size = price_filter["tickSize"]
+                .as_str()
+                .ok_or_else(|| StreamError::ParseError("tickSize not found".to_string()))?
+                .parse::<f32>()
+                .map_err(|e| StreamError::ParseError(format!("Failed to parse tickSize: {e}")))?;
+
+            ticker_info_map.insert(Ticker::new(ticker, market), Some(TickerInfo { tick_size }));
+        } else {
+            ticker_info_map.insert(Ticker::new(ticker, market), None);
+        }
+    }
+
+    Ok(ticker_info_map)
+}
+
+pub async fn fetch_ticker_prices(market: MarketType) -> Result<HashMap<Ticker, TickerStats>, StreamError> {
+    let url = match market {
+        MarketType::Spot => "https://api.binance.com/api/v3/ticker/24hr".to_string(),
+        MarketType::LinearPerps => "https://fapi.binance.com/fapi/v1/ticker/24hr".to_string(),
+    };
+    let response = reqwest::get(&url).await.map_err(StreamError::FetchError)?;
+    let text = response.text().await.map_err(StreamError::FetchError)?;
+
+    let value: Vec<serde_json::Value> = serde_json::from_str(&text)
+        .map_err(|e| StreamError::ParseError(format!("Failed to parse prices: {e}")))?;
+
+    let mut ticker_price_map = HashMap::new();
+
+    let re = Regex::new(r"^[a-zA-Z0-9]+$").unwrap();
+
+    let volume_threshold = match market {
+        MarketType::Spot => 9_000_000.0,
+        MarketType::LinearPerps => 29_000_000.0,
+    };
+
+    for item in value {
+        if let (Some(symbol), Some(last_price), Some(price_change_pt), Some(volume)) = (
+            item.get("symbol").and_then(|v| v.as_str()),
+            item.get("lastPrice")
+                .and_then(|v| v.as_str())
+                .and_then(|v| v.parse::<f32>().ok()),
+            item.get("priceChangePercent")
+                .and_then(|v| v.as_str())
+                .and_then(|v| v.parse::<f32>().ok()),
+            item.get("quoteVolume")
+                .and_then(|v| v.as_str())
+                .and_then(|v| v.parse::<f32>().ok()),
+        ) {
+            if !re.is_match(symbol) {
+                continue;
+            }
+
+            if !symbol.ends_with("USDT") {
+                continue;
+            }
+
+            if volume < volume_threshold {
+                continue;
+            }
+
+            let ticker_stats = TickerStats {
+                mark_price: last_price,
+                daily_price_chg: price_change_pt,
+                daily_volume: volume,
+            };
+
+            ticker_price_map.insert(Ticker::new(symbol, market), ticker_stats);
+        }
+    }
+
+    Ok(ticker_price_map)
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct DeOpenInterest {
+    #[serde(rename = "timestamp")]
+    pub time: i64,
+    #[serde(rename = "sumOpenInterest", deserialize_with = "deserialize_string_to_f32")]
+    pub sum: f32,
+}
+
+pub async fn fetch_historical_oi(
+    ticker: Ticker, 
+    range: Option<(i64, i64)>,
+    period: Timeframe,
+) -> Result<Vec<OpenInterest>, StreamError> {
+    let ticker_str = ticker.get_string().0.to_uppercase();
+    let period_str = match period {
+        Timeframe::M5 => "5m",
+        Timeframe::M15 => "15m",
+        Timeframe::M30 => "30m",
+        Timeframe::H1 => "1h",
+        Timeframe::H4 => "4h",
+        _ => {
+            let err_msg = format!("Unsupported timeframe for open interest: {}", period);
+            log::error!("{}", err_msg);
+            return Err(StreamError::UnknownError(err_msg));
+        }
+    };
+
+    let mut url = format!(
+        "https://fapi.binance.com/futures/data/openInterestHist?symbol={}&period={}",
+        ticker_str, period_str,
+    );
+
+    if let Some((start, end)) = range {
+        let interval_ms = period.to_milliseconds() as i64;
+        let num_intervals = ((end - start) / interval_ms).min(500);
+
+        url.push_str(&format!(
+            "&startTime={start}&endTime={end}&limit={num_intervals}"
+        ));
+    } else {
+        url.push_str(&format!("&limit={}", 200));
+    }
+
+    let response = reqwest::get(&url)
+        .await
+        .map_err(|e| {
+            log::error!("Failed to fetch from {}: {}", url, e);
+            StreamError::FetchError(e)
+        })?;
+        
+    let text = response.text()
+        .await
+        .map_err(|e| {
+            log::error!("Failed to get response text from {}: {}", url, e);
+            StreamError::FetchError(e)
+        })?;
+
+    let binance_oi: Vec<DeOpenInterest> = serde_json::from_str(&text)
+        .map_err(|e| {
+            log::error!("Failed to parse response from {}: {}\nResponse: {}", url, e, text);
+            StreamError::ParseError(format!("Failed to parse open interest: {e}"))
+        })?;
+
+    let open_interest: Vec<OpenInterest> = binance_oi
+        .iter()
+        .map(|x| OpenInterest {
+            time: x.time,
+            value: x.sum,
+        })
+        .collect();
+
+    Ok(open_interest)
+}

+ 0 - 753
src/data_providers/binance/market_data.rs

@@ -1,753 +0,0 @@
-use iced::{futures, stream};  
-use futures::stream::Stream;
-use serde::Deserializer;
-use futures::sink::SinkExt;
-
-use crate::{Ticker, Timeframe};
-
-use bytes::Bytes;
-
-use sonic_rs::{Deserialize, Serialize, JsonValueTrait}; 
-use sonic_rs::to_object_iter_unchecked;
-
-use anyhow::{Context, Result};
-
-use fastwebsockets::{FragmentCollector, OpCode};
-use http_body_util::Empty;
-use hyper::header::{CONNECTION, UPGRADE};
-use hyper::upgrade::Upgraded;
-use hyper::Request;
-use hyper_util::rt::TokioIo;
-use tokio::net::TcpStream;
-use tokio_rustls::rustls::{ClientConfig, OwnedTrustAnchor};
-use tokio_rustls::TlsConnector;
-
-use crate::data_providers::{
-    LocalDepthCache, Trade, Depth, Order, FeedLatency, Kline, StreamError,
-};
-
-#[allow(clippy::large_enum_variant)]
-enum State {
-    Disconnected,
-    Connected(
-        FragmentCollector<TokioIo<Upgraded>>
-    ),
-}
-
-#[derive(Debug, Clone)]
-pub enum Event {
-    Connected(Connection),
-    Disconnected(String),
-    DepthReceived(Ticker, FeedLatency, i64, Depth, Vec<Trade>),
-    KlineReceived(Ticker, Kline, Timeframe),
-}
-
-#[derive(Debug, Clone)]
-pub struct Connection;
-
-impl<'de> Deserialize<'de> for Order {
-    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-    where
-        D: Deserializer<'de>,
-    {
-        let arr: Vec<&str> = Vec::<&str>::deserialize(deserializer)?;
-        let price: f32 = arr[0].parse::<f32>().map_err(serde::de::Error::custom)?;
-        let qty: f32 = arr[1].parse::<f32>().map_err(serde::de::Error::custom)?;
-        Ok(Order { price, qty })
-    }
-}
-#[derive(Debug, Deserialize, Clone)]
-pub struct FetchedDepth {
-    #[serde(rename = "lastUpdateId")]
-    update_id: i64,
-    #[serde(rename = "T")]
-    time: i64,
-    #[serde(rename = "bids")]
-    bids: Vec<Order>,
-    #[serde(rename = "asks")]
-    asks: Vec<Order>,
-}
-
-#[derive(Serialize, Deserialize, Debug)]
-struct SonicDepth {
-	#[serde(rename = "T")]
-	time: u64,
-	#[serde(rename = "U")]
-	first_id: u64,
-	#[serde(rename = "u")]
-	final_id: u64,
-	#[serde(rename = "pu")]
-	prev_final_id: u64,
-	#[serde(rename = "b")]
-	bids: Vec<BidAsk>,
-	#[serde(rename = "a")]
-	asks: Vec<BidAsk>,
-}
-
-#[derive(Serialize, Deserialize, Debug)]
-struct BidAsk {
-	#[serde(rename = "0")]
-	price: String,
-	#[serde(rename = "1")]
-	qty: String,
-}
-
-#[derive(Serialize, Deserialize, Debug)]
-struct SonicTrade {
-	#[serde(rename = "T")]
-	time: u64,
-	#[serde(rename = "p")]
-	price: String,
-	#[serde(rename = "q")]
-	qty: String,
-	#[serde(rename = "m")]
-	is_sell: bool,
-}
-
-#[derive(Deserialize, Debug, Clone)]
-struct SonicKline {
-    #[serde(rename = "t")]
-    time: u64,
-    #[serde(rename = "o")]
-    open: String,
-    #[serde(rename = "h")]
-    high: String,
-    #[serde(rename = "l")]
-    low: String,
-    #[serde(rename = "c")]
-    close: String,
-    #[serde(rename = "v")]
-    volume: String,
-    #[serde(rename = "V")]
-    taker_buy_base_asset_volume: String,
-    #[serde(rename = "i")]
-    interval: String,
-}
-
-#[derive(Deserialize, Debug, Clone)]
-struct SonicKlineWrap {
-    #[serde(rename = "s")]
-    symbol: String,
-    #[serde(rename = "k")]
-    kline: SonicKline,
-}
-
-#[derive(Debug)]
-enum StreamData {
-	Trade(SonicTrade),
-	Depth(SonicDepth),
-    Kline(Ticker, SonicKline),
-}
-
-#[derive(Debug)]
-enum StreamName {
-    Depth,
-    Trade,
-    Kline,
-    Unknown,
-}
-impl StreamName {
-    fn from_stream_type(stream_type: &str) -> Self {
-        if let Some(after_at) = stream_type.split('@').nth(1) {
-            match after_at {
-                _ if after_at.starts_with("dep") => StreamName::Depth,
-                _ if after_at.starts_with("agg") => StreamName::Trade,
-                _ if after_at.starts_with("kli") => StreamName::Kline,
-                _ => StreamName::Unknown,
-            }
-        } else {
-            StreamName::Unknown
-        }
-    }
-}
-
-#[derive(Debug)]
-enum StreamWrapper {
-	Trade,
-	Depth,
-    Kline,
-}
-
-fn feed_de(bytes: &Bytes) -> Result<StreamData> {
-	let mut stream_type: Option<StreamWrapper> = None;
-
-	let iter: sonic_rs::ObjectJsonIter = unsafe { to_object_iter_unchecked(bytes) };
-
-	for elem in iter {
-		let (k, v) = elem
-            .context("Error parsing stream")?;
-
-		if k == "stream" {
-			if let Some(val) = v.as_str() {
-                match StreamName::from_stream_type(val) {
-					StreamName::Depth => {
-						stream_type = Some(StreamWrapper::Depth);
-					},
-					StreamName::Trade => {
-						stream_type = Some(StreamWrapper::Trade);
-					},
-                    StreamName::Kline => {
-                        stream_type = Some(StreamWrapper::Kline);
-                    },
-					_ => {
-                        log::warn!("Unknown stream name");
-                    }
-				}
-			}
-		} else if k == "data" {
-			match stream_type {
-				Some(StreamWrapper::Trade) => {
-					let trade: SonicTrade = sonic_rs::from_str(&v.as_raw_faststr())
-						.context("Error parsing trade")?;
-
-					return Ok(StreamData::Trade(trade));
-				},
-				Some(StreamWrapper::Depth) => {
-					let depth: SonicDepth = sonic_rs::from_str(&v.as_raw_faststr())
-						.context("Error parsing depth")?;
-
-					return Ok(StreamData::Depth(depth));
-				},
-                Some(StreamWrapper::Kline) => {
-                    let kline_wrap: SonicKlineWrap = sonic_rs::from_str(&v.as_raw_faststr())
-                        .context("Error parsing kline")?;
-
-                    let ticker = match &kline_wrap.symbol[..] {
-                        "BTCUSDT" => Ticker::BTCUSDT,
-                        "ETHUSDT" => Ticker::ETHUSDT,
-                        "SOLUSDT" => Ticker::SOLUSDT,
-                        "LTCUSDT" => Ticker::LTCUSDT,
-                        _ => Ticker::BTCUSDT,
-                    };
-
-                    return Ok(StreamData::Kline(ticker, kline_wrap.kline));
-                },
-				_ => {
-					log::error!("Unknown stream type");
-				}
-			}
-		} else {
-			log::error!("Unknown data: {:?}", k);
-		}
-	}
-
-	Err(anyhow::anyhow!("Unknown data"))
-}
-
-fn tls_connector() -> Result<TlsConnector> {
-	let mut root_store = tokio_rustls::rustls::RootCertStore::empty();
-
-	root_store.add_trust_anchors(
-		webpki_roots::TLS_SERVER_ROOTS.0.iter().map(|ta| {
-			OwnedTrustAnchor::from_subject_spki_name_constraints(
-			ta.subject,
-			ta.spki,
-			ta.name_constraints,
-			)
-		}),
-	);
-
-	let config = ClientConfig::builder()
-		.with_safe_defaults()
-		.with_root_certificates(root_store)
-		.with_no_client_auth();
-
-	Ok(TlsConnector::from(std::sync::Arc::new(config)))
-}
-
-async fn connect(domain: &str, streams: &str) -> Result<FragmentCollector<TokioIo<Upgraded>>> {
-	let mut addr = String::from(domain);
-	addr.push_str(":443");
-
-	let tcp_stream: TcpStream = TcpStream::connect(&addr).await?;
-	let tls_connector: TlsConnector = tls_connector().unwrap();
-	let domain: tokio_rustls::rustls::ServerName =
-	tokio_rustls::rustls::ServerName::try_from(domain).map_err(|_| {
-		std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid dnsname")
-	})?;
-
-	let tls_stream: tokio_rustls::client::TlsStream<TcpStream> = tls_connector.connect(domain, tcp_stream).await?;
-
-    let url = format!("wss://{}/stream?streams={}", &addr, streams);
-
-	let req: Request<Empty<Bytes>> = Request::builder()
-	.method("GET")
-	.uri(url)
-	.header("Host", &addr)
-	.header(UPGRADE, "websocket")
-	.header(CONNECTION, "upgrade")
-	.header(
-		"Sec-WebSocket-Key",
-		fastwebsockets::handshake::generate_key(),
-	)
-	.header("Sec-WebSocket-Version", "13")
-	.body(Empty::<Bytes>::new())?;
-
-	let (ws, _) = fastwebsockets::handshake::client(&SpawnExecutor, req, tls_stream).await?;
-	Ok(FragmentCollector::new(ws))
-}
-struct SpawnExecutor;
-
-impl<Fut> hyper::rt::Executor<Fut> for SpawnExecutor
-where
-  Fut: std::future::Future + Send + 'static,
-  Fut::Output: Send + 'static,
-{
-  fn execute(&self, fut: Fut) {
-	tokio::task::spawn(fut);
-  }
-}
-
-pub fn connect_market_stream(ticker: Ticker) -> impl Stream<Item = Event> {    
-    stream::channel (
-        100,
-        move |mut output| async move {
-            let mut state = State::Disconnected;     
-            let mut trades_buffer: Vec<Trade> = Vec::new(); 
-
-            let selected_ticker = ticker;
-
-            let symbol_str = match selected_ticker {
-                Ticker::BTCUSDT => "btcusdt",
-                Ticker::ETHUSDT => "ethusdt",
-                Ticker::SOLUSDT => "solusdt",
-                Ticker::LTCUSDT => "ltcusdt",
-            };
-
-            let stream_1 = format!("{symbol_str}@aggTrade");
-            let stream_2 = format!("{symbol_str}@depth@100ms");
-
-            let mut orderbook: LocalDepthCache = LocalDepthCache::new();
-
-            let mut already_fetching: bool = false;
-
-            let mut prev_id: u64 = 0;
-
-            let mut trade_latencies: Vec<i64> = Vec::new();
-
-            loop {
-                match &mut state {
-                    State::Disconnected => {        
-                        let streams = format!("{stream_1}/{stream_2}");
-
-                        let domain: &str = "fstream.binance.com";
-
-                        if let Ok(websocket) = connect(domain, streams.as_str()
-                        )
-                        .await {
-                            let (tx, rx) = tokio::sync::oneshot::channel();
-                                                
-                            tokio::spawn(async move {
-                                let fetched_depth = fetch_depth(selected_ticker).await;
-
-                                let depth = match fetched_depth {
-                                    Ok(depth) => {
-                                        LocalDepthCache {
-                                            last_update_id: depth.update_id,
-                                            time: depth.time,
-                                            bids: depth.bids,
-                                            asks: depth.asks,
-                                        }
-                                    },
-                                    Err(e) => {
-                                        log::error!("Failed to fetch depth for {}, error: {}", symbol_str, e);
-                                        return;
-                                    }
-                                };
-
-                                let _ = tx.send(depth);
-                            });
-                            match rx.await {
-                                Ok(depth) => {
-                                    orderbook.fetched(depth);
-
-                                    prev_id = 0;
-
-                                    state = State::Connected(websocket);
-                                    let _ = output.send(Event::Connected(Connection)).await;                                 
-                                },
-                                Err(e) => {
-                                    let _ = output.send(Event::Disconnected(
-                                        format!("Failed to send fetched depth for {}, error: {}", symbol_str, e)
-                                    )).await.expect("Trying to send disconnect event...");
-                                }
-                            }
-                        } else {
-                            tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
-
-                            let _ = output.send(Event::Disconnected(
-                                "Failed to connect to websocket".to_string()
-                            )).await;
-                        }
-                    },
-                    State::Connected(ws) => {
-                        let feed_latency: FeedLatency;
-
-                        match ws.read_frame().await {
-                            Ok(msg) => match msg.opcode {
-                                OpCode::Text => {                    
-                                    let json_bytes: Bytes = Bytes::from(msg.payload.to_vec());
-                    
-                                    if let Ok(data) = feed_de(&json_bytes) {
-                                        match data {
-                                            StreamData::Trade(de_trade) => {
-                                                let trade = Trade {
-                                                    time: de_trade.time as i64,
-                                                    is_sell: de_trade.is_sell,
-                                                    price: str_f32_parse(&de_trade.price),
-                                                    qty: str_f32_parse(&de_trade.qty),
-                                                };
-
-                                                trade_latencies.push(
-                                                    chrono::Utc::now().timestamp_millis() - trade.time
-                                                );
-
-                                                trades_buffer.push(trade);
-                                            },
-                                            StreamData::Depth(de_depth) => {
-                                                if already_fetching {
-                                                    log::warn!("Already fetching...\n");
-    
-                                                    continue;
-                                                }
-    
-                                                let last_update_id = orderbook.get_fetch_id() as u64;
-                                                
-                                                if (de_depth.final_id <= last_update_id) || last_update_id == 0 {
-                                                    continue;
-                                                }
-    
-                                                if prev_id == 0 && (de_depth.first_id > last_update_id + 1) || (last_update_id + 1 > de_depth.final_id) {
-                                                    log::warn!("Out of sync at first event. Trying to resync...\n");
-    
-                                                    let (tx, rx) = tokio::sync::oneshot::channel();
-                                                    already_fetching = true;
-    
-                                                    tokio::spawn(async move {
-                                                        let fetched_depth = fetch_depth(selected_ticker).await;
-    
-                                                        let depth = match fetched_depth {
-                                                            Ok(depth) => {
-                                                                LocalDepthCache {
-                                                                    last_update_id: depth.update_id,
-                                                                    time: depth.time,
-                                                                    bids: depth.bids,
-                                                                    asks: depth.asks,
-                                                                }
-                                                            },
-                                                            Err(e) => {
-                                                                log::error!("Failed to fetch depth for {}, error: {}", symbol_str, e);
-                                                                return;
-                                                            }
-                                                        };
-    
-                                                        let _ = tx.send(depth);
-                                                    });
-                                                    match rx.await {
-                                                        Ok(depth) => {
-                                                            orderbook.fetched(depth)
-                                                        },
-                                                        Err(e) => {
-                                                            state = State::Disconnected;
-                                                            let _ = output.send(Event::Disconnected(
-                                                                format!("Failed to send fetched depth for {}, error: {}", symbol_str, e)
-                                                            )).await.expect("Trying to send disconnect event...");
-                                                        }
-                                                    }
-                                                    already_fetching = false;
-                                                }
-                                        
-                                                if (prev_id == 0) || (prev_id == de_depth.prev_final_id) {
-                                                    let time = de_depth.time as i64;
-    
-                                                    let depth_latency = chrono::Utc::now().timestamp_millis() - time;
-    
-                                                    let depth_update = LocalDepthCache {
-                                                        last_update_id: de_depth.final_id as i64,
-                                                        time,
-                                                        bids: de_depth.bids.iter().map(
-                                                            |x| Order { price: str_f32_parse(&x.price), qty: str_f32_parse(&x.qty) }
-                                                        ).collect(),
-                                                        asks: de_depth.asks.iter().map(
-                                                            |x| Order { price: str_f32_parse(&x.price), qty: str_f32_parse(&x.qty) }
-                                                        ).collect(),
-                                                    };
-    
-                                                    orderbook.update_depth_cache(depth_update);
-                                                    
-                                                    let avg_trade_latency = if !trade_latencies.is_empty() {
-                                                        let avg = trade_latencies.iter().sum::<i64>() / trade_latencies.len() as i64;
-                                                        trade_latencies.clear();
-                                                        Some(avg)
-                                                    } else {
-                                                        None
-                                                    };
-                                                    feed_latency = FeedLatency {
-                                                        time,
-                                                        depth_latency,
-                                                        trade_latency: avg_trade_latency,
-                                                    };
-    
-                                                    let _ = output.send(
-                                                        Event::DepthReceived(
-                                                            selected_ticker,
-                                                            feed_latency,
-                                                            time, 
-                                                            orderbook.get_depth(),
-                                                            std::mem::take(&mut trades_buffer)
-                                                        )
-                                                    ).await;
-    
-                                                    prev_id = de_depth.final_id;
-                                                } else {
-                                                    state = State::Disconnected;
-                                                    let _ = output.send(
-                                                        Event::Disconnected(
-                                                            format!("Out of sync. Expected update_id: {}, got: {}", de_depth.prev_final_id, prev_id)
-                                                        )
-                                                    ).await;
-                                                }
-                                            },
-                                            _ => {}
-                                        }
-                                    } else {
-                                        log::error!("\nUnknown data: {:?}", &json_bytes);
-                                    }
-                                }
-                                OpCode::Close => {
-                                    state = State::Disconnected;
-                                    let _ = output.send(
-                                        Event::Disconnected("Connection closed".to_string())
-                                    ).await;
-                                }
-                                _ => {}
-                            },
-                            Err(e) => {    
-                                state = State::Disconnected;           
-                                let _ = output.send(
-                                    Event::Disconnected("Error reading frame: ".to_string() + &e.to_string())
-                                ).await;
-                            }
-                        };
-                    }
-                }
-            }
-        },
-    )
-}
-
-pub fn connect_kline_stream(streams: Vec<(Ticker, Timeframe)>) -> impl Stream<Item = Event> {    
-    stream::channel (
-        100,
-        move |mut output| async move {
-            let mut state = State::Disconnected;    
-
-            let stream_str = streams.iter().map(|(ticker, timeframe)| {
-                let symbol_str = match ticker {
-                    Ticker::BTCUSDT => "btcusdt",
-                    Ticker::ETHUSDT => "ethusdt",
-                    Ticker::SOLUSDT => "solusdt",
-                    Ticker::LTCUSDT => "ltcusdt",
-                };
-                let timeframe_str = match timeframe {
-                    Timeframe::M1 => "1m",
-                    Timeframe::M3 => "3m",
-                    Timeframe::M5 => "5m",
-                    Timeframe::M15 => "15m",
-                    Timeframe::M30 => "30m",
-                };
-                format!("{symbol_str}@kline_{timeframe_str}")
-            }).collect::<Vec<String>>().join("/");
-
-            loop {
-                match &mut state {
-                    State::Disconnected => {
-                        let domain: &str = "fstream.binance.com";
-
-                        let streams = stream_str.as_str();
-                        
-                        if let Ok(websocket) = connect(
-                            domain, streams
-                        )
-                        .await {
-                            state = State::Connected(websocket);
-                            let _ = output.send(Event::Connected(Connection)).await;        
-                        } else {
-                            tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
-
-                            let _ = output.send(Event::Disconnected(
-                                "Failed to connect to websocket".to_string()
-                            )).await;
-                        }
-                    },
-                    State::Connected(ws) => {
-                        match ws.read_frame().await {
-                            Ok(msg) => match msg.opcode {
-                                OpCode::Text => {                    
-                                    let json_bytes: Bytes = Bytes::from(msg.payload.to_vec());
-                    
-                                    if let Ok(StreamData::Kline(ticker, de_kline)) = feed_de(&json_bytes) {
-                                        let buy_volume = str_f32_parse(&de_kline.taker_buy_base_asset_volume);
-                                        let sell_volume = str_f32_parse(&de_kline.volume) - buy_volume;
-
-                                        let kline = Kline {
-                                            time: de_kline.time,
-                                            open: str_f32_parse(&de_kline.open),
-                                            high: str_f32_parse(&de_kline.high),
-                                            low: str_f32_parse(&de_kline.low),
-                                            close: str_f32_parse(&de_kline.close),
-                                            volume: (buy_volume, sell_volume),
-                                        };
-
-                                        if let Some(timeframe) = streams.iter().find(|(_, tf)| tf.to_string() == de_kline.interval) {
-                                            let _ = output.send(Event::KlineReceived(ticker, kline, timeframe.1)).await;
-                                        }
-                                    } else {
-                                        log::error!("\nUnknown data: {:?}", &json_bytes);
-                                    }
-                                }
-                                OpCode::Close => {
-                                    state = State::Disconnected;
-                                    let _ = output.send(
-                                        Event::Disconnected("Connection closed".to_string())
-                                    ).await;
-                                }
-                                _ => {}
-                            }, 
-                            Err(e) => {      
-                                state = State::Disconnected;        
-                                let _ = output.send(
-                                    Event::Disconnected("Error reading frame: ".to_string() + &e.to_string())
-                                ).await;  
-                            }
-                        }
-                    }
-                }
-            }
-        },
-    )
-}
-
-fn str_f32_parse(s: &str) -> f32 {
-    s.parse::<f32>().unwrap_or_else(|e| {
-        log::error!("Failed to parse float: {}, error: {}", s, e);
-        0.0
-    })
-}
-
-mod string_to_f32 {
-    use serde::{self, Deserialize, Deserializer};
-
-    pub fn deserialize<'de, D>(deserializer: D) -> Result<f32, D::Error>
-    where
-        D: Deserializer<'de>,
-    {
-        let s: &str = <&str>::deserialize(deserializer)?;
-        s.parse::<f32>().map_err(serde::de::Error::custom)
-    }
-}
-
-#[allow(dead_code)]
-#[derive(Deserialize, Debug, Clone)]
-struct FetchedKlines (
-    u64,
-    #[serde(with = "string_to_f32")] f32,
-    #[serde(with = "string_to_f32")] f32,
-    #[serde(with = "string_to_f32")] f32,
-    #[serde(with = "string_to_f32")] f32,
-    #[serde(with = "string_to_f32")] f32,
-    u64,
-    String,
-    u32,
-    #[serde(with = "string_to_f32")] f32,
-    String,
-    String,
-);
-impl From<FetchedKlines> for Kline {
-    fn from(fetched: FetchedKlines) -> Self {
-        let sell_volume = fetched.5 - fetched.9;
-
-        Self {
-            time: fetched.0,
-            open: fetched.1,
-            high: fetched.2,
-            low: fetched.3,
-            close: fetched.4,
-            volume: (fetched.9, sell_volume),
-        }
-    }
-}
-
-pub async fn fetch_klines(ticker: Ticker, timeframe: Timeframe) -> Result<Vec<Kline>, StreamError> {
-    let symbol_str = ticker.get_string();
-    let timeframe_str = match timeframe {
-        Timeframe::M1 => "1m",
-        Timeframe::M3 => "3m",
-        Timeframe::M5 => "5m",
-        Timeframe::M15 => "15m",
-        Timeframe::M30 => "30m",
-    };
-
-    let url = format!("https://fapi.binance.com/fapi/v1/klines?symbol={symbol_str}&interval={timeframe_str}&limit=720");
-
-    let response = reqwest::get(&url)
-        .await.map_err(StreamError::FetchError)?;
-    let text = response.text()
-        .await.map_err(StreamError::FetchError)?;
-
-    let fetched_klines: Vec<FetchedKlines> = serde_json::from_str(&text)
-        .map_err(|e| StreamError::ParseError(format!("Failed to parse klines: {}", e)))?;
-
-    let klines= fetched_klines.into_iter().map(Kline::from).collect();
-
-    Ok(klines)
-}
-
-pub async fn fetch_depth(ticker: Ticker) -> Result<FetchedDepth, StreamError> {
-    let symbol_str = ticker.get_string();
-
-    let url = format!("https://fapi.binance.com/fapi/v1/depth?symbol={symbol_str}&limit=1000");
-
-    let response = reqwest::get(&url)
-        .await.map_err(StreamError::FetchError)?;
-    let text = response.text().await
-        .map_err(StreamError::FetchError)?;
-
-    let depth: FetchedDepth = serde_json::from_str(&text).map_err(|e| {
-        log::error!("Failed to parse depth: {}", text);
-        StreamError::ParseError(e.to_string())
-    })?;
-
-    Ok(depth)
-}
-
-pub async fn fetch_ticksize(ticker: Ticker) -> Result<f32, StreamError> {
-    let symbol_str = ticker.get_string().to_uppercase();
-    let url = "https://fapi.binance.com/fapi/v1/exchangeInfo".to_string();
-
-    let response = reqwest::get(&url).await.map_err(StreamError::FetchError)?;
-    let text = response.text().await.map_err(StreamError::FetchError)?;
-
-    let exchange_info: serde_json::Value = serde_json::from_str(&text)
-        .map_err(|e| StreamError::ParseError(format!("Failed to parse exchange info: {}", e)))?;
-
-    let symbols = exchange_info["symbols"].as_array()
-        .ok_or_else(|| StreamError::ParseError("Missing symbols array".to_string()))?;
-
-    let symbol = symbols.iter()
-        .find(|x| x["symbol"].as_str().unwrap_or_default() == symbol_str)
-        .ok_or_else(|| StreamError::ParseError(format!("Symbol {} not found", symbol_str)))?;
-
-    let tick_size_str = symbol["filters"].as_array()
-        .ok_or_else(|| StreamError::ParseError("Missing filters array".to_string()))?
-        .iter()
-        .find(|x| x["filterType"].as_str().unwrap_or_default() == "PRICE_FILTER")
-        .ok_or_else(|| StreamError::ParseError("PRICE_FILTER not found".to_string()))?
-        ["tickSize"].as_str()
-        .ok_or_else(|| StreamError::ParseError("tickSize not found".to_string()))?;
-
-    let tick_size = tick_size_str.parse::<f32>()
-        .map_err(|e| StreamError::ParseError(format!("Failed to parse tickSize: {}", e)))?;
-
-    Ok(tick_size)
-}

+ 0 - 472
src/data_providers/binance/user_data.rs

@@ -1,472 +0,0 @@
-use iced::{futures, stream};
-use futures::stream::{Stream, StreamExt};
-use reqwest::header::{HeaderMap, HeaderValue};
-use hmac::{Hmac, Mac};
-use sha2::Sha256;
-use hex;
-use futures::channel::mpsc;
-use futures::sink::SinkExt;
-use chrono::Utc;
-use serde::Deserialize;
-use serde_json::json;
-use futures::FutureExt;
-use async_tungstenite::tungstenite;
-
-mod string_to_f32 {
-    use serde::{self, Deserialize, Deserializer};
-
-    pub fn deserialize<'de, D>(deserializer: D) -> Result<f32, D::Error>
-    where
-        D: Deserializer<'de>,
-    {
-        let s = String::deserialize(deserializer)?;
-        s.parse::<f32>().map_err(serde::de::Error::custom)
-    }
-}
-
-#[derive(Debug)]
-#[allow(clippy::large_enum_variant)]
-enum State {
-    Disconnected,
-    Connected(
-        async_tungstenite::WebSocketStream<
-            async_tungstenite::tokio::ConnectStream,
-        >,
-    ),
-}
-
-#[derive(Debug, Clone)]
-pub enum Event {
-    Connected(Connection),
-    Disconnected,
-    NewOrder(NewOrder),
-    CancelOrder(OrderTradeUpdate),
-    TestEvent(String),
-    NewPositions(Vec<Position>),
-    FetchedPositions(Vec<FetchedPosition>),
-    FetchedBalance(Vec<FetchedBalance>),
-}
-
-#[derive(Debug, Clone)]
-pub struct Connection(mpsc::Sender<String>);
-
-pub fn connect_user_stream(listen_key: String) -> impl Stream<Item = Event> {
-    stream::channel(
-        100,
-        |mut output| async move {
-            let mut state = State::Disconnected;     
- 
-            loop {
-                match &mut state {
-                    State::Disconnected => {
-                        let websocket_server = format!(
-                            "wss://stream.binancefuture.com/ws/{}",
-                            listen_key
-                        );
-        
-                        if let Ok((websocket, _)) = async_tungstenite::tokio::connect_async(
-                            websocket_server,
-                        )
-                        .await {
-                            state = State::Connected(websocket);
-                            log::info!("Connected to user stream");
-                        } else {
-                            tokio::time::sleep(tokio::time::Duration::from_secs(1))
-                            .await;
-                            log::info!("Failed to connect to user stream");
-                            let _ = output.send(Event::Disconnected).await;
-                        }
-                    }
-                    State::Connected(websocket) => {
-                        let mut fused_websocket = websocket.by_ref().fuse();
-
-                        futures::select! {
-                            received = fused_websocket.select_next_some() => {
-                                match received {
-                                    Ok(tungstenite::Message::Text(message)) => {
-                                        let parsed_message: Result<serde_json::Value, _> = serde_json::from_str(&message);
-                                        match parsed_message {
-                                            Ok(data) => {
-                                                let event;
-                                                if data["e"] == "ACCOUNT_UPDATE" {
-                                                    if let Some(account_update) = data["a"].as_object() {
-                                                        let account_update: AccountUpdate = serde_json::from_value(json!(account_update)).unwrap();
-                                                        if account_update.event_type == "ORDER" {
-                                                            event = Event::NewPositions(account_update.positions);
-                                                        } else {
-                                                            event = Event::TestEvent("Account Update".to_string());
-                                                        }
-                                                    } else {
-                                                        event = Event::TestEvent("Unknown".to_string());
-                                                    }
-                                                } else if data["e"] == "ORDER_TRADE_UPDATE" {
-                                                    if let Some(order_trade_update) = data["o"].as_object() {
-                                                        let order_trade_update: OrderTradeUpdate = serde_json::from_value(json!(order_trade_update)).unwrap();
-                                                        if order_trade_update.exec_type == "NEW" {
-                                                            event = Event::TestEvent("New Order".to_string());
-                                                        } else if order_trade_update.exec_type == "TRADE" {
-                                                            event = Event::TestEvent("Trade".to_string());
-                                                        } else if order_trade_update.exec_type == "CANCELED" {
-                                                            event = Event::CancelOrder(order_trade_update);
-                                                        } else {
-                                                            event = Event::TestEvent("Unknown".to_string());
-                                                        }
-                                                    } else {
-                                                        event = Event::TestEvent("Unknown".to_string());
-                                                    }
-
-                                                } else {
-                                                    event = Event::TestEvent("Unknown".to_string());
-                                                }
-                                                let _ = output.send(event).await;
-                                            },
-                                            Err(e) => {
-                                                log::error!("Failed to parse message: {e:?}");
-                                            }
-                                        }
-                                    }
-                                    Err(_) => {
-                                        log::info!("Disconnected from user stream");
-                                        let _ = output.send(Event::Disconnected).await;
-                                        state = State::Disconnected;
-                                    }
-                                    Ok(_) => continue,
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        },
-    )
-}
-
-pub fn fetch_user_stream(api_key: &str, secret_key: &str) -> impl Stream<Item = Event> {
-    let api_key = api_key.to_owned();
-    let secret_key = secret_key.to_owned();
-
-    stream::channel(
-        100,
-        move |mut output| {
-            tokio::spawn(async move {
-                loop {
-                    let fetch_positions = fetch_open_positions(&api_key, &secret_key);
-                    let fetch_balance = fetch_acc_balance(&api_key, &secret_key);
-
-                    let (fetched_positions, fetched_balance) = futures::join!(fetch_positions, fetch_balance);
-
-                    match fetched_positions {
-                        Ok(positions) => {
-                            let _ = output.send(Event::FetchedPositions(positions)).await;
-                        }
-                        Err(e) => {
-                            log::error!("Error fetching positions: {e:?}");
-                        }
-                    }
-
-                    match fetched_balance {
-                        Ok(balance) => {
-                            let _ = output.send(Event::FetchedBalance(balance)).await;
-                        }
-                        Err(e) => {
-                            log::error!("Error fetching balance: {e:?}");
-                        }
-                    }
-
-                    tokio::time::sleep(std::time::Duration::from_secs(19)).await;
-                }
-            })
-        }.map(|result| result.expect("Failed to join"))
-    )
-}
-
-#[derive(Debug, Clone, Deserialize)]
-pub struct AccBalance {
-    #[serde(rename = "a")]
-    pub asset: String,
-    #[serde(rename = "wb")]
-    pub wallet_bal: String,
-    #[serde(rename = "cw")]
-    pub cross_bal: String,
-    #[serde(rename = "bc")]
-    pub balance_chg: String,
-}
-
-#[derive(Debug, Clone, Deserialize, PartialEq)]
-pub struct FetchedBalance {
-    pub asset: String,
-    #[serde(with = "string_to_f32", rename = "balance")]
-    pub balance: f32,
-    #[serde(with = "string_to_f32", rename = "crossWalletBalance")]
-    pub cross_bal: f32,
-    #[serde(with = "string_to_f32", rename = "crossUnPnl")]
-    pub cross_upnl: f32,
-    #[serde(with = "string_to_f32", rename = "availableBalance")]
-    pub available_bal: f32,
-}
-
-#[derive(Debug, Clone, Deserialize)]
-pub struct Position {
-    #[serde(rename = "s")]
-    pub symbol: String,
-    #[serde(with = "string_to_f32", rename = "pa")]
-    pub pos_amt: f32,
-    #[serde(with = "string_to_f32", rename = "ep")]
-    pub entry_price: f32,
-    #[serde(with = "string_to_f32", rename = "bep")]
-    pub breakeven_price: f32,
-    #[serde(rename = "up")]
-    pub unrealized_pnl: String,
-    #[serde(rename = "mt")]
-    pub margin_type: String,
-    #[serde(rename = "iw")]
-    pub isolated_wallet: String,
-    #[serde(rename = "ps")]
-    pub pos_side: String,
-}
-
-#[derive(Debug, Clone, Deserialize)]
-pub struct FetchedPosition {
-    pub symbol: String,
-    #[serde(with = "string_to_f32", rename = "positionAmt")]
-    pub pos_amt: f32,
-    #[serde(with = "string_to_f32", rename = "entryPrice")]
-    pub entry_price: f32,
-    #[serde(with = "string_to_f32", rename = "breakEvenPrice")]
-    pub breakeven_price: f32,
-    #[serde(with = "string_to_f32", rename = "markPrice")]
-    pub mark_price: f32,
-    #[serde(with = "string_to_f32", rename = "unRealizedProfit")]
-    pub unrealized_pnl: f32,
-    #[serde(with = "string_to_f32", rename = "liquidationPrice")]
-    pub liquidation_price: f32,
-    #[serde(with = "string_to_f32", rename = "leverage")]
-    pub leverage: f32,
-    #[serde(rename = "marginType")]
-    pub margin_type: String,
-}
-
-#[derive(Debug, Clone)]
-pub struct PositionInTable {
-    pub symbol: String,
-    pub size: f32,
-    pub entry_price: f32,
-    pub breakeven_price: f32,
-    pub mark_price: f32,
-    pub liquidation_price: f32,
-    pub margin_amt: f32,
-    pub unrealized_pnl: f32,
-}
-
-pub enum EventType {
-    AccountUpdate,
-    OrderTradeUpdate,
-}
-
-#[derive(Debug, Clone, Deserialize)]
-pub struct AccountUpdate {
-    #[serde(rename = "m")]
-    pub event_type: String,
-    #[serde(rename = "B")]
-    pub balances: Vec<AccBalance>,
-    #[serde(rename = "P")]
-    pub positions: Vec<Position>,
-}
-
-#[derive(Debug, Clone, Deserialize)]
-pub struct OrderTradeUpdate {
-    #[serde(rename = "s")]
-    pub symbol: String,
-    #[serde(rename = "S")]
-    pub side: String,
-    #[serde(rename = "o")]
-    pub order_type: String,
-    #[serde(rename = "x")]
-    pub exec_type: String,
-    #[serde(rename = "X")]
-    pub order_status: String,
-    #[serde(rename = "f")]
-    pub time_in_force: String,
-    #[serde(rename = "wt")]
-    pub working_type: String,
-    #[serde(rename = "i")]
-    pub order_id: i64,
-    #[serde(rename = "p")]
-    pub price: String,
-    #[serde(rename = "q")]
-    pub orig_qty: String,
-}
-
-#[derive(Debug)]
-pub enum BinanceError {
-    Reqwest(reqwest::Error),
-    BinanceAPI(String),
-}
-
-impl From<reqwest::Error> for BinanceError {
-    fn from(err: reqwest::Error) -> BinanceError {
-        BinanceError::Reqwest(err)
-    }
-}
-
-#[derive(Debug, Clone, Deserialize)]
-pub struct NewOrder {
-    #[serde(rename = "orderId")]
-    pub order_id: i64,
-    pub symbol: String,
-    pub side: String,
-    pub price: String,
-    #[serde(rename = "origQty")]
-    pub orig_qty: String,
-    #[serde(rename = "executedQty")]
-    pub executed_qty: String,
-    #[serde(rename = "timeInForce")]
-    pub time_in_force: String,
-    #[serde(rename = "type")]
-    pub order_type: String,
-    #[serde(rename = "reduceOnly")]
-    pub reduce_only: bool,
-    #[serde(rename = "updateTime")]
-    pub update_time: u64,
-}
-
-pub async fn create_limit_order (side: String, qty: String, price: String, api_key: &str, secret_key: &str) -> Result<NewOrder, BinanceError> {
-    let params = format!("symbol=BTCUSDT&side={}&type=LIMIT&timeInForce=GTC&quantity={}&price={}&timestamp={}", side, qty, price, Utc::now().timestamp_millis());
-    let signature = sign_params(&params, secret_key);
-
-    let url = format!("https://testnet.binancefuture.com/fapi/v1/order?{}&signature={}", params, signature);
-
-    let mut headers = HeaderMap::new();
-    headers.insert("X-MBX-APIKEY", HeaderValue::from_str(api_key).unwrap());
-
-    let client = reqwest::Client::new();
-    let res = client.post(&url).headers(headers).send().await?;
-
-    if res.status().is_success() {
-        let limit_order: NewOrder = res.json().await.map_err(BinanceError::Reqwest)?;
-        Ok(limit_order)
-    } else {
-        let error_msg: String = res.text().await.map_err(BinanceError::Reqwest)?;
-        Err(BinanceError::BinanceAPI(error_msg))
-    }
-}
-
-pub async fn create_market_order (side: String, qty: String, api_key: &str, secret_key: &str) -> Result<NewOrder, BinanceError> {
-    let params = format!("symbol=BTCUSDT&side={}&type=MARKET&quantity={}&timestamp={}", side, qty, Utc::now().timestamp_millis());
-    let signature = sign_params(&params, secret_key);
-
-    let url = format!("https://testnet.binancefuture.com/fapi/v1/order?{params}&signature={signature}");
-
-    let mut headers = HeaderMap::new();
-    headers.insert("X-MBX-APIKEY", HeaderValue::from_str(api_key).unwrap());
-
-    let client = reqwest::Client::new();
-    let res = client.post(&url).headers(headers).send().await?;
-
-    if res.status().is_success() {
-        let market_order: NewOrder = res.json().await.map_err(BinanceError::Reqwest)?;
-        Ok(market_order)
-    } else {
-        let error_msg: String = res.text().await.map_err(BinanceError::Reqwest)?;
-        Err(BinanceError::BinanceAPI(error_msg))
-    }
-}
-
-pub async fn cancel_order(order_id: String, api_key: &str, secret_key: &str) -> Result<(), BinanceError> {
-    let params = format!("symbol=BTCUSDT&orderId={}&timestamp={}", order_id, Utc::now().timestamp_millis());
-    let signature = sign_params(&params, secret_key);
-
-    let url = format!("https://testnet.binancefuture.com/fapi/v1/order?{params}&signature={signature}");
-
-    let mut headers = HeaderMap::new();
-    headers.insert("X-MBX-APIKEY", HeaderValue::from_str(api_key).unwrap());
-
-    let client = reqwest::Client::new();
-    let res = client.delete(&url).headers(headers).send().await?;
-
-    if res.status().is_success() {
-        Ok(())
-    } else {
-        let error_msg: String = res.text().await.map_err(BinanceError::Reqwest)?;
-        Err(BinanceError::BinanceAPI(error_msg))
-    }
-}
-
-pub async fn fetch_open_orders(symbol: String, api_key: &str, secret_key: &str) -> Result<Vec<NewOrder>, BinanceError> {
-    let params = format!("timestamp={}&symbol={}", Utc::now().timestamp_millis(), symbol);
-    let signature = sign_params(&params, secret_key);
-
-    let url = format!("https://testnet.binancefuture.com/fapi/v1/openOrders?{params}&signature={signature}");
-
-    let mut headers = HeaderMap::new();
-    headers.insert("X-MBX-APIKEY", HeaderValue::from_str(api_key).unwrap());
-
-    let client = reqwest::Client::new();
-    let res = client.get(&url).headers(headers).send().await?;
-
-    let open_orders: Vec<NewOrder> = res.json().await?;
-    Ok(open_orders)
-}
-
-pub async fn fetch_open_positions(api_key: &str, secret_key: &str) -> Result<Vec<FetchedPosition>, BinanceError> {
-    let params = format!("timestamp={}", Utc::now().timestamp_millis());
-    let signature = sign_params(&params, secret_key);
-
-    let url = format!("https://testnet.binancefuture.com/fapi/v2/positionRisk?{params}&signature={signature}");
-
-    let mut headers = HeaderMap::new();
-    headers.insert("X-MBX-APIKEY", HeaderValue::from_str(api_key).unwrap());
-
-    let client = reqwest::Client::new();
-    let res = client.get(&url).headers(headers).send().await?;
-
-    let positions: Vec<FetchedPosition> = res.json().await?;
-
-    Ok(positions)
-}
-
-pub async fn fetch_acc_balance(api_key: &str, secret_key: &str) -> Result<Vec<FetchedBalance>, BinanceError> {
-    let params = format!("timestamp={}", Utc::now().timestamp_millis());
-    let signature = sign_params(&params, secret_key);
-
-    let url = format!("https://testnet.binancefuture.com/fapi/v2/balance?{params}&signature={signature}");
-
-    let mut headers = HeaderMap::new();
-    headers.insert("X-MBX-APIKEY", HeaderValue::from_str(api_key).unwrap());
-
-    let client = reqwest::Client::new();
-    let res = client.get(&url).headers(headers).send().await?;
-
-    let acc_balance: Vec<FetchedBalance> = res.json().await?;
-    Ok(acc_balance)
-}
-
-pub async fn get_listen_key(api_key: &str, secret_key: &str) -> Result<String, BinanceError> {
-    let params = format!("timestamp={}", Utc::now().timestamp_millis());
-    let signature = sign_params(&params, secret_key);
-
-    let url = format!("https://testnet.binancefuture.com/fapi/v1/listenKey?{params}&signature={signature}");
-
-    let mut headers = HeaderMap::new();
-    headers.insert("X-MBX-APIKEY", HeaderValue::from_str(api_key).unwrap());
-
-    let client = reqwest::Client::new();
-    let res = client.post(&url).headers(headers).send().await?;
-
-    let listen_key: serde_json::Value = res.json().await?;
-    
-    if let Some(key) = listen_key.get("listenKey") {
-        Ok(key.as_str().unwrap().to_string())
-    } else {
-        Err(BinanceError::BinanceAPI("Failed to get listen key".to_string()))
-    }
-}
-
-fn sign_params(params: &str, secret_key: &str) -> String {
-    type HmacSha256 = Hmac<Sha256>;
-
-    let mut mac = HmacSha256::new_from_slice(secret_key.as_bytes())
-        .expect("HMAC can take key of any size");
-    mac.update(params.as_bytes());
-    hex::encode(mac.finalize().into_bytes())
-}

+ 761 - 1
src/data_providers/bybit.rs

@@ -1 +1,761 @@
-pub mod market_data;
+use crate::data_providers::deserialize_string_to_f32;
+use crate::data_providers::deserialize_string_to_i64;
+use std::collections::HashMap;
+
+use iced::{
+    stream, 
+    futures::{sink::SinkExt, Stream},
+};
+
+use regex::Regex;
+use serde_json::json;
+use serde_json::Value;
+
+use sonic_rs::{JsonValueTrait, Deserialize, Serialize};
+use sonic_rs::to_object_iter_unchecked;
+
+use fastwebsockets::{Frame, FragmentCollector, OpCode};
+use hyper::upgrade::Upgraded;
+use hyper_util::rt::TokioIo;
+
+use crate::data_providers::{
+    setup_tcp_connection, setup_tls_connection, setup_websocket_connection, 
+    Connection, Event, Kline, LocalDepthCache, MarketType, Order, State, 
+    StreamError, TickerInfo, TickerStats, Trade, VecLocalDepthCache,
+};
+use crate::{Ticker, Timeframe};
+
+use super::OpenInterest;
+
+#[derive(Serialize, Deserialize, Debug)]
+struct SonicDepth {
+    #[serde(rename = "u")]
+    pub update_id: u64,
+    #[serde(rename = "b")]
+    pub bids: Vec<BidAsk>,
+    #[serde(rename = "a")]
+    pub asks: Vec<BidAsk>,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct BidAsk {
+    #[serde(rename = "0")]
+    pub price: String,
+    #[serde(rename = "1")]
+    pub qty: String,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct SonicTrade {
+    #[serde(rename = "T")]
+    pub time: u64,
+    #[serde(rename = "p")]
+    pub price: String,
+    #[serde(rename = "v")]
+    pub qty: String,
+    #[serde(rename = "S")]
+    pub is_sell: String,
+}
+
+#[derive(Deserialize, Debug, Clone)]
+pub struct SonicKline {
+    #[serde(rename = "start")]
+    pub time: u64,
+    #[serde(rename = "open")]
+    pub open: String,
+    #[serde(rename = "high")]
+    pub high: String,
+    #[serde(rename = "low")]
+    pub low: String,
+    #[serde(rename = "close")]
+    pub close: String,
+    #[serde(rename = "volume")]
+    pub volume: String,
+    #[serde(rename = "interval")]
+    pub interval: String,
+}
+
+#[derive(Debug)]
+enum StreamData {
+    Trade(Vec<SonicTrade>),
+    Depth(SonicDepth, String, i64),
+    Kline(Ticker, Vec<SonicKline>),
+}
+
+#[derive(Debug)]
+enum StreamName {
+    Depth(Ticker),
+    Trade(Ticker),
+    Kline(Ticker),
+    Unknown,
+}
+impl StreamName {
+    fn from_topic(topic: &str, is_ticker: Option<Ticker>, market_type: MarketType) -> Self {
+        let parts: Vec<&str> = topic.split('.').collect();
+
+        if let Some(ticker_str) = parts.last() {
+            let ticker = is_ticker.unwrap_or_else(|| Ticker::new(ticker_str, market_type));
+
+            match parts.first() {
+                Some(&"publicTrade") => StreamName::Trade(ticker),
+                Some(&"orderbook") => StreamName::Depth(ticker),
+                Some(&"kline") => StreamName::Kline(ticker),
+                _ => StreamName::Unknown,
+            }
+        } else {
+            StreamName::Unknown
+        }
+    }
+}
+
+#[derive(Debug)]
+enum StreamWrapper {
+    Trade,
+    Depth,
+    Kline,
+}
+
+#[allow(unused_assignments)]
+fn feed_de(
+    slice: &[u8], 
+    ticker: Option<Ticker>, 
+    market_type: MarketType
+) -> Result<StreamData, StreamError> {
+    let mut stream_type: Option<StreamWrapper> = None;
+    let mut depth_wrap: Option<SonicDepth> = None;
+
+    let mut data_type = String::new();
+    let mut topic_ticker = Ticker::default();
+
+    let iter: sonic_rs::ObjectJsonIter = unsafe { to_object_iter_unchecked(slice) };
+
+    for elem in iter {
+        let (k, v) = elem.map_err(|e| StreamError::ParseError(e.to_string()))?;
+        
+        if k == "topic" {
+            if let Some(val) = v.as_str() {
+                let mut is_ticker = None;
+
+                if let Some(ticker) = ticker {
+                    is_ticker = Some(ticker);
+                }
+
+                match StreamName::from_topic(val, is_ticker, market_type) {
+                    StreamName::Depth(ticker) => {
+                        stream_type = Some(StreamWrapper::Depth);
+
+                        topic_ticker = ticker;
+                    }
+                    StreamName::Trade(ticker) => {
+                        stream_type = Some(StreamWrapper::Trade);
+
+                        topic_ticker = ticker;
+                    }
+                    StreamName::Kline(ticker) => {
+                        stream_type = Some(StreamWrapper::Kline);
+
+                        topic_ticker = ticker;
+                    }
+                    _ => {
+                        log::error!("Unknown stream name");
+                    }
+                }
+            }
+        } else if k == "type" {
+            v.as_str().unwrap().clone_into(&mut data_type);
+        } else if k == "data" {
+            match stream_type {
+                Some(StreamWrapper::Trade) => {
+                    let trade_wrap: Vec<SonicTrade> = sonic_rs::from_str(&v.as_raw_faststr())
+                        .map_err(|e| StreamError::ParseError(e.to_string()))?;
+
+                    return Ok(StreamData::Trade(trade_wrap));
+                }
+                Some(StreamWrapper::Depth) => {
+                    if depth_wrap.is_none() {
+                        depth_wrap = Some(SonicDepth {
+                            update_id: 0,
+                            bids: Vec::new(),
+                            asks: Vec::new(),
+                        });
+                    }
+                    depth_wrap = Some(
+                        sonic_rs::from_str(&v.as_raw_faststr())
+                            .map_err(|e| StreamError::ParseError(e.to_string()))?,
+                    );
+                }
+                Some(StreamWrapper::Kline) => {
+                    let kline_wrap: Vec<SonicKline> = sonic_rs::from_str(&v.as_raw_faststr())
+                        .map_err(|e| StreamError::ParseError(e.to_string()))?;
+
+                    return Ok(StreamData::Kline(topic_ticker, kline_wrap));
+                }
+                _ => {
+                    log::error!("Unknown stream type");
+                }
+            }
+        } else if k == "cts" {
+            if let Some(dw) = depth_wrap {
+                let time: u64 = v
+                    .as_u64()
+                    .ok_or_else(|| StreamError::ParseError("Failed to parse u64".to_string()))?;
+
+                return Ok(StreamData::Depth(dw, data_type.to_string(), time as i64));
+            }
+        }
+    }
+
+    Err(StreamError::UnknownError("Unknown data".to_string()))
+}
+
+async fn connect(domain: &str, market_type: MarketType) -> Result<FragmentCollector<TokioIo<Upgraded>>, StreamError> {
+    let tcp_stream = setup_tcp_connection(domain).await?;
+    let tls_stream = setup_tls_connection(domain, tcp_stream).await?;
+    let url = format!(
+        "wss://stream.bybit.com/v5/public/{}",
+        match market_type {
+            MarketType::Spot => "spot",
+            MarketType::LinearPerps => "linear",
+        }
+    );
+    setup_websocket_connection(domain, tls_stream, &url).await
+}
+
+fn str_f32_parse(s: &str) -> f32 {
+    s.parse::<f32>().unwrap_or_else(|e| {
+        log::error!("Failed to parse float: {}, error: {}", s, e);
+        0.0
+    })
+}
+
+fn string_to_timeframe(interval: &str) -> Option<Timeframe> {
+    Timeframe::ALL
+        .iter()
+        .find(|&tf| tf.to_minutes().to_string() == interval)
+        .copied()
+}
+
+pub fn connect_market_stream(ticker: Ticker) -> impl Stream<Item = Event> {
+    stream::channel(100, move |mut output| async move {
+        let mut state: State = State::Disconnected;
+
+        let mut trades_buffer: Vec<Trade> = Vec::new();
+
+        let (symbol_str, market_type) = ticker.get_string();
+
+        let stream_1 = format!("publicTrade.{symbol_str}");
+        let stream_2 = format!(
+            "orderbook.{}.{}",
+            match market_type {
+                MarketType::Spot => "200",
+                MarketType::LinearPerps => "500",
+            },
+            symbol_str,
+        );
+
+        let mut orderbook: LocalDepthCache = LocalDepthCache::new();
+
+        loop {
+            match &mut state {
+                State::Disconnected => {
+                    let domain: &str = "stream.bybit.com";
+
+                    if let Ok(mut websocket) = connect(domain, market_type).await {
+                        let subscribe_message: String = serde_json::json!({
+                            "op": "subscribe",
+                            "args": [stream_1, stream_2]
+                        })
+                        .to_string();
+
+                        if let Err(e) = websocket
+                            .write_frame(Frame::text(fastwebsockets::Payload::Borrowed(
+                                subscribe_message.as_bytes(),
+                            )))
+                            .await
+                        {
+                            let _ = output
+                                .send(Event::Disconnected(format!("Failed subscribing: {e}")))
+                                .await;
+
+                            continue;
+                        }
+
+                        state = State::Connected(websocket);
+                        let _ = output.send(Event::Connected(Connection)).await;
+                    } else {
+                        tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
+
+                        let _ = output
+                            .send(Event::Disconnected(
+                                "Failed to connect to websocket".to_string(),
+                            ))
+                            .await;
+                    }
+                }
+                State::Connected(websocket) => match websocket.read_frame().await {
+                    Ok(msg) => match msg.opcode {
+                        OpCode::Text => {
+                            if let Ok(data) = feed_de(&msg.payload[..], Some(ticker), market_type) {
+                                match data {
+                                    StreamData::Trade(de_trade_vec) => {
+                                        for de_trade in &de_trade_vec {
+                                            let trade = Trade {
+                                                time: de_trade.time as i64,
+                                                is_sell: de_trade.is_sell == "Sell",
+                                                price: str_f32_parse(&de_trade.price),
+                                                qty: str_f32_parse(&de_trade.qty),
+                                            };
+
+                                            trades_buffer.push(trade);
+                                        }
+                                    }
+                                    StreamData::Depth(de_depth, data_type, time) => {
+                                        let depth_update = VecLocalDepthCache {
+                                            last_update_id: de_depth.update_id as i64,
+                                            time,
+                                            bids: de_depth
+                                                .bids
+                                                .iter()
+                                                .map(|x| Order {
+                                                    price: str_f32_parse(&x.price),
+                                                    qty: str_f32_parse(&x.qty),
+                                                })
+                                                .collect(),
+                                            asks: de_depth
+                                                .asks
+                                                .iter()
+                                                .map(|x| Order {
+                                                    price: str_f32_parse(&x.price),
+                                                    qty: str_f32_parse(&x.qty),
+                                                })
+                                                .collect(),
+                                        };
+
+                                        if (data_type == "snapshot")
+                                            || (depth_update.last_update_id == 1)
+                                        {
+                                            orderbook.fetched(&depth_update);
+                                        } else if data_type == "delta" {
+                                            orderbook.update_depth_cache(&depth_update);
+
+                                            let _ = output
+                                                .send(Event::DepthReceived(
+                                                    ticker,
+                                                    time,
+                                                    orderbook.get_depth(),
+                                                    std::mem::take(&mut trades_buffer),
+                                                ))
+                                                .await;
+                                        }
+                                    }
+                                    _ => {
+                                        log::warn!("Unknown data: {:?}", &data);
+                                    }
+                                }
+                            }
+                        }
+                        OpCode::Close => {
+                            state = State::Disconnected;
+                            let _ = output
+                                .send(Event::Disconnected("Connection closed".to_string()))
+                                .await;
+                        }
+                        _ => {}
+                    },
+                    Err(e) => {
+                        state = State::Disconnected;
+                        let _ = output
+                            .send(Event::Disconnected(
+                                "Error reading frame: ".to_string() + &e.to_string(),
+                            ))
+                            .await;
+                    }
+                },
+            }
+        }
+    })
+}
+
+pub fn connect_kline_stream(
+    streams: Vec<(Ticker, Timeframe)>, 
+    market_type: MarketType
+) -> impl Stream<Item = Event> {
+    stream::channel(100, move |mut output| async move {
+        let mut state = State::Disconnected;
+
+        let stream_str = streams
+            .iter()
+            .map(|(ticker, timeframe)| {
+                let timeframe_str = timeframe.to_minutes().to_string();
+                format!("kline.{timeframe_str}.{}", ticker.get_string().0)
+            })
+            .collect::<Vec<String>>();
+
+        loop {
+            match &mut state {
+                State::Disconnected => {
+                    let domain = "stream.bybit.com";
+
+                    if let Ok(mut websocket) = connect(domain, market_type).await {
+                        let subscribe_message = serde_json::json!({
+                            "op": "subscribe",
+                            "args": stream_str
+                        })
+                        .to_string();
+
+                        if let Err(e) = websocket
+                            .write_frame(Frame::text(fastwebsockets::Payload::Borrowed(
+                                subscribe_message.as_bytes(),
+                            )))
+                            .await
+                        {
+                            let _ = output
+                                .send(Event::Disconnected(format!("Failed subscribing: {e}")))
+                                .await;
+
+                            continue;
+                        }
+
+                        state = State::Connected(websocket);
+                        let _ = output.send(Event::Connected(Connection)).await;
+                    } else {
+                        tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
+
+                        let _ = output
+                            .send(Event::Disconnected(
+                                "Failed to connect to websocket".to_string(),
+                            ))
+                            .await;
+                    }
+                }
+                State::Connected(websocket) => match websocket.read_frame().await {
+                    Ok(msg) => {
+                        if msg.opcode == OpCode::Text {
+                            if let Ok(StreamData::Kline(ticker, de_kline_vec)) =
+                                feed_de(&msg.payload[..], None, market_type)
+                            {
+                                for de_kline in &de_kline_vec {
+                                    let kline = Kline {
+                                        time: de_kline.time,
+                                        open: str_f32_parse(&de_kline.open),
+                                        high: str_f32_parse(&de_kline.high),
+                                        low: str_f32_parse(&de_kline.low),
+                                        close: str_f32_parse(&de_kline.close),
+                                        volume: (-1.0, str_f32_parse(&de_kline.volume)),
+                                    };
+
+                                    if let Some(timeframe) = string_to_timeframe(&de_kline.interval)
+                                    {
+                                        let _ = output
+                                            .send(Event::KlineReceived(ticker, kline, timeframe))
+                                            .await;
+                                    } else {
+                                        log::error!(
+                                            "Failed to find timeframe: {}, {:?}",
+                                            &de_kline.interval,
+                                            streams
+                                        );
+                                    }
+                                }
+                            }
+                        }
+                    }
+                    Err(e) => {
+                        state = State::Disconnected;
+                        let _ = output
+                            .send(Event::Disconnected(
+                                "Error reading frame: ".to_string() + &e.to_string(),
+                            ))
+                            .await;
+                    }
+                },
+            }
+        }
+    })
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct DeOpenInterest {
+    #[serde(rename = "openInterest", deserialize_with = "deserialize_string_to_f32")]
+    pub value: f32,
+    #[serde(deserialize_with = "deserialize_string_to_i64")]
+    pub timestamp: i64,
+}
+
+pub async fn fetch_historical_oi(
+    ticker: Ticker, 
+    range: Option<(i64, i64)>,
+    period: Timeframe,
+) -> Result<Vec<OpenInterest>, StreamError> {
+    let ticker_str = ticker.get_string().0.to_uppercase();
+    let period_str = match period {
+        Timeframe::M5 => "5min",
+        Timeframe::M15 => "15min",
+        Timeframe::M30 => "30min",
+        Timeframe::H1 => "1h",
+        Timeframe::H4 => "4h",
+        _ => {
+            let err_msg = format!("Unsupported timeframe for open interest: {}", period);
+            log::error!("{}", err_msg);
+            return Err(StreamError::UnknownError(err_msg));
+        }
+    };
+
+    let mut url = format!(
+        "https://api.bybit.com/v5/market/open-interest?category=linear&symbol={}&intervalTime={}",
+        ticker_str, period_str,
+    );
+
+    if let Some((start, end)) = range {
+        let interval_ms = period.to_milliseconds() as i64;
+        let num_intervals = ((end - start) / interval_ms).min(200);
+
+        url.push_str(&format!("&startTime={start}&endTime={end}&limit={num_intervals}"));
+    } else {
+        url.push_str(&format!("&limit={}", 200));
+    }
+
+    let response = reqwest::get(&url)
+        .await
+        .map_err(|e| {
+            log::error!("Failed to fetch from {}: {}", url, e);
+            StreamError::FetchError(e)
+        })?;
+        
+    let text = response.text()
+        .await
+        .map_err(|e| {
+            log::error!("Failed to get response text from {}: {}", url, e);
+            StreamError::FetchError(e)
+        })?;
+
+    let content: Value = sonic_rs::from_str(&text)
+        .map_err(|e| {
+            log::error!("Failed to parse JSON from {}: {}\nResponse: {}", url, e, text);
+            StreamError::ParseError(e.to_string())
+        })?;
+
+    let result_list = content["result"]["list"]
+        .as_array()
+        .ok_or_else(|| {
+            log::error!("Result list is not an array in response: {}", text);
+            StreamError::ParseError("Result list is not an array".to_string())
+        })?;
+    
+    let bybit_oi: Vec<DeOpenInterest> = serde_json::from_value(json!(result_list))
+        .map_err(|e| {
+            log::error!("Failed to parse open interest array: {}\nResponse: {}", e, text);
+            StreamError::ParseError(format!("Failed to parse open interest: {e}"))
+        })?;
+
+    let open_interest = bybit_oi
+        .into_iter()
+        .map(|x| OpenInterest {
+            time: x.timestamp,
+            value: x.value,
+        })
+        .collect();
+
+    Ok(open_interest)
+}
+
+#[allow(dead_code)]
+#[derive(Deserialize, Debug)]
+struct ApiResponse {
+    #[serde(rename = "retCode")]
+    ret_code: u32,
+    #[serde(rename = "retMsg")]
+    ret_msg: String,
+    result: ApiResult,
+}
+
+#[allow(dead_code)]
+#[derive(Deserialize, Debug)]
+struct ApiResult {
+    symbol: String,
+    category: String,
+    list: Vec<Vec<Value>>,
+}
+
+pub async fn fetch_klines(
+    ticker: Ticker,
+    timeframe: Timeframe,
+    range: Option<(i64, i64)>,
+) -> Result<Vec<Kline>, StreamError> {
+    let (symbol_str, market_type) = &ticker.get_string();
+    let timeframe_str = timeframe.to_minutes().to_string();
+
+    fn parse_kline_field<T: std::str::FromStr>(field: Option<&str>) -> Result<T, StreamError> {
+        field
+            .ok_or_else(|| StreamError::ParseError("Failed to parse kline".to_string()))
+            .and_then(|s| {
+                s.parse::<T>()
+                    .map_err(|_| StreamError::ParseError("Failed to parse kline".to_string()))
+            })
+    }
+
+    let market = match market_type {
+        MarketType::Spot => "spot",
+        MarketType::LinearPerps => "linear",
+    };
+
+    let mut url = format!(
+        "https://api.bybit.com/v5/market/kline?category={}&symbol={}&interval={}",
+        market, symbol_str.to_uppercase(), timeframe_str
+    );
+
+    if let Some((start, end)) = range {
+        let interval_ms = timeframe.to_milliseconds() as i64;
+        let num_intervals = ((end - start) / interval_ms).min(1000);
+
+        url.push_str(&format!("&start={start}&end={end}&limit={num_intervals}"));
+    } else {
+        url.push_str(&format!("&limit={}", 200));
+    }
+
+    let response: reqwest::Response = reqwest::get(&url).await.map_err(StreamError::FetchError)?;
+    let text = response.text().await.map_err(StreamError::FetchError)?;
+
+    let api_response: ApiResponse =
+        sonic_rs::from_str(&text).map_err(|e| StreamError::ParseError(e.to_string()))?;
+
+    let klines: Result<Vec<Kline>, StreamError> = api_response
+        .result
+        .list
+        .iter()
+        .map(|kline| {
+            let time = parse_kline_field::<u64>(kline[0].as_str())?;
+            let open = parse_kline_field::<f32>(kline[1].as_str())?;
+            let high = parse_kline_field::<f32>(kline[2].as_str())?;
+            let low = parse_kline_field::<f32>(kline[3].as_str())?;
+            let close = parse_kline_field::<f32>(kline[4].as_str())?;
+            let volume = parse_kline_field::<f32>(kline[5].as_str())?;
+
+            Ok(Kline {
+                time,
+                open,
+                high,
+                low,
+                close,
+                volume: (-1.0, volume),
+            })
+        })
+        .collect();
+
+    klines
+}
+
+pub async fn fetch_ticksize(market_type: MarketType) -> Result<HashMap<Ticker, Option<TickerInfo>>, StreamError> {
+    let market = match market_type {
+        MarketType::Spot => "spot",
+        MarketType::LinearPerps => "linear",
+    };
+
+    let url = format!("https://api.bybit.com/v5/market/instruments-info?category={market}");
+    let response = reqwest::get(&url).await.map_err(StreamError::FetchError)?;
+    let text = response.text().await.map_err(StreamError::FetchError)?;
+
+    let exchange_info: Value =
+        sonic_rs::from_str(&text).map_err(|e| StreamError::ParseError(e.to_string()))?;
+
+    let result_list: &Vec<Value> = exchange_info["result"]["list"]
+        .as_array()
+        .ok_or_else(|| StreamError::ParseError("Result list is not an array".to_string()))?;
+
+    let mut ticker_info_map = HashMap::new();
+
+    let re = Regex::new(r"^[a-zA-Z0-9]+$").unwrap();
+
+    for item in result_list {
+        let symbol = item["symbol"]
+            .as_str()
+            .ok_or_else(|| StreamError::ParseError("Symbol not found".to_string()))?;
+
+        if !re.is_match(symbol) {
+            continue;
+        }
+
+        let price_filter = item["priceFilter"]
+            .as_object()
+            .ok_or_else(|| StreamError::ParseError("Price filter not found".to_string()))?;
+
+        let tick_size = price_filter["tickSize"]
+            .as_str()
+            .ok_or_else(|| StreamError::ParseError("Tick size not found".to_string()))?
+            .parse::<f32>()
+            .map_err(|_| StreamError::ParseError("Failed to parse tick size".to_string()))?;
+
+        ticker_info_map.insert(Ticker::new(symbol, market_type), Some(TickerInfo { tick_size }));
+    }
+
+    Ok(ticker_info_map)
+}
+
+pub async fn fetch_ticker_prices(market_type: MarketType) -> Result<HashMap<Ticker, TickerStats>, StreamError> {
+    let market = match market_type {
+        MarketType::Spot => "spot",
+        MarketType::LinearPerps => "linear",
+    };
+
+    let url = format!("https://api.bybit.com/v5/market/tickers?category={market}");
+    let response = reqwest::get(&url).await.map_err(StreamError::FetchError)?;
+    let text = response.text().await.map_err(StreamError::FetchError)?;
+
+    let exchange_info: Value =
+        sonic_rs::from_str(&text).map_err(|e| StreamError::ParseError(e.to_string()))?;
+
+    let result_list: &Vec<Value> = exchange_info["result"]["list"]
+        .as_array()
+        .ok_or_else(|| StreamError::ParseError("Result list is not an array".to_string()))?;
+
+    let mut ticker_prices_map = HashMap::new();
+
+    let re = Regex::new(r"^[a-zA-Z0-9]+$").unwrap();
+
+    for item in result_list {
+        let symbol = item["symbol"]
+            .as_str()
+            .ok_or_else(|| StreamError::ParseError("Symbol not found".to_string()))?;
+
+        if !re.is_match(symbol) {
+            continue;
+        }
+
+        let mark_price = item["lastPrice"]
+            .as_str()
+            .ok_or_else(|| StreamError::ParseError("Mark price not found".to_string()))?
+            .parse::<f32>()
+            .map_err(|_| StreamError::ParseError("Failed to parse mark price".to_string()))?;
+
+        let daily_price_chg = item["price24hPcnt"]
+            .as_str()
+            .ok_or_else(|| StreamError::ParseError("Daily price change not found".to_string()))?
+            .parse::<f32>()
+            .map_err(|_| {
+                StreamError::ParseError("Failed to parse daily price change".to_string())
+            })?;
+
+        let daily_volume = item["volume24h"]
+            .as_str()
+            .ok_or_else(|| StreamError::ParseError("Daily volume not found".to_string()))?
+            .parse::<f32>()
+            .map_err(|_| StreamError::ParseError("Failed to parse daily volume".to_string()))?;
+
+        let quote_volume = daily_volume * mark_price;
+
+        if quote_volume < 4_000_000.0 {
+            continue;
+        }
+
+        let ticker_stats = TickerStats {
+            mark_price,
+            daily_price_chg: daily_price_chg * 100.0,
+            daily_volume: quote_volume,
+        };
+
+        ticker_prices_map.insert(Ticker::new(symbol, market_type), ticker_stats);
+    }
+
+    Ok(ticker_prices_map)
+}

+ 0 - 648
src/data_providers/bybit/market_data.rs

@@ -1,648 +0,0 @@
-use iced::{stream, futures};
-use futures::sink::SinkExt;
-use futures::stream::Stream;
-
-use serde_json::Value;
-use bytes::Bytes;
-
-use sonic_rs::{JsonValueTrait, Deserialize, Serialize}; 
-use sonic_rs::to_object_iter_unchecked;
-
-use anyhow::anyhow;
-use anyhow::{Context, Result};
-
-use fastwebsockets::{Frame, FragmentCollector, OpCode};
-use http_body_util::Empty;
-use hyper::header::{CONNECTION, UPGRADE};
-use hyper::upgrade::Upgraded;
-use hyper::Request;
-use hyper_util::rt::TokioIo;
-use tokio::net::TcpStream;
-use tokio_rustls::rustls::{ClientConfig, OwnedTrustAnchor};
-use tokio_rustls::TlsConnector;
-
-use crate::data_providers::{Depth, FeedLatency, Kline, LocalDepthCache, Order, Trade};
-use crate::{Ticker, Timeframe};
-
-#[allow(clippy::large_enum_variant)]
-enum State {
-    Disconnected,
-    Connected(
-        FragmentCollector<TokioIo<Upgraded>>
-    ),
-}
-
-#[derive(Debug, Clone)]
-pub enum Event {
-    Connected(Connection),
-    Disconnected(String),
-    DepthReceived(Ticker, FeedLatency, i64, Depth, Vec<Trade>),
-    KlineReceived(Ticker, Kline, Timeframe),
-}
-
-#[derive(Debug, Clone)]
-pub struct Connection;
-
-#[derive(Serialize, Deserialize, Debug)]
-struct SonicDepth {
-	#[serde(rename = "u")]
-	pub update_id: u64,
-	#[serde(rename = "b")]
-	pub bids: Vec<BidAsk>,
-	#[serde(rename = "a")]
-	pub asks: Vec<BidAsk>,
-}
-
-#[derive(Serialize, Deserialize, Debug)]
-struct BidAsk {
-	#[serde(rename = "0")]
-	pub price: String,
-	#[serde(rename = "1")]
-	pub qty: String,
-}
-
-#[derive(Serialize, Deserialize, Debug)]
-struct SonicTrade {
-	#[serde(rename = "T")]
-	pub time: u64,
-	#[serde(rename = "p")]
-	pub price: String,
-	#[serde(rename = "v")]
-	pub qty: String,
-	#[serde(rename = "S")]
-	pub is_sell: String,
-}
-
-#[derive(Deserialize, Debug, Clone)]
-pub struct SonicKline {
-    #[serde(rename = "start")]
-    pub time: u64,
-    #[serde(rename = "open")]
-    pub open: String,
-    #[serde(rename = "high")]
-    pub high: String,
-    #[serde(rename = "low")]
-    pub low: String,
-    #[serde(rename = "close")]
-    pub close: String,
-    #[serde(rename = "volume")]
-    pub volume: String,
-    #[serde(rename = "interval")]
-    pub interval: String,
-}
-
-#[derive(Debug)]
-enum StreamData {
-	Trade(Vec<SonicTrade>),
-	Depth(SonicDepth, String, i64),
-    Kline(Ticker, Vec<SonicKline>),
-}
-
-#[derive(Debug)]
-enum StreamName {
-    Depth(Ticker),
-    Trade(Ticker),
-    Kline(Ticker),
-    Unknown,
-}
-impl StreamName {
-    fn from_topic(topic: &str) -> Self {
-        topic.split('.').collect::<Vec<&str>>().as_slice().split_first().map(|(first, rest)| {
-            match *first {
-                "publicTrade" => {
-                    match rest {
-                        [ticker] if *ticker == "BTCUSDT" => StreamName::Trade(Ticker::BTCUSDT),
-                        [ticker] if *ticker == "ETHUSDT" => StreamName::Trade(Ticker::ETHUSDT),
-                        [ticker] if *ticker == "SOLUSDT" => StreamName::Trade(Ticker::SOLUSDT),
-                        [ticker] if *ticker == "LTCUSDT" => StreamName::Trade(Ticker::LTCUSDT),
-                        _ => StreamName::Unknown,
-                    }
-                },
-                "orderbook" => {
-                    match rest {
-                        [_, ticker] if *ticker == "BTCUSDT" => StreamName::Depth(Ticker::BTCUSDT),
-                        [_, ticker] if *ticker == "ETHUSDT" => StreamName::Depth(Ticker::ETHUSDT),
-                        [_, ticker] if *ticker == "SOLUSDT" => StreamName::Depth(Ticker::SOLUSDT),
-                        [_, ticker] if *ticker == "LTCUSDT" => StreamName::Depth(Ticker::LTCUSDT),
-                        _ => StreamName::Unknown,
-                    }
-                },
-                "kline" => {
-                    match rest {
-                        [_, ticker] if *ticker == "BTCUSDT" => StreamName::Kline(Ticker::BTCUSDT),
-                        [_, ticker] if *ticker == "ETHUSDT" => StreamName::Kline(Ticker::ETHUSDT),
-                        [_, ticker] if *ticker == "SOLUSDT" => StreamName::Kline(Ticker::SOLUSDT),
-                        [_, ticker] if *ticker == "LTCUSDT" => StreamName::Kline(Ticker::LTCUSDT),
-                        _ => StreamName::Unknown,
-                    }
-                },
-                _ => StreamName::Unknown,
-            }
-        }).unwrap_or(StreamName::Unknown)
-    }
-}
-
-#[derive(Debug)]
-enum StreamWrapper {
-	Trade,
-	Depth,
-    Kline,
-}
-
-fn feed_de(bytes: &Bytes) -> Result<StreamData> {
-    let mut stream_type: Option<StreamWrapper> = None;
-
-    let mut depth_wrap: Option<SonicDepth> = None;
-
-    let mut data_type: String = String::new();
-
-    let iter: sonic_rs::ObjectJsonIter = unsafe { to_object_iter_unchecked(bytes) };
-
-    let mut topic_ticker = Ticker::BTCUSDT;
-
-    for elem in iter {
-        let (k, v) = elem.context("Error parsing stream")?;
-
-        if k == "topic" {
-            if let Some(val) = v.as_str() {
-                match StreamName::from_topic(val) {
-                    StreamName::Depth(ticker) => {
-                        stream_type = Some(StreamWrapper::Depth);
-
-                        topic_ticker = ticker;
-                    },
-                    StreamName::Trade(ticker) => {
-                        stream_type = Some(StreamWrapper::Trade);
-
-                        topic_ticker = ticker;
-                    },
-                    StreamName::Kline(ticker) => {
-                        stream_type = Some(StreamWrapper::Kline);
-
-                        topic_ticker = ticker;
-                    },
-                    _ => {
-                        log::error!("Unknown stream name");
-                    }
-                }
-            }
-        } else if k == "type" {
-            v.as_str().unwrap().clone_into(&mut data_type);
-        } else if k == "data" {
-            match stream_type {
-                Some(StreamWrapper::Trade) => {
-                    let trade_wrap: Vec<SonicTrade> = sonic_rs::from_str(&v.as_raw_faststr())
-                        .context("Error parsing trade")?;
-
-                    return Ok(StreamData::Trade(trade_wrap));
-                },
-                Some(StreamWrapper::Depth) => {
-                    if depth_wrap.is_none() {
-                        depth_wrap = Some(SonicDepth {
-                            update_id: 0,
-                            bids: Vec::new(),
-                            asks: Vec::new(),
-                        });
-                    }
-                    depth_wrap = Some(sonic_rs::from_str(&v.as_raw_faststr())
-                        .context("Error parsing depth")?);
-                },
-                Some(StreamWrapper::Kline) => {
-                    let kline_wrap: Vec<SonicKline> = sonic_rs::from_str(&v.as_raw_faststr())
-                        .context("Error parsing kline")?;
-
-                    return Ok(StreamData::Kline(topic_ticker, kline_wrap));
-                },
-                _ => {
-                    log::error!("Unknown stream type");
-                }
-            }
-        } else if k == "cts" {
-            if let Some(dw) = depth_wrap {
-                let time: u64 = v.as_u64().context("Error parsing time")?;
-                
-                return Ok(StreamData::Depth(dw, data_type.to_string(), time as i64));
-            }
-        }
-    }
-
-    Err(anyhow::anyhow!("Unknown data"))
-}
-
-fn tls_connector() -> Result<TlsConnector> {
-	let mut root_store = tokio_rustls::rustls::RootCertStore::empty();
-
-	root_store.add_trust_anchors(
-		webpki_roots::TLS_SERVER_ROOTS.0.iter().map(|ta| {
-			OwnedTrustAnchor::from_subject_spki_name_constraints(
-			ta.subject,
-			ta.spki,
-			ta.name_constraints,
-			)
-		}),
-	);
-
-	let config = ClientConfig::builder()
-		.with_safe_defaults()
-		.with_root_certificates(root_store)
-		.with_no_client_auth();
-
-	Ok(TlsConnector::from(std::sync::Arc::new(config)))
-}
-
-async fn connect(domain: &str) -> Result<FragmentCollector<TokioIo<Upgraded>>> {
-	let mut addr = String::from(domain);
-    addr.push_str(":443");
-
-	let tcp_stream: TcpStream = TcpStream::connect(&addr).await?;
-	let tls_connector: TlsConnector = tls_connector().unwrap();
-	let domain: tokio_rustls::rustls::ServerName =
-	tokio_rustls::rustls::ServerName::try_from(domain).map_err(|_| {
-		std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid dnsname")
-	})?;
-
-	let tls_stream: tokio_rustls::client::TlsStream<TcpStream> = tls_connector.connect(domain, tcp_stream).await?;
-
-    let url = "wss://stream.bybit.com/v5/public/linear".to_string();
-
-	let req: Request<Empty<Bytes>> = Request::builder()
-	.method("GET")
-	.uri(url)
-	.header("Host", &addr)
-	.header(UPGRADE, "websocket")
-	.header(CONNECTION, "upgrade")
-	.header(
-		"Sec-WebSocket-Key",
-		fastwebsockets::handshake::generate_key(),
-	)
-	.header("Sec-WebSocket-Version", "13")
-	.body(Empty::<Bytes>::new())?;
-
-	let (ws, _) = fastwebsockets::handshake::client(&SpawnExecutor, req, tls_stream).await?;
-	Ok(FragmentCollector::new(ws))
-}
-struct SpawnExecutor;
-
-impl<Fut> hyper::rt::Executor<Fut> for SpawnExecutor
-where
-  Fut: std::future::Future + Send + 'static,
-  Fut::Output: Send + 'static,
-{
-  fn execute(&self, fut: Fut) {
-	tokio::task::spawn(fut);
-  }
-}
-
-fn str_f32_parse(s: &str) -> f32 {
-    s.parse::<f32>().unwrap_or_else(|e| {
-        log::error!("Failed to parse float: {}, error: {}", s, e);
-        0.0
-    })
-}
-
-fn string_to_timeframe(interval: &str) -> Option<Timeframe> {
-    Timeframe::ALL.iter().find(|&tf| tf.to_string() == format!("{}m", interval)).copied()
-}
-
-pub fn connect_market_stream(ticker: Ticker) -> impl Stream<Item = Event> {
-    stream::channel (
-        100,
-        move |mut output| async move {
-            let mut state: State = State::Disconnected;  
-
-            let mut trades_buffer: Vec<Trade> = Vec::new();    
-
-            let selected_ticker = ticker;
-
-            let symbol_str = selected_ticker.get_string().to_uppercase();
-            
-            let stream_1 = format!("publicTrade.{symbol_str}");
-            let stream_2 = format!("orderbook.500.{symbol_str}");
-
-            let mut orderbook: LocalDepthCache = LocalDepthCache::new();
-
-            let mut trade_latencies: Vec<i64> = Vec::new();
-
-            loop {
-                match &mut state {
-                    State::Disconnected => {        
-                        let domain: &str = "stream.bybit.com";
-
-                        if let Ok(mut websocket) = connect(domain
-                        )
-                        .await {
-                            let subscribe_message: String = serde_json::json!({
-                                "op": "subscribe",
-                                "args": [stream_1, stream_2]
-                            }).to_string();
-    
-                            if let Err(e) = websocket.write_frame(Frame::text(fastwebsockets::Payload::Borrowed(subscribe_message.as_bytes()))).await {
-                                let _ = output.send(Event::Disconnected(
-                                    format!("Failed subscribing: {}", e)
-                                )).await;
-
-                                continue;
-                            }
-
-                            state = State::Connected(websocket);
-                            let _ = output.send(Event::Connected(Connection)).await; 
-                        } else {
-                            tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
-
-                            let _ = output.send(Event::Disconnected(
-                                "Failed to connect to websocket".to_string()
-                            )).await;
-                        }
-                    },
-                    State::Connected(websocket) => {
-                        let feed_latency: FeedLatency;
-
-                        match websocket.read_frame().await {
-                            Ok(msg) => match msg.opcode {
-                                OpCode::Text => {       
-                                    let json_bytes: Bytes = Bytes::from(msg.payload.to_vec());
-
-                                    if let Ok(data) = feed_de(&json_bytes) {
-                                        match data {
-                                            StreamData::Trade(de_trade_vec) => {
-                                                for de_trade in de_trade_vec.iter() {
-                                                    let trade = Trade {
-                                                        time: de_trade.time as i64,
-                                                        is_sell: de_trade.is_sell == "Sell",
-                                                        price: str_f32_parse(&de_trade.price),
-                                                        qty: str_f32_parse(&de_trade.qty),
-                                                    };
-
-                                                    trade_latencies.push(
-                                                        chrono::Utc::now().timestamp_millis() - trade.time
-                                                    );
-
-                                                    trades_buffer.push(trade);
-                                                }                                             
-                                            },
-                                            StreamData::Depth(de_depth, data_type, time) => {                                            
-                                                let depth_latency = chrono::Utc::now().timestamp_millis() - time;
-
-                                                let depth_update = LocalDepthCache {
-                                                    last_update_id: de_depth.update_id as i64,
-                                                    time,
-                                                    bids: de_depth.bids.iter().map(
-                                                        |x| Order { price: str_f32_parse(&x.price), qty: str_f32_parse(&x.qty) }
-                                                    ).collect(),
-                                                    asks: de_depth.asks.iter().map(
-                                                        |x| Order { price: str_f32_parse(&x.price), qty: str_f32_parse(&x.qty) }
-                                                    ).collect(),
-                                                };
-
-                                                if (data_type == "snapshot") || (depth_update.last_update_id == 1) {
-                                                    orderbook.fetched(depth_update);
-
-                                                } else if data_type == "delta" {
-                                                    orderbook.update_depth_cache(depth_update);
-
-                                                    let avg_trade_latency = if !trade_latencies.is_empty() {
-                                                        let avg = trade_latencies.iter().sum::<i64>() / trade_latencies.len() as i64;
-                                                        trade_latencies.clear();
-                                                        Some(avg)
-                                                    } else {
-                                                        None
-                                                    };
-                                                    feed_latency = FeedLatency {
-                                                        time,
-                                                        depth_latency,
-                                                        trade_latency: avg_trade_latency,
-                                                    };
-
-                                                    let _ = output.send(
-                                                        Event::DepthReceived(
-                                                            selected_ticker,
-                                                            feed_latency,
-                                                            time, 
-                                                            orderbook.get_depth(),
-                                                            std::mem::take(&mut trades_buffer)
-                                                        )
-                                                    ).await;
-                                                }
-                                            },
-                                            _ => {
-                                                log::warn!("Unknown data: {:?}", &data);
-                                            }
-                                        }
-                                    }
-                                }
-                                OpCode::Close => {
-                                    state = State::Disconnected;
-                                    let _ = output.send(
-                                        Event::Disconnected("Connection closed".to_string())
-                                    ).await;
-                                }
-                                _ => {}
-                            },
-                            Err(e) => {
-                                state = State::Disconnected;        
-                                let _ = output.send(
-                                    Event::Disconnected("Error reading frame: ".to_string() + &e.to_string())
-                                ).await;
-                            }
-                        }
-                    }
-                }
-            }
-        },
-    )
-}
- 
-pub fn connect_kline_stream(streams: Vec<(Ticker, Timeframe)>) -> impl Stream<Item = Event> {
-    stream::channel (
-        100,
-        move |mut output| async move {
-            let mut state = State::Disconnected;    
-
-            let stream_str = streams.iter().map(|(ticker, timeframe)| {
-                let symbol_str = ticker.get_string().to_uppercase();
-                let timeframe_str = match timeframe {
-                    Timeframe::M1 => "1",
-                    Timeframe::M3 => "3",
-                    Timeframe::M5 => "5",
-                    Timeframe::M15 => "15",
-                    Timeframe::M30 => "30",
-                };
-                format!("kline.{timeframe_str}.{symbol_str}")
-            }).collect::<Vec<String>>();
- 
-            loop {
-                match &mut state {
-                    State::Disconnected => {
-                        let domain = "stream.bybit.com";
-                        
-                        if let Ok(mut websocket) = connect(
-                            domain,
-                        )
-                        .await {
-                            let subscribe_message = serde_json::json!({
-                                "op": "subscribe",
-                                "args": stream_str 
-                            }).to_string();
-    
-                            if let Err(e) = websocket.write_frame(Frame::text(fastwebsockets::Payload::Borrowed(subscribe_message.as_bytes()))).await {
-                                let _ = output.send(Event::Disconnected
-                                    (format!("Failed subscribing: {}", e))
-                                ).await;
-
-                                continue;
-                            }
-
-                            state = State::Connected(websocket);
-                            let _ = output.send(Event::Connected(Connection)).await;
-                        } else {
-                            tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
-
-                            let _ = output.send(Event::Disconnected(
-                                "Failed to connect to websocket".to_string()
-                            )).await;
-                        }
-                    }
-                    State::Connected(websocket) => {
-                        match websocket.read_frame().await {
-                            Ok(msg) => match msg.opcode {
-                                OpCode::Text => {                    
-                                    let json_bytes: Bytes = Bytes::from(msg.payload.to_vec());
-                    
-                                    if let Ok(StreamData::Kline(ticker, de_kline_vec)) = feed_de(&json_bytes) {
-                                        for de_kline in de_kline_vec.iter() {
-                                            let kline = Kline {
-                                                time: de_kline.time,
-                                                open: str_f32_parse(&de_kline.open),
-                                                high: str_f32_parse(&de_kline.high),
-                                                low: str_f32_parse(&de_kline.low),
-                                                close: str_f32_parse(&de_kline.close),
-                                                volume: (-1.0, str_f32_parse(&de_kline.volume)),
-                                            };
-
-                                            if let Some(timeframe) = string_to_timeframe(&de_kline.interval) {
-                                                let _ = output.send(Event::KlineReceived(ticker, kline, timeframe)).await;
-                                            } else {
-                                                log::error!("Failed to find timeframe: {}, {:?}", &de_kline.interval, streams);
-                                            }
-                                        }
-                                         
-                                    } else {
-                                        log::error!("\nUnknown data: {:?}", &json_bytes);
-                                    }
-                                }
-                                _ => {}
-                            },
-                            Err(e) => {   
-                                state = State::Disconnected;             
-                                let _ = output.send(
-                                    Event::Disconnected("Error reading frame: ".to_string() + &e.to_string())
-                                ).await;
-                            }
-                        }
-                    }
-                }
-            }
-        },
-    )
-}
-
-#[derive(Deserialize, Debug)]
-struct ApiResponse {
-    #[serde(rename = "retCode")]
-    ret_code: u32,
-    #[serde(rename = "retMsg")]
-    ret_msg: String,
-    result: ApiResult,
-}
-
-#[derive(Deserialize, Debug)]
-struct ApiResult {
-    symbol: String,
-    category: String,
-    list: Vec<Vec<Value>>,
-}
-
-pub async fn fetch_klines(ticker: Ticker, timeframe: Timeframe) -> Result<Vec<Kline>> {
-    let symbol_str = ticker.get_string().to_uppercase();
-    let timeframe_str = match timeframe {
-        Timeframe::M1 => "1",
-        Timeframe::M3 => "3",
-        Timeframe::M5 => "5",
-        Timeframe::M15 => "15",
-        Timeframe::M30 => "30",
-    };
-
-    let url: String = format!("https://api.bybit.com/v5/market/kline?category=linear&symbol={symbol_str}&interval={timeframe_str}&limit=720");
-
-    let response: reqwest::Response = reqwest::get(&url).await
-        .context("Failed to send request")?;
-    let text: String = response.text().await
-        .context("Failed to read response text")?;
-
-    let api_response: ApiResponse = sonic_rs::from_str(&text)
-        .context("Failed to parse JSON")?;
-    
-    let klines: Result<Vec<Kline>, anyhow::Error> = api_response.result.list.iter().map(|kline| {
-        let time = kline[0].as_str().ok_or_else(|| anyhow!("Missing time value"))
-            .and_then(|s| s.parse::<u64>()
-            .context("Failed to parse time as u64"));
-        let open = kline[1].as_str().ok_or_else(|| anyhow!("Missing open value"))
-            .and_then(|s| s.parse::<f32>()
-            .context("Failed to parse open as f32"));
-        let high = kline[2].as_str().ok_or_else(|| anyhow!("Missing high value"))
-            .and_then(|s| s.parse::<f32>()
-            .context("Failed to parse high as f32"));
-        let low = kline[3].as_str().ok_or_else(|| anyhow!("Missing low value"))
-            .and_then(|s| s.parse::<f32>()
-            .context("Failed to parse low as f32"));
-        let close = kline[4].as_str().ok_or_else(|| anyhow!("Missing close value"))
-            .and_then(|s| s.parse::<f32>()
-            .context("Failed to parse close as f32"));
-        let volume = kline[5].as_str().ok_or_else(|| anyhow!("Missing volume value"))
-            .and_then(|s| s.parse::<f32>()
-            .context("Failed to parse volume as f32"));
-    
-        Ok(Kline {
-            time: time?,
-            open: open?,
-            high: high?,
-            low: low?,
-            close: close?,
-            volume: (-1.0, volume?),
-        })
-    }).collect();
-
-    klines
-}
-
-pub async fn fetch_ticksize(ticker: Ticker) -> Result<f32> {
-    let symbol_str = ticker.get_string().to_uppercase();
-
-    let url = format!("https://api.bybit.com/v5/market/instruments-info?category=linear&symbol={}", symbol_str);
-
-    let response: reqwest::Response = reqwest::get(&url).await
-        .context("Failed to send request")?;
-    let text: String = response.text().await
-        .context("Failed to read response text")?;
-
-    let exchange_info: Value = sonic_rs::from_str(&text)
-        .context("Failed to parse JSON")?;
-
-    let result_list: &Vec<Value> = exchange_info["result"]["list"].as_array()
-        .context("Result list is not an array")?;
-
-    for item in result_list {
-        if item["symbol"] == symbol_str {
-            let price_filter: &serde_json::Map<String, Value> = item["priceFilter"].as_object()
-                .context("Price filter not found")?;
-
-            let tick_size_str: &str = price_filter.get("tickSize").context("Tick size not found")?.as_str()
-                .context("Tick size is not a string")?;
-
-            return tick_size_str.parse::<f32>()
-                .context("Failed to parse tick size");
-        }
-    }
-
-    anyhow::bail!("Tick size not found for symbol {}", symbol_str)
-}

+ 93 - 0
src/data_providers/fetcher.rs

@@ -0,0 +1,93 @@
+use uuid::Uuid;
+use std::collections::HashMap;
+
+#[derive(thiserror::Error, Debug, Clone)]
+pub enum ReqError {
+    #[error("Request is already completed")]
+    Completed,
+    #[error("Request is already failed: {0}")]
+    Failed(String),
+    #[error("Request overlaps with an existing request")]
+    Overlaps,
+}
+
+#[derive(PartialEq, Debug)]
+enum RequestStatus {
+    Pending,
+    Completed(u64),
+    Failed(String),
+}
+
+pub struct RequestHandler {
+    requests: HashMap<Uuid, FetchRequest>,
+}
+
+impl RequestHandler {
+    pub fn new() -> Self {
+        RequestHandler {
+            requests: HashMap::new(),
+        }
+    }
+
+    pub fn add_request(&mut self, fetch: FetchRange) -> Result<Uuid, ReqError> {
+        let request = FetchRequest::new(fetch);
+        let id = Uuid::new_v4();
+
+        if let Some(r) = self.requests.values().find(|r| r.ends_same_with(&request)) {
+            return match &r.status {
+                RequestStatus::Failed(error_msg) => Err(ReqError::Failed(error_msg.clone())),
+                RequestStatus::Completed(_) => Err(ReqError::Completed),
+                RequestStatus::Pending => Err(ReqError::Overlaps),
+            };
+        }
+
+        self.requests.insert(id, request);
+
+        Ok(id)
+    }
+
+    pub fn mark_completed(&mut self, id: Uuid) {
+        if let Some(request) = self.requests.get_mut(&id) {
+            let timestamp = chrono::Utc::now().timestamp_millis() as u64;
+            request.status = RequestStatus::Completed(timestamp);
+        } else {
+            log::warn!("Request not found: {:?}", id);
+        }
+    }
+
+    pub fn mark_failed(&mut self, id: Uuid, error: String) {
+        if let Some(request) = self.requests.get_mut(&id) {
+            request.status = RequestStatus::Failed(error);
+        } else {
+            log::warn!("Request not found: {:?}", id);
+        }
+    }
+}
+
+#[derive(PartialEq, Debug, Clone, Copy)]
+pub enum FetchRange {
+    Kline(i64, i64),
+    OpenInterest(i64, i64),
+}
+
+#[derive(PartialEq, Debug)]
+struct FetchRequest {
+    fetch_type: FetchRange,
+    status: RequestStatus,
+}
+
+impl FetchRequest {
+    fn new(fetch_type: FetchRange) -> Self {
+        FetchRequest {
+            fetch_type,
+            status: RequestStatus::Pending,
+        }
+    }
+
+    fn ends_same_with(&self, other: &FetchRequest) -> bool {
+        match (&self.fetch_type, &other.fetch_type) {
+            (FetchRange::Kline(_, e1), FetchRange::Kline(_, e2)) => e1 == e2,
+            _ => false,
+        }
+    }
+}

+ 135 - 45
src/fonts/config.json

@@ -7,61 +7,117 @@
   "ascent": 850,
   "ascent": 850,
   "glyphs": [
   "glyphs": [
     {
     {
-      "uid": "c1f1975c885aa9f3dad7810c53b82074",
+      "uid": "60612554c452aa7940ce73860b9a4b49",
       "css": "lock",
       "css": "lock",
-      "code": 59393,
-      "src": "fontawesome"
+      "code": 59392,
+      "src": "custom_icons",
+      "selected": true,
+      "svg": {
+        "path": "M179 429H464V321Q464 262 422 220T321 179 220 220 179 321V429ZM643 482V804Q643 826 627 841T589 857H54Q31 857 16 841T0 804V482Q0 460 16 444T54 429H71V321Q71 219 145 145T321 71 498 145 571 321V429H589Q612 429 627 444T643 482Z",
+        "width": 642
+      },
+      "search": [
+        "lock"
+      ]
     },
     },
     {
     {
-      "uid": "657ab647f6248a6b57a5b893beaf35a9",
+      "uid": "b28286f9770aaece4022bcae5718ce88",
       "css": "lock-open",
       "css": "lock-open",
-      "code": 59392,
-      "src": "fontawesome"
+      "code": 59393,
+      "src": "custom_icons",
+      "selected": true,
+      "svg": {
+        "path": "M929 321V464Q929 479 918 489T893 500H857Q843 500 832 489T821 464V321Q821 262 780 220T679 179 578 220 536 321V429H589Q612 429 627 444T643 482V804Q643 826 627 841T589 857H54Q31 857 16 841T0 804V482Q0 460 16 444T54 429H429V321Q429 218 502 145T679 71 855 145 929 321Z",
+        "width": 928
+      },
+      "search": [
+        "lock-open"
+      ]
     },
     },
     {
     {
-      "uid": "e594fc6e5870b4ab7e49f52571d52577",
-      "css": "resize-full",
+      "uid": "9dd9e835aebe1060ba7190ad2b2ed951",
+      "css": "search",
       "code": 59394,
       "code": 59394,
       "src": "fontawesome"
       "src": "fontawesome"
     },
     },
     {
     {
-      "uid": "3c24ee33c9487bbf18796ca6dffa1905",
-      "css": "resize-small",
+      "uid": "12be5a4f3c6627ff0e39dddee2025e5d",
+      "css": "resize-full",
       "code": 59395,
       "code": 59395,
-      "src": "fontawesome"
+      "src": "custom_icons",
+      "selected": true,
+      "svg": {
+        "path": "M421 589Q421 596 416 602L231 787 311 868Q321 878 321 893T311 918 286 929H36Q21 929 11 918T0 893V643Q0 628 11 618T36 607 61 618L141 698 327 513Q332 507 339 507T352 513L416 576Q421 582 421 589ZM857 107V357Q857 372 847 382T821 393 796 382L716 302 531 487Q525 493 518 493T505 487L441 423Q436 418 436 411T441 398L627 213 546 132Q536 122 536 107T546 82 571 71H821Q836 71 847 82T857 107Z",
+        "width": 857
+      },
+      "search": [
+        "resize-full"
+      ]
     },
     },
     {
     {
-      "uid": "5211af474d3a9848f67f945e2ccaf143",
-      "css": "cancel",
+      "uid": "33973fa333ad375d5a633bedcdafd435",
+      "css": "resize-small",
       "code": 59396,
       "code": 59396,
-      "src": "fontawesome"
+      "src": "custom_icons",
+      "selected": true,
+      "svg": {
+        "path": "M429 536V786Q429 800 418 811T393 821 368 811L287 730 102 916Q97 921 89 921T77 916L13 852Q7 846 7 839T13 826L198 641 118 561Q107 550 107 536T118 511 143 500H393Q407 500 418 511T429 536ZM850 161Q850 168 844 173L659 359 739 439Q750 450 750 464T739 489 714 500H464Q450 500 439 489T429 464V214Q429 200 439 189T464 179 489 189L570 269 755 84Q761 79 768 79T781 84L844 148Q850 153 850 161Z",
+        "width": 857
+      },
+      "search": [
+        "resize-small"
+      ]
     },
     },
     {
     {
-      "uid": "5e9f01871d44e56b45ecbfd00f4dbc3a",
-      "css": "layout",
+      "uid": "902a4e8e6f5592bbf98bdc47626fdc95",
+      "css": "cancel",
       "code": 59397,
       "code": 59397,
-      "src": "entypo"
+      "src": "custom_icons",
+      "selected": true,
+      "svg": {
+        "path": "M724 738Q724 760 709 776L633 852Q617 867 595 867T557 852L393 687 229 852Q213 867 191 867T153 852L77 776Q61 760 61 738T77 700L241 536 77 372Q61 356 61 334T77 296L153 220Q169 204 191 204T229 220L393 384 557 220Q573 204 595 204T633 220L709 296Q724 311 724 334T709 372L545 536 709 700Q724 715 724 738Z",
+        "width": 785
+      },
+      "search": [
+        "cancel"
+      ]
     },
     },
     {
     {
-      "uid": "bc64550dd022ce21604f97309b346cea",
-      "css": "cog",
+      "uid": "0f5c02613b953e4a957c9ede7a2a1736",
+      "css": "layout",
       "code": 59398,
       "code": 59398,
-      "src": "entypo"
+      "src": "custom_icons",
+      "selected": true,
+      "svg": {
+        "path": "M170 200Q250 200 250 280V370Q250 450 170 450H80Q0 450 0 370V280Q0 200 80 200H170ZM520 200Q600 200 600 280V370Q600 450 520 450H430Q350 450 350 370V280Q350 200 430 200H520ZM170 550Q250 550 250 630V720Q250 800 170 800H80Q0 800 0 720V630Q0 550 80 550H170ZM520 550Q600 550 600 630V720Q600 800 520 800H430Q350 800 350 720V630Q350 550 430 550H520Z",
+        "width": 600
+      },
+      "search": [
+        "layout"
+      ]
     },
     },
     {
     {
-      "uid": "815503841e980c848f55e0271deacead",
+      "uid": "258df07a96c2a33edba9b8fbe0d86b69",
       "css": "link",
       "css": "link",
       "code": 59399,
       "code": 59399,
-      "src": "entypo"
+      "src": "custom_icons",
+      "selected": true,
+      "svg": {
+        "path": "M294 734Q308 720 328 720T364 734Q396 768 364 804L322 844Q266 900 189 900T56 844 0 711 56 578L204 430Q274 362 348 353T476 396Q492 412 492 432T476 468Q440 500 406 468 356 420 274 502L126 648Q100 674 100 712T126 775 189 800 252 774ZM744 160Q800 216 800 293T744 426L586 584Q512 656 436 656 374 656 324 606 310 592 310 572T324 537 359 522 394 536Q444 584 516 512L674 356Q702 328 702 291T674 230Q650 204 618 199T558 220L508 270Q492 284 472 284T438 270Q404 236 438 200L488 150Q542 96 615 99T744 160Z",
+        "width": 800
+      },
+      "search": [
+        "link"
+      ]
     },
     },
     {
     {
-      "uid": "bac5560321c6d563a572b825ee747568",
+      "uid": "931bb8ea6223fb95f227bb8f63465339",
       "css": "bybit",
       "css": "bybit",
       "code": 59400,
       "code": 59400,
       "src": "custom_icons",
       "src": "custom_icons",
       "selected": true,
       "selected": true,
       "svg": {
       "svg": {
-        "path": "M695 364.4S643.7 348.1 640.6 350L391.2 22.5S343.1 44.4 340 46.9L550.6 308.1C546.9 312.5 499.4 312.5 495 312.5L272.5 91.9 234.3 135.6 418.7 312.5C415 316.9 373.1 333.8 369.4 338.8L191.8 203.8C191.8 206.9 170 251.9 168.1 255L311.8 357.5C311.8 361.9 275.6 391.3 272.5 396.3L165 339.4S165 391.9 165 394.4L227.5 433.8 223.1 443.1 165.6 431.9 206.8 463.1C124.3 578.1 143.7 762.5 249.3 889.4L210 779.4H218.1L291.2 945S340.6 961.3 344.3 960L226.8 703.1S241.2 666.9 243.7 665.6L425 995.7C428.7 995.7 480.6 1001.9 487.5 999.4L270 635S326.2 663.1 332.5 660.6L561.9 995.7C565 995.7 613.1 979.4 615.6 976.9L396.8 681.3C400.6 681.3 456.2 692.5 459.4 689.4L685 939.4S726.9 905 729.4 901.9L536.9 700.7C540.6 696.3 588.1 687.5 591.9 683.2L779.4 838.8 812.5 793.2 653.7 668.2C653.7 663.2 695.6 638.8 698.7 633.8L830.6 714.4C830.6 711.3 837.5 661.9 838.7 659.4L749.4 599.4C749.4 595.6 778.7 565.6 780.6 562.5L838.7 574.4 796.2 543.1C876.9 436.9 853.7 230.6 745 105.6L800 250S768.7 254.4 765.6 256.3L666.2 45C663.1 45 610.6 22.5 606.9 24.4L754.4 320.6C751.2 320.6 715.6 316.3 712.5 320.6L525.6 0C521.9 0 472.5 6.3 469.4 8.8Z",
+        "path": "M695 364L669 356Q643 349 641 350L391 22 367 33Q342 45 340 47L551 308Q547 312 495 312L273 92 234 136 419 312Q417 314 394 325T369 339L192 204Q192 206 181 229T168 255L312 357Q312 360 288 381 274 394 273 396L165 339V394L228 434 223 443 166 432 207 463Q166 520 158 596 149 671 173 748.5T249 889L210 779H218L291 945 316 953Q342 961 344 960L227 703 234 685Q242 667 244 666L425 996 435 997Q483 1001 488 999L270 635 299 649Q329 662 333 661L562 996Q564 996 589 987.5T616 977L397 681 412 683Q457 691 459 689L685 939 706 922Q728 904 729 902L537 701Q539 698 567 691 590 685 592 683L779 839 813 793 654 668Q654 665 679 648 698 636 699 634L831 714Q831 712 834.5 686.5T839 659L749 599Q749 597 767 578 780 564 781 562L839 574 796 543Q836 491 843 410 850 332 825 251 798 167 745 106L800 250 784 252Q768 255 766 256L666 45Q665 45 648 38 610 23 607 24L754 321H749Q715 317 713 321L526 0Q521 0 496.5 3.5T469 9Z",
         "width": 1000
         "width": 1000
       },
       },
       "search": [
       "search": [
@@ -69,13 +125,13 @@
       ]
       ]
     },
     },
     {
     {
-      "uid": "9292a7baaacec6cda7efa476f962ce17",
+      "uid": "5a1164687e2aa52f1aa536b52fa35b12",
       "css": "binance_futures",
       "css": "binance_futures",
       "code": 59401,
       "code": 59401,
       "src": "custom_icons",
       "src": "custom_icons",
       "selected": true,
       "selected": true,
       "svg": {
       "svg": {
-        "path": "M306.3 418.8L500 225 693.8 418.8 806.3 306.3 500 0 193.7 306.3 306.2 418.8ZM0 500L112.5 387.5 225 500 112.5 612.5 0 500ZM306.3 581.3L500 775 693.8 581.3 806.3 693.8 500 1000 193.7 693.8 306.2 581.2ZM775 500L887.5 387.5 1000 500 887.5 612.5 775 500ZM612.5 500L500 387.5 387.5 500 500 612.5 612.5 500Z",
+        "path": "M306 419L500 225 694 419 806 306 500 0 194 306ZM0 500L113 387 225 500 113 612ZM306 581L500 775 694 581 806 694 500 1000 194 694ZM775 500L888 387 1000 500 888 612ZM613 500L500 387 388 500 500 612Z",
         "width": 1000
         "width": 1000
       },
       },
       "search": [
       "search": [
@@ -83,32 +139,66 @@
       ]
       ]
     },
     },
     {
     {
-      "uid": "e392b748bd095b278c27391b3e05286c",
-      "css": "disconnect-svgrepo-com",
-      "code": 59399,
+      "uid": "992789927ac62fa408b8b24fe5efbce0",
+      "css": "cog",
+      "code": 59408,
       "src": "custom_icons",
       "src": "custom_icons",
-      "selected": false,
+      "selected": true,
       "svg": {
       "svg": {
-        "path": "M1040.8 239.3C935 133.5 763.9 133.5 658.3 239.3L537.1 360.4 600.9 424.1 722 303C789.3 235.8 902.8 228.6 977 303 1051.4 377.4 1044.3 490.8 977 558L855.9 679.1 919.8 743 1040.9 621.9C1146.4 516.1 1146.4 345 1040.8 239.2ZM558.1 977C490.9 1044.3 377.4 1051.4 303.1 977 228.8 902.6 235.9 789.3 303.1 722L424.3 600.9 360.4 537 239.2 658.1C133.5 763.9 133.5 935 239.2 1040.6S516.1 1146.4 621.8 1040.6L742.9 919.5 679.1 855.8 558.1 977ZM325.4 261.8A10 10 0 0 0 311.3 261.8L261.8 311.3A10 10 0 0 0 261.8 325.4L954.8 1018.4C958.6 1022.3 965 1022.3 968.9 1018.4L1018.4 968.9C1022.3 965 1022.3 958.6 1018.4 954.8L325.4 261.8Z",
-        "width": 1000
+        "path": "M760 500Q760 572 840 622 828 662 806 704 736 686 670 748 616 806 636 884 596 904 552 920 506 838 420 838T288 920Q244 904 204 884 224 804 170 749T34 714Q20 688 0 632 82 580 82 500 82 428 0 376 20 320 34 294 108 312 170 250 224 194 204 114 246 92 288 80 334 160 420 160T552 80Q594 92 636 114 616 192 670 250 736 312 806 294 828 336 840 376 760 426 760 500ZM420 682Q496 682 549 629T602 500 549 370 420 316 291 370 238 500 291 629 420 682Z",
+        "width": 840
       },
       },
       "search": [
       "search": [
-        "disconnect-svgrepo-com"
+        "cog"
       ]
       ]
     },
     },
     {
     {
-      "uid": "336e273abd6f7e289f16dbc34b8d97d5",
-      "css": "circle-svgrepo-com",
-      "code": 59393,
-      "src": "custom_icons",
-      "selected": false,
-      "svg": {
-        "path": "",
-        "width": 1000
-      },
-      "search": [
-        "circle-svgrepo-com"
-      ]
+      "uid": "56a21935a5d4d79b2e91ec00f760b369",
+      "css": "sort",
+      "code": 61660,
+      "src": "fontawesome"
+    },
+    {
+      "uid": "94103e1b3f1e8cf514178ec5912b4469",
+      "css": "sort-down",
+      "code": 61661,
+      "src": "fontawesome"
+    },
+    {
+      "uid": "65b3ce930627cabfb6ac81ac60ec5ae4",
+      "css": "sort-up",
+      "code": 61662,
+      "src": "fontawesome"
+    },
+    {
+      "uid": "d17030afaecc1e1c22349b99f3c4992a",
+      "css": "star-empty",
+      "code": 59402,
+      "src": "fontawesome"
+    },
+    {
+      "uid": "474656633f79ea2f1dad59ff63f6bf07",
+      "css": "star",
+      "code": 59403,
+      "src": "fontawesome"
+    },
+    {
+      "uid": "5308d824454af8ec7835786e272361a9",
+      "css": "level-up",
+      "code": 59404,
+      "src": "entypo"
+    },
+    {
+      "uid": "493hui9b6xiqaf04slclmqnwpap3oxjj",
+      "css": "popup",
+      "code": 59405,
+      "src": "typicons"
+    },
+    {
+      "uid": "85046e7e5a2fe685961d1423fcda0649",
+      "css": "chart-outline",
+      "code": 59406,
+      "src": "typicons"
     }
     }
   ]
   ]
 }
 }

BIN
src/fonts/icons.ttf


+ 354 - 0
src/layout.rs

@@ -0,0 +1,354 @@
+use std::collections::HashMap;
+use std::fs::File;
+use std::io::{Read, Write};
+use std::path::Path;
+use iced::widget::pane_grid;
+use iced::{Point, Size, Theme};
+use serde::{Deserialize, Serialize};
+
+use crate::charts::indicators::{CandlestickIndicator, FootprintIndicator, HeatmapIndicator};
+use crate::data_providers::{Exchange, StreamType, Ticker};
+use crate::screen::dashboard::{Dashboard, PaneContent, PaneSettings, PaneState};
+use crate::pane::Axis;
+use crate::screen::UserTimezone;
+use crate::style;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
+pub enum LayoutId {
+    Layout1,
+    Layout2,
+    Layout3,
+    Layout4,
+}
+
+impl std::fmt::Display for LayoutId {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            LayoutId::Layout1 => write!(f, "Layout 1"),
+            LayoutId::Layout2 => write!(f, "Layout 2"),
+            LayoutId::Layout3 => write!(f, "Layout 3"),
+            LayoutId::Layout4 => write!(f, "Layout 4"),
+        }
+    }
+}
+
+impl LayoutId {
+    pub const ALL: [LayoutId; 4] = [
+        LayoutId::Layout1,
+        LayoutId::Layout2,
+        LayoutId::Layout3,
+        LayoutId::Layout4,
+    ];
+}
+
+#[derive(Debug, Clone, PartialEq, Copy, Deserialize, Serialize)]
+pub enum Sidebar {
+    Left,
+    Right,
+}
+
+impl Default for Sidebar {
+    fn default() -> Self {
+        Sidebar::Left
+    }
+}
+
+impl std::fmt::Display for Sidebar {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Sidebar::Left => write!(f, "Left"),
+            Sidebar::Right => write!(f, "Right"),
+        }
+    }
+}
+
+pub struct SavedState {
+    pub layouts: HashMap<LayoutId, Dashboard>,
+    pub selected_theme: SerializableTheme,
+    pub favorited_tickers: Vec<(Exchange, Ticker)>,
+    pub last_active_layout: LayoutId,
+    pub window_size: Option<(f32, f32)>,
+    pub window_position: Option<(f32, f32)>,
+    pub timezone: UserTimezone,
+    pub sidebar: Sidebar,
+}
+
+impl Default for SavedState {
+    fn default() -> Self {
+        let mut layouts = HashMap::new();
+        layouts.insert(LayoutId::Layout1, Dashboard::default());
+        layouts.insert(LayoutId::Layout2, Dashboard::default());
+        layouts.insert(LayoutId::Layout3, Dashboard::default());
+        layouts.insert(LayoutId::Layout4, Dashboard::default());
+
+        SavedState {
+            layouts,
+            selected_theme: SerializableTheme::default(),
+            favorited_tickers: Vec::new(),
+            last_active_layout: LayoutId::Layout1,
+            window_size: None,
+            window_position: None,
+            timezone: UserTimezone::default(),
+            sidebar: Sidebar::default(),
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct SerializableTheme {
+    pub theme: Theme,
+}
+
+impl Default for SerializableTheme {
+    fn default() -> Self {
+        Self {
+            theme: Theme::Custom(style::custom_theme().into()),
+        }
+    }
+}
+
+impl Serialize for SerializableTheme {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        let theme_str = match self.theme {
+            Theme::Ferra => "ferra",
+            Theme::Dark => "dark",
+            Theme::Light => "light",
+            Theme::Dracula => "dracula",
+            Theme::Nord => "nord",
+            Theme::SolarizedLight => "solarized_light",
+            Theme::SolarizedDark => "solarized_dark",
+            Theme::GruvboxLight => "gruvbox_light",
+            Theme::GruvboxDark => "gruvbox_dark",
+            Theme::CatppuccinLatte => "catppuccino_latte",
+            Theme::CatppuccinFrappe => "catppuccino_frappe",
+            Theme::CatppuccinMacchiato => "catppuccino_macchiato",
+            Theme::CatppuccinMocha => "catppuccino_mocha",
+            Theme::TokyoNight => "tokyo_night",
+            Theme::TokyoNightStorm => "tokyo_night_storm",
+            Theme::TokyoNightLight => "tokyo_night_light",
+            Theme::KanagawaWave => "kanagawa_wave",
+            Theme::KanagawaDragon => "kanagawa_dragon",
+            Theme::KanagawaLotus => "kanagawa_lotus",
+            Theme::Moonfly => "moonfly",
+            Theme::Nightfly => "nightfly",
+            Theme::Oxocarbon => "oxocarbon",
+            Theme::Custom(_) => "flowsurface",
+        };
+        theme_str.serialize(serializer)
+    }
+}
+
+impl<'de> Deserialize<'de> for SerializableTheme {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let theme_str = String::deserialize(deserializer)?;
+        let theme = match theme_str.as_str() {
+            "ferra" => Theme::Ferra,
+            "dark" => Theme::Dark,
+            "light" => Theme::Light,
+            "dracula" => Theme::Dracula,
+            "nord" => Theme::Nord,
+            "solarized_light" => Theme::SolarizedLight,
+            "solarized_dark" => Theme::SolarizedDark,
+            "gruvbox_light" => Theme::GruvboxLight,
+            "gruvbox_dark" => Theme::GruvboxDark,
+            "catppuccino_latte" => Theme::CatppuccinLatte,
+            "catppuccino_frappe" => Theme::CatppuccinFrappe,
+            "catppuccino_macchiato" => Theme::CatppuccinMacchiato,
+            "catppuccino_mocha" => Theme::CatppuccinMocha,
+            "tokyo_night" => Theme::TokyoNight,
+            "tokyo_night_storm" => Theme::TokyoNightStorm,
+            "tokyo_night_light" => Theme::TokyoNightLight,
+            "kanagawa_wave" => Theme::KanagawaWave,
+            "kanagawa_dragon" => Theme::KanagawaDragon,
+            "kanagawa_lotus" => Theme::KanagawaLotus,
+            "moonfly" => Theme::Moonfly,
+            "nightfly" => Theme::Nightfly,
+            "oxocarbon" => Theme::Oxocarbon,
+            "flowsurface" => SerializableTheme::default().theme,
+            _ => return Err(serde::de::Error::custom("Invalid theme")),
+        };
+        Ok(SerializableTheme { theme })
+    }
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct SerializableState {
+    pub layouts: HashMap<LayoutId, SerializableDashboard>,
+    pub selected_theme: SerializableTheme,
+    pub favorited_tickers: Vec<(Exchange, Ticker)>,
+    pub last_active_layout: LayoutId,
+    pub window_size: Option<(f32, f32)>,
+    pub window_position: Option<(f32, f32)>,
+    pub timezone: UserTimezone,
+    pub sidebar: Sidebar,
+}
+
+impl SerializableState {
+    pub fn from_parts(
+        layouts: HashMap<LayoutId, SerializableDashboard>,
+        selected_theme: Theme,
+        favorited_tickers: Vec<(Exchange, Ticker)>,
+        last_active_layout: LayoutId,
+        size: Option<Size>,
+        position: Option<Point>,
+        timezone: UserTimezone,
+        sidebar: Sidebar,
+    ) -> Self {
+        SerializableState {
+            layouts,
+            selected_theme: SerializableTheme {
+                theme: selected_theme,
+            },
+            favorited_tickers,
+            last_active_layout,
+            window_size: size.map(|s| (s.width, s.height)),
+            window_position: position.map(|p| (p.x, p.y)),
+            timezone,
+            sidebar,
+        }
+    }
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct SerializableDashboard {
+    pub pane: SerializablePane,
+    pub popout: Vec<(SerializablePane, (f32, f32), (f32, f32))>,
+}
+
+impl<'a> From<&'a Dashboard> for SerializableDashboard {
+    fn from(dashboard: &'a Dashboard) -> Self {
+        use pane_grid::Node;
+
+        fn from_layout(
+            panes: &pane_grid::State<PaneState>,
+            node: pane_grid::Node,
+        ) -> SerializablePane {
+            match node {
+                Node::Split {
+                    axis, ratio, a, b, ..
+                } => SerializablePane::Split {
+                    axis: match axis {
+                        pane_grid::Axis::Horizontal => Axis::Horizontal,
+                        pane_grid::Axis::Vertical => Axis::Vertical,
+                    },
+                    ratio,
+                    a: Box::new(from_layout(panes, *a)),
+                    b: Box::new(from_layout(panes, *b)),
+                },
+                Node::Pane(pane) => panes
+                    .get(pane)
+                    .map_or(SerializablePane::Starter, SerializablePane::from),
+            }
+        }
+
+        let main_window_layout = dashboard.panes.layout().clone();
+
+        let popouts_layout: Vec<(SerializablePane, (Point, Size))> = dashboard
+            .popout
+            .iter()
+            .map(|(_, (pane, specs))| (from_layout(pane, pane.layout().clone()), *specs))
+            .collect();
+
+        SerializableDashboard {
+            pane: from_layout(&dashboard.panes, main_window_layout),
+            popout: {
+                popouts_layout
+                    .iter()
+                    .map(|(pane, (pos, size))| {
+                        (pane.clone(), (pos.x, pos.y), (size.width, size.height))
+                    })
+                    .collect()
+            },
+        }
+    }
+}
+
+impl Default for SerializableDashboard {
+    fn default() -> Self {
+        Self {
+            pane: SerializablePane::Starter,
+            popout: vec![],
+        }
+    }
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub enum SerializablePane {
+    Split {
+        axis: Axis,
+        ratio: f32,
+        a: Box<SerializablePane>,
+        b: Box<SerializablePane>,
+    },
+    Starter,
+    HeatmapChart {
+        stream_type: Vec<StreamType>,
+        settings: PaneSettings,
+        indicators: Vec<HeatmapIndicator>,
+    },
+    FootprintChart {
+        stream_type: Vec<StreamType>,
+        settings: PaneSettings,
+        indicators: Vec<FootprintIndicator>,
+    },
+    CandlestickChart {
+        stream_type: Vec<StreamType>,
+        settings: PaneSettings,
+        indicators: Vec<CandlestickIndicator>,
+    },
+    TimeAndSales {
+        stream_type: Vec<StreamType>,
+        settings: PaneSettings,
+    },
+}
+
+impl From<&PaneState> for SerializablePane {
+    fn from(pane: &PaneState) -> Self {
+        let pane_stream = pane.stream.clone();
+
+        match &pane.content {
+            PaneContent::Starter => SerializablePane::Starter,
+            PaneContent::Heatmap(_, indicators) => SerializablePane::HeatmapChart {
+                stream_type: pane_stream,
+                settings: pane.settings,
+                indicators: indicators.clone(),
+            },
+            PaneContent::Footprint(_, indicators) => SerializablePane::FootprintChart {
+                stream_type: pane_stream,
+                settings: pane.settings,
+                indicators: indicators.clone(),
+            },
+            PaneContent::Candlestick(_, indicators) => SerializablePane::CandlestickChart {
+                stream_type: pane_stream,
+                settings: pane.settings,
+                indicators: indicators.clone(),
+            },
+            PaneContent::TimeAndSales(_) => SerializablePane::TimeAndSales {
+                stream_type: pane_stream,
+                settings: pane.settings,
+            },
+        }
+    }
+}
+
+pub fn write_json_to_file(json: &str, file_path: &str) -> std::io::Result<()> {
+    let path = Path::new(file_path);
+    let mut file = File::create(path)?;
+    file.write_all(json.as_bytes())?;
+    Ok(())
+}
+
+pub fn read_from_file(file_path: &str) -> Result<SerializableState, Box<dyn std::error::Error>> {
+    let path = Path::new(file_path);
+    let mut file = File::open(path)?;
+    let mut contents = String::new();
+    file.read_to_string(&mut contents)?;
+
+    Ok(serde_json::from_str(&contents)?)
+}

+ 9 - 6
src/logger.rs

@@ -1,9 +1,12 @@
 use chrono::Local;
 use chrono::Local;
-use std::{fs::{self, File}, process};
+use std::{
+    fs::{self, File},
+    process,
+};
 
 
 const MAX_LOG_FILE_SIZE: u64 = 10_000_000; // 10 MB
 const MAX_LOG_FILE_SIZE: u64 = 10_000_000; // 10 MB
 
 
-pub fn setup(is_debug: bool, log_trace: bool) -> Result<(), anyhow::Error> {
+pub fn setup(is_debug: bool, log_trace: bool) -> Result<(), fern::InitError> {
     let log_level = if log_trace {
     let log_level = if log_trace {
         log::LevelFilter::Trace
         log::LevelFilter::Trace
     } else {
     } else {
@@ -19,7 +22,7 @@ pub fn setup(is_debug: bool, log_trace: bool) -> Result<(), anyhow::Error> {
                 record.file().unwrap_or("unknown"),
                 record.file().unwrap_or("unknown"),
                 record.line().unwrap_or(0),
                 record.line().unwrap_or(0),
                 message
                 message
-            ))
+            ));
         })
         })
         .level(log_level);
         .level(log_level);
 
 
@@ -49,16 +52,16 @@ fn monitor_file_size(file_path: &str, max_size_bytes: u64) {
                 if metadata.len() > max_size_bytes {
                 if metadata.len() > max_size_bytes {
                     eprintln!(
                     eprintln!(
                         "Things went south. Log file size caused panic exceeding {} MB",
                         "Things went south. Log file size caused panic exceeding {} MB",
-                        metadata.len() / 1_000_000, 
+                        metadata.len() / 1_000_000,
                     );
                     );
                     process::exit(1);
                     process::exit(1);
                 }
                 }
             }
             }
             Err(err) => {
             Err(err) => {
-                eprintln!("Error reading log file metadata: {}", err);
+                eprintln!("Error reading log file metadata: {err}");
                 process::exit(1);
                 process::exit(1);
             }
             }
         }
         }
         std::thread::sleep(std::time::Duration::from_secs(30));
         std::thread::sleep(std::time::Duration::from_secs(30));
     }
     }
-}
+}

+ 923 - 735
src/main.rs

@@ -1,47 +1,60 @@
 #![windows_subsystem = "windows"]
 #![windows_subsystem = "windows"]
 
 
-mod data_providers;
 mod charts;
 mod charts;
-mod style;
-mod screen;
+mod data_providers;
+mod layout;
 mod logger;
 mod logger;
-
-use style::{ICON_FONT, ICON_BYTES, Icon};
-
-use screen::{dashboard, Error, Notification};
-use screen::dashboard::{
-    Dashboard,
-    pane::{self, SerializablePane}, Uuid,
-    PaneContent, PaneSettings, PaneState, 
-    SerializableDashboard, 
+mod screen;
+mod style;
+mod tickers_table;
+mod tooltip;
+mod window;
+
+use tooltip::tooltip;
+use screen::modal::dashboard_modal;
+use layout::{SerializableDashboard, SerializablePane, Sidebar};
+
+use futures::TryFutureExt;
+use iced_futures::MaybeSend;
+use style::{get_icon_text, Icon, ICON_BYTES};
+
+use screen::{create_button, dashboard, handle_error, Notification, UserTimezone};
+use screen::dashboard::{Dashboard, pane, PaneContent, PaneSettings, PaneState};
+use data_providers::{
+    binance, bybit, Exchange, MarketType, StreamType, TickMultiplier, Ticker, TickerInfo, TickerStats, Timeframe
 };
 };
-use data_providers::{binance, bybit, Exchange, MarketEvents, Ticker, Timeframe, StreamType};
+use tickers_table::TickersTable;
 
 
 use charts::footprint::FootprintChart;
 use charts::footprint::FootprintChart;
 use charts::heatmap::HeatmapChart;
 use charts::heatmap::HeatmapChart;
 use charts::candlestick::CandlestickChart;
 use charts::candlestick::CandlestickChart;
 use charts::timeandsales::TimeAndSales;
 use charts::timeandsales::TimeAndSales;
+use window::{window_events, Window, WindowEvent};
 
 
-use std::{collections::{HashMap, VecDeque}, vec};
+use std::future::Future;
+use std::{collections::HashMap, vec};
 
 
 use iced::{
 use iced::{
-    alignment, widget::{
-        button, center, checkbox, mouse_area, opaque, pick_list, stack, tooltip, Column, Container, Row, Slider, Space, Text
-    }, window::{self, Position}, Alignment, Color, Element, Length, Point, Size, Subscription, Task, Theme
+    widget::{button, pick_list, Space, column, container, row, text},
+    padding, Alignment, Element, Length, Point, Size, Subscription, Task, Theme,
 };
 };
+use iced::widget::{center, responsive};
 use iced::widget::pane_grid::{self, Configuration};
 use iced::widget::pane_grid::{self, Configuration};
-use iced::widget::{container, row, scrollable, text};
 
 
-fn main() -> iced::Result {
+fn main() {
     logger::setup(false, false).expect("Failed to initialize logger");
     logger::setup(false, false).expect("Failed to initialize logger");
 
 
-    let saved_state = match read_layout_from_file("dashboard_state.json") {
+    let saved_state = match layout::read_from_file("dashboard_state.json") {
         Ok(state) => {
         Ok(state) => {
-            let mut de_state = SavedState {
+            let mut de_state = layout::SavedState {
+                selected_theme: state.selected_theme,
                 layouts: HashMap::new(),
                 layouts: HashMap::new(),
+                favorited_tickers: state.favorited_tickers,
                 last_active_layout: state.last_active_layout,
                 last_active_layout: state.last_active_layout,
                 window_size: state.window_size,
                 window_size: state.window_size,
                 window_position: state.window_position,
                 window_position: state.window_position,
+                timezone: state.timezone,
+                sidebar: state.sidebar,
             };
             };
 
 
             fn configuration(pane: SerializablePane) -> Configuration<PaneState> {
             fn configuration(pane: SerializablePane) -> Configuration<PaneState> {
@@ -56,866 +69,1041 @@ fn main() -> iced::Result {
                         b: Box::new(configuration(*b)),
                         b: Box::new(configuration(*b)),
                     },
                     },
                     SerializablePane::Starter => {
                     SerializablePane::Starter => {
-                        Configuration::Pane(PaneState::new(Uuid::new_v4(), vec![], PaneSettings::default()))
-                    },
-                    SerializablePane::CandlestickChart { stream_type, settings } => {
-                        let timeframe = settings.selected_timeframe
-                            .unwrap()
-                            .to_minutes();
-
-                        Configuration::Pane(
-                            PaneState::from_config(
-                                PaneContent::Candlestick(
-                                    CandlestickChart::new(
-                                        vec![], 
-                                        timeframe
-                                    )
-                                ),
-                                stream_type,
-                                settings
-                            )
-                        )
-                    },
-                    SerializablePane::FootprintChart { stream_type, settings } => {
-                        let ticksize = settings.tick_multiply
-                            .unwrap()
+                        Configuration::Pane(PaneState::new(vec![], PaneSettings::default()))
+                    }
+                    SerializablePane::CandlestickChart {
+                        stream_type,
+                        settings,
+                        indicators,
+                    } => {
+                        let tick_size = settings.tick_multiply
+                            .unwrap_or(TickMultiplier(1))
                             .multiply_with_min_tick_size(
                             .multiply_with_min_tick_size(
                                 settings.min_tick_size
                                 settings.min_tick_size
                                     .expect("No min tick size found, deleting dashboard_state.json probably fixes this")
                                     .expect("No min tick size found, deleting dashboard_state.json probably fixes this")
                             );
                             );
-                    
-                        let timeframe = settings.selected_timeframe
-                            .unwrap()
-                            .to_minutes();
-
-                        Configuration::Pane(
-                            PaneState::from_config(
-                                PaneContent::Footprint(
-                                    FootprintChart::new(
-                                        timeframe,
-                                        ticksize,
-                                        vec![], 
-                                        vec![]
-                                    )
+
+                        let timeframe = settings.selected_timeframe.unwrap_or(Timeframe::M5);
+
+                        Configuration::Pane(PaneState::from_config(
+                            PaneContent::Candlestick(
+                                CandlestickChart::new(
+                                    vec![],
+                                    timeframe,
+                                    tick_size,
+                                    UserTimezone::default(),
                                 ),
                                 ),
-                                stream_type,
-                                settings
-                            )
-                        )
-                    },
-                    SerializablePane::HeatmapChart { stream_type, settings } => {
-                        let ticksize = settings.tick_multiply
-                            .unwrap()
+                                indicators,
+                            ),
+                            stream_type,
+                            settings,
+                        ))
+                    }
+                    SerializablePane::FootprintChart {
+                        stream_type,
+                        settings,
+                        indicators,
+                    } => {
+                        let tick_size = settings.tick_multiply
+                            .unwrap_or(TickMultiplier(50))
                             .multiply_with_min_tick_size(
                             .multiply_with_min_tick_size(
                                 settings.min_tick_size
                                 settings.min_tick_size
                                     .expect("No min tick size found, deleting dashboard_state.json probably fixes this")
                                     .expect("No min tick size found, deleting dashboard_state.json probably fixes this")
                             );
                             );
 
 
-                        Configuration::Pane(
-                            PaneState::from_config(
-                                PaneContent::Heatmap(
-                                    HeatmapChart::new(ticksize)
+                        let timeframe = settings.selected_timeframe.unwrap_or(Timeframe::M15);
+
+                        Configuration::Pane(PaneState::from_config(
+                            PaneContent::Footprint(
+                                FootprintChart::new(
+                                    timeframe,
+                                    tick_size,
+                                    vec![],
+                                    vec![],
+                                    UserTimezone::default(),
                                 ),
                                 ),
-                                stream_type,
-                                settings
-                            )
-                        )
-                    },
-                    SerializablePane::TimeAndSales { stream_type, settings } => {
-                        Configuration::Pane(
-                            PaneState::from_config(
-                                PaneContent::TimeAndSales(
-                                    TimeAndSales::new()
+                                indicators,
+                            ),
+                            stream_type,
+                            settings,
+                        ))
+                    }
+                    SerializablePane::HeatmapChart {
+                        stream_type,
+                        settings,
+                        indicators,
+                    } => {
+                        let tick_size = settings.tick_multiply
+                            .unwrap_or(TickMultiplier(10))
+                            .multiply_with_min_tick_size(
+                                settings.min_tick_size
+                                    .expect("No min tick size found, deleting dashboard_state.json probably fixes this")
+                            );
+
+                        Configuration::Pane(PaneState::from_config(
+                            PaneContent::Heatmap(
+                                HeatmapChart::new(
+                                    tick_size,
+                                    100,
+                                    UserTimezone::default(),
                                 ),
                                 ),
-                                stream_type,
-                                settings
-                            )
-                        )
-                    },
+                                indicators,
+                            ),
+                            stream_type,
+                            settings,
+                        ))
+                    }
+                    SerializablePane::TimeAndSales {
+                        stream_type,
+                        settings,
+                    } => Configuration::Pane(PaneState::from_config(
+                        PaneContent::TimeAndSales(TimeAndSales::new()),
+                        stream_type,
+                        settings,
+                    )),
                 }
                 }
             }
             }
 
 
-            for (id, dashboard) in state.layouts.iter() {                
-                let dashboard = Dashboard::from_config(configuration(dashboard.pane.clone()));
+            for (id, dashboard) in &state.layouts {
+                let mut popout_windows: Vec<(Configuration<PaneState>, (Point, Size))> = Vec::new();
+
+                for (popout, pos, size) in &dashboard.popout {
+                    let configuration = configuration(popout.clone());
+                    popout_windows.push((
+                        configuration,
+                        (Point::new(pos.0, pos.1), Size::new(size.0, size.1)),
+                    ));
+                }
+
+                let dashboard =
+                    Dashboard::from_config(configuration(dashboard.pane.clone()), popout_windows);
 
 
                 de_state.layouts.insert(*id, dashboard);
                 de_state.layouts.insert(*id, dashboard);
             }
             }
 
 
             de_state
             de_state
-        },
+        }
         Err(e) => {
         Err(e) => {
-            log::error!("Failed to load/find layout state: {}. Starting with a new layout.", e);
+            log::error!(
+                "Failed to load/find layout state: {}. Starting with a new layout.",
+                e
+            );
 
 
-            SavedState::default()
+            layout::SavedState::default()
         }
         }
     };
     };
 
 
     let window_size = saved_state.window_size.unwrap_or((1600.0, 900.0));
     let window_size = saved_state.window_size.unwrap_or((1600.0, 900.0));
-    let window_position = saved_state.window_position.unwrap_or((0.0, 0.0));
+    let window_position = saved_state.window_position;
 
 
     let window_settings = window::Settings {
     let window_settings = window::Settings {
         size: iced::Size::new(window_size.0, window_size.1),
         size: iced::Size::new(window_size.0, window_size.1),
-        position: Position::Specific(Point::new(window_position.0, window_position.1)),
+        position: {
+            if let Some(position) = window_position {
+                iced::window::Position::Specific(Point {
+                    x: position.0,
+                    y: position.1,
+                })
+            } else {
+                iced::window::Position::Centered
+            }
+        },
+        platform_specific: iced::window::settings::PlatformSpecific {
+            title_hidden: true,
+            titlebar_transparent: true,
+            fullsize_content_view: true,
+        },
+        exit_on_close_request: false,
+        min_size: Some(iced::Size::new(800.0, 600.0)),
         ..Default::default()
         ..Default::default()
     };
     };
 
 
-    iced::application(
-        "Iced Trade",
-        State::update,
-        State::view,
-    )
-    .subscription(State::subscription)
-    .theme(|_| Theme::KanagawaDragon)
-    .antialiasing(true)
-    .window(window_settings)
-    .centered()   
-    .font(ICON_BYTES)
-    .exit_on_close_request(false)
-    .run_with(move || State::new(saved_state))
+    let _ = iced::daemon("Flowsurface", State::update, State::view)
+        .settings(iced::Settings {
+            default_text_size: iced::Pixels(12.0),
+            antialiasing: true,
+            ..Default::default()
+        })
+        .theme(State::theme)
+        .subscription(State::subscription)
+        .font(ICON_BYTES)
+        .run_with(move || State::new(saved_state, window_settings));
+}
+
+#[derive(thiserror::Error, Debug, Clone)]
+enum InternalError {
+    #[error("Fetch error: {0}")]
+    Fetch(String),
+}
+
+#[derive(Debug, Clone, PartialEq)]
+enum DashboardModal {
+    Layout,
+    Settings,
+    None,
 }
 }
 
 
 #[derive(Debug, Clone)]
 #[derive(Debug, Clone)]
-pub enum Message {
-    Debug(String),
+enum Message {
     Notification(Notification),
     Notification(Notification),
-    ErrorOccurred(Error),
-    ClearNotification,
+    ErrorOccurred(InternalError),
 
 
-    ShowLayoutModal,
-    HideLayoutModal,
+    ToggleModal(DashboardModal),
 
 
-    MarketWsEvent(MarketEvents),
-    
-    Event(Event),
-    SaveAndExit(window::Id, Option<Size>, Option<Point>),
+    MarketWsEvent(Exchange, data_providers::Event),
+
+    WindowEvent(WindowEvent),
+    SaveAndExit(HashMap<window::Id, (Point, Size)>),
 
 
     ToggleLayoutLock,
     ToggleLayoutLock,
     ResetCurrentLayout,
     ResetCurrentLayout,
-    LayoutSelected(LayoutId),
+    LayoutSelected(layout::LayoutId),
+    ThemeSelected(Theme),
     Dashboard(dashboard::Message),
     Dashboard(dashboard::Message),
+    SetTickersInfo(Exchange, HashMap<Ticker, Option<TickerInfo>>),
+    SetTimezone(UserTimezone),
+    SidebarPosition(layout::Sidebar),
+
+    TickersTable(tickers_table::Message),
+    ToggleTickersDashboard,
+    UpdateTickersTable(Exchange, HashMap<Ticker, TickerStats>),
+    FetchAndUpdateTickersTable,
+
+    LoadLayout(layout::LayoutId),
 }
 }
 
 
 struct State {
 struct State {
-    layouts: HashMap<LayoutId, Dashboard>,
-    last_active_layout: LayoutId,
-    show_layout_modal: bool,
-    exchange_latency: Option<(u32, u32)>,
-    feed_latency_cache: VecDeque<data_providers::FeedLatency>,
+    theme: Theme,
+    layouts: HashMap<layout::LayoutId, Dashboard>,
+    last_active_layout: layout::LayoutId,
+    main_window: Window,
+    active_modal: DashboardModal,
+    sidebar_location: Sidebar,
     notification: Option<Notification>,
     notification: Option<Notification>,
+    ticker_info_map: HashMap<Exchange, HashMap<Ticker, Option<TickerInfo>>>,
+    show_tickers_dashboard: bool,
+    tickers_table: TickersTable,
 }
 }
 
 
+#[allow(dead_code)]
 impl State {
 impl State {
-    fn new(saved_state: SavedState) -> (Self, Task<Message>) {
-        let mut tasks = vec![];
+    fn new(
+        saved_state: layout::SavedState,
+        window_settings: window::Settings,
+    ) -> (Self, Task<Message>) {
+        let (main_window, open_main_window) = window::open(window_settings);
 
 
         let last_active_layout = saved_state.last_active_layout;
         let last_active_layout = saved_state.last_active_layout;
 
 
-        let wait_and_fetch = Task::perform(
-            async { tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; },
-            move |_| Message::LayoutSelected(last_active_layout)
+        let mut ticker_info_map = HashMap::new();
+        let mut ticksizes_tasks = Vec::new();
+
+        for exchange in &Exchange::ALL {
+            ticker_info_map.insert(*exchange, HashMap::new());
+
+            let fetch_ticksize = match exchange {
+                Exchange::BinanceFutures => {
+                    fetch_ticker_info(*exchange, binance::fetch_ticksize(MarketType::LinearPerps))
+                }
+                Exchange::BybitLinear => {
+                    fetch_ticker_info(*exchange, bybit::fetch_ticksize(MarketType::LinearPerps))
+                }
+                Exchange::BinanceSpot => {
+                    fetch_ticker_info(*exchange, binance::fetch_ticksize(MarketType::Spot))
+                }
+                Exchange::BybitSpot => {
+                    fetch_ticker_info(*exchange, bybit::fetch_ticksize(MarketType::Spot))
+                }
+            };
+            ticksizes_tasks.push(fetch_ticksize);
+        }
+
+        let bybit_tickers_fetch = fetch_ticker_prices(
+            Exchange::BybitLinear,
+            bybit::fetch_ticker_prices(MarketType::LinearPerps),
+        );
+        let binance_tickers_fetch = fetch_ticker_prices(
+            Exchange::BinanceFutures,
+            binance::fetch_ticker_prices(MarketType::LinearPerps),
         );
         );
-        tasks.push(wait_and_fetch);
+        let binance_spot_tickers_fetch = fetch_ticker_prices(
+            Exchange::BinanceSpot,
+            binance::fetch_ticker_prices(MarketType::Spot),
+        );
+        let bybit_spot_tickers_fetch = fetch_ticker_prices(
+            Exchange::BybitSpot,
+            bybit::fetch_ticker_prices(MarketType::Spot),
+        );
+
+        let batch_fetch_tasks = Task::batch(vec![
+            bybit_tickers_fetch,
+            binance_tickers_fetch,
+            binance_spot_tickers_fetch,
+            bybit_spot_tickers_fetch,
+            Task::batch(ticksizes_tasks),
+        ]);
 
 
         (
         (
-            Self { 
+            Self {
+                theme: saved_state.selected_theme.theme,
                 layouts: saved_state.layouts,
                 layouts: saved_state.layouts,
                 last_active_layout,
                 last_active_layout,
-                show_layout_modal: false,
-                exchange_latency: None,
-                feed_latency_cache: VecDeque::new(),
+                main_window: Window::new(main_window),
+                active_modal: DashboardModal::None,
                 notification: None,
                 notification: None,
+                ticker_info_map,
+                show_tickers_dashboard: false,
+                sidebar_location: saved_state.sidebar,
+                tickers_table: TickersTable::new(saved_state.favorited_tickers),
             },
             },
-            Task::batch(tasks)
+            open_main_window
+                .then(|_| Task::none())
+                .chain(Task::batch(vec![
+                    Task::done(Message::LoadLayout(last_active_layout)),
+                    Task::done(Message::SetTimezone(saved_state.timezone)),
+                    batch_fetch_tasks,
+                ])),
         )
         )
     }
     }
 
 
     fn update(&mut self, message: Message) -> Task<Message> {
     fn update(&mut self, message: Message) -> Task<Message> {
         match message {
         match message {
-            Message::MarketWsEvent(event) => {
-                let dashboard = self.get_mut_dashboard();
+            Message::SetTickersInfo(exchange, tickers_info) => {
+                log::info!("Received tickers info for {exchange}, len: {}", tickers_info.len());
 
 
-                match event {
-                    MarketEvents::Binance(event) => match event {
-                        binance::market_data::Event::Connected(connection) => {
-                            log::info!("a stream connected to Binance WS: {connection:?}");
-                        }
-                        binance::market_data::Event::Disconnected(event) => {
-                            log::info!("a stream disconnected from Binance WS: {event:?}");
-                        }
-                        binance::market_data::Event::DepthReceived(ticker, feed_latency, depth_update_t, depth, trades_buffer) => {                            
-                            let stream_type = StreamType::DepthAndTrades {
-                                exchange: Exchange::BinanceFutures,
-                                ticker,
-                            };
-                            
-                            if let Err(err) = dashboard.update_depth_and_trades(stream_type, depth_update_t, depth, trades_buffer) {
-                                log::error!("{err}, {stream_type:?}");
-                            }
-                        }
-                        binance::market_data::Event::KlineReceived(ticker, kline, timeframe) => {
-                            let stream_type = StreamType::Kline {
-                                exchange: Exchange::BinanceFutures,
-                                ticker,
-                                timeframe,
-                            };
+                self.ticker_info_map.insert(exchange, tickers_info);
 
 
-                            if let Err(err) = dashboard.update_latest_klines(&stream_type, &kline) {
-                                log::error!("{err}, {stream_type:?}");
-                            }
-                        }
-                    },
-                    MarketEvents::Bybit(event) => match event {
-                        bybit::market_data::Event::Connected(_) => {
-                            log::info!("a stream connected to Bybit WS");
-                        }
-                        bybit::market_data::Event::Disconnected(event) => {
-                            log::info!("a stream disconnected from Bybit WS: {event:?}");
-                        }
-                        bybit::market_data::Event::DepthReceived(ticker, feed_latency, depth_update_t, depth, trades_buffer) => {
-                            let stream_type = StreamType::DepthAndTrades {
-                                exchange: Exchange::BybitLinear,
-                                ticker,
-                            };
-                            
-                            if let Err(err) = dashboard.update_depth_and_trades(stream_type, depth_update_t, depth, trades_buffer) {
-                                log::error!("{err}, {stream_type:?}");
-                            }
-                        }
-                        bybit::market_data::Event::KlineReceived(ticker, kline, timeframe) => {
-                            let stream_type = StreamType::Kline {
-                                exchange: Exchange::BybitLinear,
-                                ticker,
-                                timeframe,
-                            };
+                self.layouts.values_mut().for_each(|dashboard| {
+                    dashboard.set_tickers_info(self.ticker_info_map.clone());
+                });
+            }
+            Message::MarketWsEvent(exchange, event) => {
+                let main_window_id = self.main_window.id;
+                let dashboard = self.get_mut_dashboard(self.last_active_layout);
 
 
-                            if let Err(err) = dashboard.update_latest_klines(&stream_type, &kline) {
-                                log::error!("{err}, {stream_type:?}");
-                            }
-                        }
-                    },
+                match event {
+                    data_providers::Event::Connected(_) => {
+                        log::info!("a stream connected to {exchange} WS");
+                    }
+                    data_providers::Event::Disconnected(reason) => {
+                        log::info!("a stream disconnected from {exchange} WS: {reason:?}");
+                    }
+                    data_providers::Event::DepthReceived(
+                        ticker,
+                        depth_update_t,
+                        depth,
+                        trades_buffer,
+                    ) => {
+                        return dashboard
+                            .update_depth_and_trades(
+                                &StreamType::DepthAndTrades { exchange, ticker },
+                                depth_update_t,
+                                depth,
+                                trades_buffer,
+                                main_window_id,
+                            )
+                            .map(Message::Dashboard);
+                    }
+                    data_providers::Event::KlineReceived(ticker, kline, timeframe) => {
+                        return dashboard
+                            .update_latest_klines(
+                                &StreamType::Kline {
+                                    exchange,
+                                    ticker,
+                                    timeframe,
+                                },
+                                &kline,
+                                main_window_id,
+                            )
+                            .map(Message::Dashboard);
+                    }
                 }
                 }
-
-                Task::none()
-            },
+            }
             Message::ToggleLayoutLock => {
             Message::ToggleLayoutLock => {
-                let dashboard = self.get_mut_dashboard();
+                let dashboard = self.get_mut_dashboard(self.last_active_layout);
 
 
                 dashboard.layout_lock = !dashboard.layout_lock;
                 dashboard.layout_lock = !dashboard.layout_lock;
-
                 dashboard.focus = None;
                 dashboard.focus = None;
-
-                Task::none()
-            },
-            Message::Debug(msg) => {
-                println!("{msg}");
-                
-                Task::none()
-            },
-            Message::Event(event) => {
-                if let Event::CloseRequested(window) = event {     
-                    enum Either<L, R> {
-                        Left(L),
-                        Right(R),
+            }
+            Message::WindowEvent(event) => match event {
+                WindowEvent::CloseRequested(window) => {
+                    if window != self.main_window.id {
+                        self.get_mut_dashboard(self.last_active_layout)
+                            .popout
+                            .remove(&window);
+
+                        return window::close(window);
                     }
                     }
 
 
-                    Task::batch(vec![
-                        window::get_size(window).map(Either::Left),
-                        window::get_position(window).map(Either::Right)
-                    ])
-                    .collect()
-                    .map(move |results| {
-                        let mut size = None;
-                        let mut position = None;
-                        for result in results {
-                            match result {
-                                Either::Left(s) => size = Some(s),
-                                Either::Right(p) => position = p,
-                            }
-                        }
-                        Message::SaveAndExit(window, size, position)
-                    })
-                } else {
-                    Task::none()
+                    let mut opened_windows: Vec<window::Id> = self
+                        .get_dashboard(self.last_active_layout)
+                        .popout
+                        .keys()
+                        .copied()
+                        .collect::<Vec<_>>();
+
+                    opened_windows.push(self.main_window.id);
+
+                    return window::collect_window_specs(
+                        opened_windows, 
+                        Message::SaveAndExit
+                    );
                 }
                 }
             },
             },
-            Message::SaveAndExit(window, size, position) => {
+            Message::SaveAndExit(windows) => {
+                self.get_mut_dashboard(self.last_active_layout)
+                    .popout
+                    .iter_mut()
+                    .for_each(|(id, (_, (pos, size)))| {
+                        if let Some((new_pos, new_size)) = windows.get(id) {
+                            *pos = *new_pos;
+                            *size = *new_size;
+                        }
+                    });
+
                 let mut layouts = HashMap::new();
                 let mut layouts = HashMap::new();
 
 
-                for (id, dashboard) in self.layouts.iter() {
+                for (id, dashboard) in &self.layouts {
                     let serialized_dashboard = SerializableDashboard::from(dashboard);
                     let serialized_dashboard = SerializableDashboard::from(dashboard);
-
                     layouts.insert(*id, serialized_dashboard);
                     layouts.insert(*id, serialized_dashboard);
                 }
                 }
 
 
-                let layout = SerializableState::from_parts(
+                let favorited_tickers = self.tickers_table.get_favorited_tickers();
+
+                let size: Option<Size> = windows
+                    .iter()
+                    .find(|(id, _)| **id == self.main_window.id)
+                    .map(|(_, (_, size))| *size);
+
+                let position: Option<Point> = windows
+                    .iter()
+                    .find(|(id, _)| **id == self.main_window.id)
+                    .map(|(_, (position, _))| *position);
+
+                let user_tz = {
+                    let dashboard = self.get_dashboard(self.last_active_layout);
+                    dashboard.get_timezone()
+                };
+
+                let layout = layout::SerializableState::from_parts(
                     layouts,
                     layouts,
+                    self.theme.clone(),
+                    favorited_tickers,
                     self.last_active_layout,
                     self.last_active_layout,
                     size,
                     size,
-                    position
+                    position,
+                    user_tz,
+                    self.sidebar_location,
                 );
                 );
-            
+
                 match serde_json::to_string(&layout) {
                 match serde_json::to_string(&layout) {
                     Ok(layout_str) => {
                     Ok(layout_str) => {
-                        if let Err(e) = write_json_to_file(&layout_str, "dashboard_state.json") {
+                        if let Err(e) =
+                            layout::write_json_to_file(&layout_str, "dashboard_state.json")
+                        {
                             log::error!("Failed to write layout state to file: {}", e);
                             log::error!("Failed to write layout state to file: {}", e);
                         } else {
                         } else {
                             log::info!("Successfully wrote layout state to dashboard_state.json");
                             log::info!("Successfully wrote layout state to dashboard_state.json");
                         }
                         }
-                    },
+                    }
                     Err(e) => log::error!("Failed to serialize layout: {}", e),
                     Err(e) => log::error!("Failed to serialize layout: {}", e),
                 }
                 }
-            
-                window::close(window)
-            },
-            Message::ShowLayoutModal => {
-                self.show_layout_modal = true;
-                iced::widget::focus_next()
-            },
-            Message::HideLayoutModal => {
-                self.show_layout_modal = false;
-                Task::none()
-            },
+
+                return iced::exit();
+            }
+            Message::ToggleModal(modal) => {
+                if modal == self.active_modal {
+                    self.active_modal = DashboardModal::None;
+                } else {
+                    self.active_modal = modal;
+                }
+            }
             Message::Notification(notification) => {
             Message::Notification(notification) => {
                 self.notification = Some(notification);
                 self.notification = Some(notification);
-
-                Task::perform(
-                    async { tokio::time::sleep(tokio::time::Duration::from_millis(4000)).await },
-                    move |_| Message::ClearNotification
-                )
-            },
+            }
             Message::ErrorOccurred(err) => {
             Message::ErrorOccurred(err) => {
-                match err {
-                    Error::FetchError(err) => {
-                        log::error!("{err}");
-
-                        Task::perform(
-                            async {},
-                            move |_| Message::Notification(
-                                Notification::Error(format!("Failed to fetch data: {err}"))
-                            )
-                        )
-                    },
-                    Error::PaneSetError(err) => {
-                        log::error!("{err}");
-
-                        Task::perform(
-                            async {},
-                            move |_| Message::Notification(
-                                Notification::Error(format!("Failed to set pane: {err}"))
-                            )
-                        )
-                    },
-                    Error::ParseError(err) => {
-                        log::error!("{err}");
-
-                        Task::perform(
-                            async {},
-                            move |_| Message::Notification(
-                                Notification::Error(format!("Failed to parse data: {err}"))
-                            )
-                        )
-                    },
-                    Error::StreamError(err) => {
-                        log::error!("{err}");
-
-                        Task::perform(
-                            async {},
-                            move |_| Message::Notification(
-                                Notification::Error(format!("Failed to fetch stream: {err}"))
-                            )
-                        )
-                    },
-                    Error::UnknownError(err) => {
-                        log::error!("{err}");
+                return match err {
+                    InternalError::Fetch(err) => handle_error(
+                        &err, 
+                        "Failed to fetch data",
+                        Message::Notification,
+                    ),
+                };
+            }
+            Message::ThemeSelected(theme) => {
+                self.theme = theme;
+            }
+            Message::ResetCurrentLayout => {
+                let dashboard = self.get_mut_dashboard(self.last_active_layout);
 
 
-                        Task::perform(
-                            async {},
-                            move |_| Message::Notification(
-                                Notification::Error(format!("{err}"))
-                            )
-                        )
-                    },
-                }
-            },
-            Message::ClearNotification => {
-                self.notification = None;
+                let active_popout_keys = dashboard.popout.keys().copied().collect::<Vec<_>>();
 
 
-                Task::none()
-            },
-            Message::ResetCurrentLayout => {
-                let new_dashboard = Dashboard::empty();
+                let window_tasks = Task::batch(
+                    active_popout_keys
+                        .iter()
+                        .map(|&popout_id| window::close(popout_id))
+                        .collect::<Vec<_>>(),
+                )
+                .then(|_: Task<window::Id>| Task::none());
 
 
-                self.layouts.insert(self.last_active_layout, new_dashboard);
+                return window_tasks.chain(dashboard.reset_layout().map(Message::Dashboard));
+            }
+            Message::LayoutSelected(new_layout_id) => {
+                let active_popout_keys = self
+                    .get_dashboard(self.last_active_layout)
+                    .popout
+                    .keys()
+                    .copied()
+                    .collect::<Vec<_>>();
+
+                let window_tasks = Task::batch(
+                    active_popout_keys
+                        .iter()
+                        .map(|&popout_id| window::close(popout_id))
+                        .collect::<Vec<_>>(),
+                )
+                .then(|_: Task<window::Id>| Task::none());
 
 
-                Task::perform(
-                    async {},
-                    move |_| Message::Notification(
-                        Notification::Info("Layout reset".to_string())
-                    )
+                return window::collect_window_specs(
+                    active_popout_keys,
+                    dashboard::Message::SavePopoutSpecs,
                 )
                 )
-            },
-            Message::LayoutSelected(layout_id) => {
+                .map(Message::Dashboard)
+                .chain(window_tasks)
+                .chain(Task::done(Message::LoadLayout(new_layout_id)));
+            }
+            Message::LoadLayout(layout_id) => {
                 self.last_active_layout = layout_id;
                 self.last_active_layout = layout_id;
 
 
-                let dashboard = self.get_mut_dashboard();
-
-                let layout_fetch_command = dashboard.layout_changed();
-            
-                Task::batch(vec![
-                    layout_fetch_command.map(Message::Dashboard),
-                ])
-            },
+                return self
+                    .get_mut_dashboard(layout_id)
+                    .load_layout()
+                    .map(Message::Dashboard);
+            }
             Message::Dashboard(message) => {
             Message::Dashboard(message) => {
-                let dashboard = self.get_mut_dashboard();
-                
-                let command = dashboard.update(
-                    message,
+                if let Some(dashboard) = self.layouts.get_mut(&self.last_active_layout) {
+                    let command = dashboard.update(message, &self.main_window);
+
+                    return Task::batch(vec![command.map(Message::Dashboard)]);
+                }
+            }
+            Message::ToggleTickersDashboard => {
+                self.show_tickers_dashboard = !self.show_tickers_dashboard;
+            }
+            Message::UpdateTickersTable(exchange, tickers_info) => {
+                self.tickers_table.update_table(exchange, tickers_info);
+            }
+            Message::FetchAndUpdateTickersTable => {
+                let bybit_linear_fetch = fetch_ticker_prices(
+                    Exchange::BybitLinear,
+                    bybit::fetch_ticker_prices(MarketType::LinearPerps),
+                );
+                let binance_linear_fetch = fetch_ticker_prices(
+                    Exchange::BinanceFutures,
+                    binance::fetch_ticker_prices(MarketType::LinearPerps),
+                );
+                let binance_spot_fetch = fetch_ticker_prices(
+                    Exchange::BinanceSpot,
+                    binance::fetch_ticker_prices(MarketType::Spot),
+                );
+                let bybit_spot_fetch = fetch_ticker_prices(
+                    Exchange::BybitSpot,
+                    bybit::fetch_ticker_prices(MarketType::Spot),
                 );
                 );
 
 
-                Task::batch(vec![
-                    command.map(Message::Dashboard),
-                ])
-            },
+                return Task::batch(vec![
+                    bybit_linear_fetch, 
+                    binance_linear_fetch, 
+                    binance_spot_fetch,
+                    bybit_spot_fetch,
+                ]);
+            }
+            Message::TickersTable(message) => {
+                if let tickers_table::Message::TickerSelected(ticker, exchange, content) = message {
+                    let main_window_id = self.main_window.id;
+
+                    let command = self
+                        .get_mut_dashboard(self.last_active_layout)
+                        .init_pane_task(main_window_id, ticker, exchange, &content);
+
+                    return Task::batch(vec![command.map(Message::Dashboard)]);
+                } else {
+                    let command = self.tickers_table.update(message);
+
+                    return Task::batch(vec![command.map(Message::TickersTable)]);
+                }
+            }
+            Message::SetTimezone(tz) => {
+                self.layouts.values_mut().for_each(|dashboard| {
+                    dashboard.set_timezone(self.main_window.id, tz);
+                });
+            }
+            Message::SidebarPosition(pos) => {
+                self.sidebar_location = pos;
+            }
         }
         }
+        Task::none()
     }
     }
 
 
-    fn view(&self) -> Element<'_, Message> {
-        let dashboard = self.get_dashboard();
+    fn view(&self, id: window::Id) -> Element<'_, Message> {
+        let dashboard = self.get_dashboard(self.last_active_layout);
 
 
-        let layout_lock_button = button(
-            container(
-                if dashboard.layout_lock { 
-                    text(char::from(Icon::Locked).to_string()).font(ICON_FONT) 
-                } else { 
-                    text(char::from(Icon::Unlocked).to_string()).font(ICON_FONT) 
-                })
-                .width(25)
-                .center_x(iced::Pixels(20.0))
+        if id != self.main_window.id {
+            return container(
+                dashboard
+                .view_window(id, &self.main_window)
+                .map(Message::Dashboard)
             )
             )
-            .on_press(Message::ToggleLayoutLock);
+            .padding(padding::top(if cfg!(target_os = "macos") { 20 } else { 0 }))
+            .into();
+        } else {
+            let branding_logo = center(
+                text("FLOWSURFACE")
+                    .font(
+                        iced::Font {
+                            weight: iced::font::Weight::Bold,
+                            ..Default::default()
+                        }
+                    )
+                    .size(16)
+                    .style(style::branding_text)
+                    .align_x(Alignment::Center)
+                )
+            .height(20)
+            .align_y(Alignment::Center)
+            .padding(padding::right(8).top(4));
 
 
-        let layout_modal_button = button(
-            container(
-                text(char::from(Icon::Layout).to_string()).font(ICON_FONT))
-                .width(25)
-                .center_x(iced::Pixels(20.0))
-            )
-            .on_press(Message::ShowLayoutModal);
+            let tooltip_position = if self.sidebar_location == Sidebar::Left {
+                tooltip::Position::Right
+            } else {
+                tooltip::Position::Left
+            };
+            
+            let sidebar = {
+                let nav_buttons = {
+                    let layout_lock_button = {
+                        create_button(
+                            get_icon_text(
+                                if dashboard.layout_lock {
+                                    Icon::Locked
+                                } else {
+                                    Icon::Unlocked
+                                }, 
+                                14,
+                            ).width(24).align_x(Alignment::Center),
+                            Message::ToggleLayoutLock,
+                            Some("Layout Lock"),
+                            tooltip_position,
+                            |theme: &Theme, status: button::Status| 
+                                style::button_transparent(theme, status, false),
+                        )
+                    };
+                    let settings_modal_button = {
+                        let is_active = matches!(self.active_modal, DashboardModal::Settings);
+
+                        create_button(
+                            get_icon_text(Icon::Cog, 14)
+                                .width(24)
+                                .align_x(Alignment::Center),
+                            Message::ToggleModal(if is_active {
+                                DashboardModal::None
+                            } else {
+                                DashboardModal::Settings
+                            }),
+                            Some("Settings"),
+                            tooltip_position,
+                            move |theme: &Theme, status: button::Status| {
+                                style::button_transparent(theme, status, is_active)
+                            },
+                        )
+                    };
+                    let layout_modal_button = {
+                        let is_active = matches!(self.active_modal, DashboardModal::Layout);
+                
+                        create_button(
+                            get_icon_text(Icon::Layout, 14)
+                                .width(24)
+                                .align_x(Alignment::Center),
+                            Message::ToggleModal(if is_active {
+                                DashboardModal::None
+                            } else {
+                                DashboardModal::Layout
+                            }),
+                            Some("Manage Layouts"),
+                            tooltip_position,
+                            move |theme: &Theme, status: button::Status| {
+                                style::button_transparent(theme, status, is_active)
+                            },
+                        )
+                    };
+                    let ticker_search_button = {
+                        let is_active = self.show_tickers_dashboard;
+                
+                        create_button(
+                            get_icon_text(Icon::Search, 14)
+                                .width(24)
+                                .align_x(Alignment::Center),
+                            Message::ToggleTickersDashboard,
+                            Some("Search Tickers"),
+                            tooltip_position,
+                            move |theme: &Theme, status: button::Status| {
+                                style::button_transparent(theme, status, is_active)
+                            },
+                        )
+                    };
+
+                    column![
+                        ticker_search_button,
+                        layout_modal_button,
+                        layout_lock_button,
+                        Space::with_height(Length::Fill),
+                        settings_modal_button,
+                    ]
+                    .width(32)
+                    .spacing(4)
+                };
 
 
-        let layout_controls = Row::new()
-            .spacing(10)
-            .align_y(Alignment::Center)
-            .push(
-                tooltip(
-                    layout_modal_button, 
-                    "Manage Layouts", tooltip::Position::Bottom
-                ).style(style::tooltip)
-            )
-            .push(
-                tooltip(
-                    layout_lock_button, 
-                    "Layout Lock", tooltip::Position::Bottom
-                ).style(style::tooltip)
-            );
+                let tickers_table = {
+                    if self.show_tickers_dashboard {
+                        column![
+                            responsive(move |size| {
+                                self.tickers_table.view(size).map(Message::TickersTable)
+                            })
+                        ]
+                        .width(200)
+                    } else {
+                        column![]
+                    }
+                };
 
 
-        let mut ws_controls = Row::new()
-            .spacing(10)
-            .align_y(Alignment::Center);
+                match self.sidebar_location {
+                    Sidebar::Left => {
+                        row![
+                            nav_buttons,
+                            tickers_table,
+                        ]
+                    }
+                    Sidebar::Right => {
+                        row![
+                            tickers_table,
+                            nav_buttons,
+                        ]
+                    }
+                }
+                .spacing(4)
+            };
 
 
-        if let Some(notification) = &self.notification {
-            match notification {
-                Notification::Info(string) => {
-                    ws_controls = ws_controls.push(
-                        container(
-                            Column::new()
-                                .padding(4)
-                                .push(
-                                    Text::new(format!("{string}"))
-                                        .size(14)
-                                )
-                        ).style(style::notification)
+            let dashboard_view = dashboard
+                .view(&self.main_window)
+                .map(Message::Dashboard);
+
+            let content = column![
+                branding_logo,
+                match self.sidebar_location {
+                    Sidebar::Left => row![
+                        sidebar,
+                        dashboard_view,
+                    ],
+                    Sidebar::Right => row![
+                        dashboard_view,
+                        sidebar
+                    ],
+                }
+                .spacing(4)
+                .padding(8),
+            ];
+
+            match self.active_modal {
+                DashboardModal::Settings => {
+                    let mut all_themes: Vec<Theme> = Theme::ALL.to_vec();
+                    all_themes.push(Theme::Custom(style::custom_theme().into()));
+    
+                    let theme_picklist =
+                        pick_list(all_themes, Some(self.theme.clone()), Message::ThemeSelected);
+    
+                    let timezone_picklist = pick_list(
+                        [UserTimezone::Utc, UserTimezone::Local],
+                        Some(dashboard.get_timezone()),
+                        Message::SetTimezone,
                     );
                     );
-                },
-                Notification::Error(string) => {
-                    ws_controls = ws_controls.push(
-                        container(
-                            Column::new()
-                                .padding(4)
-                                .push(
-                                    Text::new(format!("err: {string}"))
-                                        .size(14)
-                                )
-                        ).style(style::notification)
+                    let sidebar_pos = pick_list(
+                        [Sidebar::Left, Sidebar::Right],
+                        Some(self.sidebar_location),
+                        Message::SidebarPosition,
                     );
                     );
-                },
-                Notification::Warn(string) => {
-                    ws_controls = ws_controls.push(
+                    let settings_modal = {
                         container(
                         container(
-                            Column::new()
-                                .padding(4)
-                                .push(
-                                    Text::new(format!("warn: {string}"))
-                                        .size(14)
-                                )
-                        ).style(style::notification)
-                    );
-                },
-            }
-        }
-
-        let content = Column::new()
-            .padding(10)
-            .spacing(10)
-            .width(Length::Fill)
-            .height(Length::Fill)
-            .push(
-                Row::new()
-                    .spacing(10)
-                    .push(ws_controls)
-                    .push(Space::with_width(Length::Fill))
-                    .push(layout_controls)
-            )
-            .push(
-                dashboard.view().map(Message::Dashboard)
-            );
-
-        if self.show_layout_modal {
-            let layout_picklist = pick_list(
-                &LayoutId::ALL[..],
-                Some(self.last_active_layout),
-                move |layout: LayoutId| Message::LayoutSelected(layout)
-            );
-
-            let mut add_pane_button = button("Split selected pane").width(iced::Pixels(200.0));
-            let mut replace_pane_button = button("Replace selected pane").width(iced::Pixels(200.0));
-
-            if dashboard.focus.is_some() {
-                replace_pane_button = replace_pane_button.on_press(
-                    Message::Dashboard(dashboard::Message::Pane(
-                        pane::Message::ReplacePane(
-                        dashboard.focus
-                            .unwrap_or_else(|| { *dashboard.panes.iter().next().unwrap().0 })
+                            column![
+                                column![
+                                    text("Sidebar").size(14),
+                                    sidebar_pos,
+                                ].spacing(4),
+                                column![text("Time zone").size(14), timezone_picklist,].spacing(4),
+                                column![text("Theme").size(14), theme_picklist,].spacing(4),
+                            ]
+                            .spacing(16),
                         )
                         )
-                    ))
-                );
-
-                add_pane_button = add_pane_button.on_press(
-                    Message::Dashboard(dashboard::Message::Pane(
-                        pane::Message::SplitPane(
-                            pane_grid::Axis::Horizontal, 
-                            dashboard.focus.unwrap_or_else(|| { *dashboard.panes.iter().next().unwrap().0 })
-                        )
-                    ))
-                );
-            }
-
-            let layout_modal = container(
-                Column::new()
-                    .spacing(16)
-                    .align_x(Alignment::Center)
-                    .push(
-                        Column::new()
-                            .align_x(Alignment::Center)
-                            .push(Text::new("Layouts"))
-                            .padding([8, 0])
-                            .spacing(8)
-                            .push(
-                                Row::new()
-                                    .push(
-                                        Row::new()
-                                        .spacing(8)
-                                        .push(
-                                            tooltip(
-                                                button(Text::new("Reset"))
-                                                .on_press(Message::ResetCurrentLayout),
-                                                "Reset current layout", 
-                                                tooltip::Position::Top
-                                            ).style(style::tooltip)
-                                        )
-                                        .push(
-                                            layout_picklist
-                                            .style(style::picklist_primary)
-                                            .menu_style(style::picklist_menu_primary)
-                                        )
-                                        .push(
-                                            tooltip(
-                                                button(Text::new("i")).style(style::button_for_info),
-                                                "Layouts won't be saved if app exited abruptly", 
-                                                tooltip::Position::Top
-                                            ).style(style::tooltip)
-                                        )                         
-                                    )
-                            )
+                        .align_x(Alignment::Start)
+                        .max_width(500)
+                        .padding(24)
+                        .style(style::dashboard_modal)
+                    };
+
+                    let (align_x, padding) = match self.sidebar_location {
+                        Sidebar::Left => (Alignment::Start, padding::left(48).top(8)),
+                        Sidebar::Right => (Alignment::End, padding::right(48).top(8)),
+                    };
+    
+                    dashboard_modal(
+                        content,
+                        settings_modal,
+                        Message::ToggleModal(DashboardModal::None),
+                        padding,
+                        Alignment::End,
+                        align_x,
                     )
                     )
-                    .push(
-                        Column::new()
+                }
+                DashboardModal::Layout => {
+                    let layout_picklist = pick_list(
+                        &layout::LayoutId::ALL[..],
+                        Some(self.last_active_layout),
+                        move |layout: layout::LayoutId| Message::LayoutSelected(layout),
+                    );
+                    let reset_layout_button = tooltip(
+                        button(text("Reset").align_x(Alignment::Center))
+                            .width(iced::Length::Fill)
+                            .on_press(Message::ResetCurrentLayout),
+                        Some("Reset current layout"),
+                        tooltip::Position::Top,
+                    );
+                    let info_text = tooltip(
+                        button(text("i")).style(move |theme, status| {
+                            style::button_transparent(theme, status, false)
+                        }),
+                        Some("Layouts won't be saved if app exited abruptly"),
+                        tooltip::Position::Top,
+                    );
+    
+                    // Pane management
+                    let reset_pane_button = tooltip(
+                        button(text("Reset").align_x(Alignment::Center))
+                            .width(iced::Length::Fill)
+                            .on_press(Message::Dashboard(dashboard::Message::Pane(
+                                id,
+                                pane::Message::ReplacePane(if let Some(focus) = dashboard.focus {
+                                    focus.1
+                                } else {
+                                    *dashboard.panes.iter().next().unwrap().0
+                                }),
+                            ))),
+                        Some("Reset selected pane"),
+                        tooltip::Position::Top,
+                    );
+                    let split_pane_button = tooltip(
+                        button(text("Split").align_x(Alignment::Center))
+                            .width(iced::Length::Fill)
+                            .on_press(Message::Dashboard(dashboard::Message::Pane(
+                                id,
+                                pane::Message::SplitPane(
+                                    pane_grid::Axis::Horizontal,
+                                    if let Some(focus) = dashboard.focus {
+                                        focus.1
+                                    } else {
+                                        *dashboard.panes.iter().next().unwrap().0
+                                    },
+                                ),
+                            ))),
+                        Some("Split selected pane horizontally"),
+                        tooltip::Position::Top,
+                    );
+                    let manage_layout_modal = {
+                        container(
+                            column![
+                                column![
+                                    text("Panes").size(14),
+                                    if dashboard.focus.is_some() {
+                                        row![reset_pane_button, split_pane_button,].spacing(8)
+                                    } else {
+                                        row![text("No pane selected"),]
+                                    },
+                                ]
+                                .align_x(Alignment::Center)
+                                .spacing(8),
+                                column![
+                                    text("Layouts").size(14),
+                                    row![info_text, layout_picklist, reset_layout_button,].spacing(8),
+                                ]
+                                .align_x(Alignment::Center)
+                                .spacing(8),
+                            ]
                             .align_x(Alignment::Center)
                             .align_x(Alignment::Center)
-                            .push(Text::new("Panes"))
-                            .padding([8, 0])
-                            .spacing(8)
-                            .push(add_pane_button)
-                            .push(replace_pane_button)
-                    )       
-                    .push(
-                        button("Close")
-                            .on_press(Message::HideLayoutModal)
+                            .spacing(32),
+                        )
+                        .width(280)
+                        .padding(24)
+                        .style(style::dashboard_modal)
+                    };
+
+                    let (align_x, padding) = match self.sidebar_location {
+                        Sidebar::Left => (Alignment::Start, padding::left(48).top(40)),
+                        Sidebar::Right => (Alignment::End, padding::right(48).top(40)),
+                    };
+    
+                    dashboard_modal(
+                        content,
+                        manage_layout_modal,
+                        Message::ToggleModal(DashboardModal::None),
+                        padding,
+                        Alignment::Start,
+                        align_x,
                     )
                     )
-            )
-            .width(Length::Shrink)
-            .padding(20)
-            .style(style::chart_modal);
+                }
+                DashboardModal::None => content.into(),
+            }
+        }
+    }
 
 
-            modal(content, layout_modal, Message::HideLayoutModal)
-        } else {
-            content 
-                .into()
-        }  
+    fn theme(&self, _window: window::Id) -> Theme {
+        self.theme.clone()
     }
     }
 
 
     fn subscription(&self) -> Subscription<Message> {
     fn subscription(&self) -> Subscription<Message> {
-        let mut all_subscriptions = Vec::new();
-    
-        for (exchange, stream) in &self.get_dashboard().pane_streams {
-            let mut depth_streams: Vec<Subscription<Message>> = Vec::new();
-            let mut kline_streams: Vec<(Ticker, Timeframe)> = Vec::new();
-    
-            for stream_types in stream.values() {
-                for stream_type in stream_types {
-                    match stream_type {
-                        StreamType::Kline { ticker, timeframe, .. } => {
+        let mut market_subscriptions: Vec<Subscription<Message>> = Vec::new();
+
+        self.get_dashboard(self.last_active_layout)
+            .pane_streams
+            .iter()
+            .for_each(|(exchange, stream)| {
+                let mut depth_streams: Vec<Subscription<Message>> = Vec::new();
+                let mut kline_streams: Vec<(Ticker, Timeframe)> = Vec::new();
+
+                let exchange: Exchange = *exchange;
+
+                stream
+                    .values()
+                    .flat_map(|stream_types| stream_types.iter())
+                    .for_each(|stream_type| match stream_type {
+                        StreamType::Kline {
+                            ticker, timeframe, ..
+                        } => {
                             kline_streams.push((*ticker, *timeframe));
                             kline_streams.push((*ticker, *timeframe));
-                        },
+                        }
                         StreamType::DepthAndTrades { ticker, .. } => {
                         StreamType::DepthAndTrades { ticker, .. } => {
-                            let ticker = *ticker;
+                            let ticker: Ticker = *ticker;
 
 
                             let depth_stream = match exchange {
                             let depth_stream = match exchange {
-                                Exchange::BinanceFutures => {
-                                    Subscription::run_with_id(ticker, binance::market_data::connect_market_stream(ticker))
-                                        .map(|event| Message::MarketWsEvent(MarketEvents::Binance(event)))
-                                },
-                                Exchange::BybitLinear => {
-                                    Subscription::run_with_id(ticker, bybit::market_data::connect_market_stream(ticker))
-                                        .map(|event| Message::MarketWsEvent(MarketEvents::Bybit(event)))
-                                },
+                                Exchange::BinanceFutures => Subscription::run_with_id(
+                                    ticker,
+                                    binance::connect_market_stream(ticker),
+                                )
+                                .map(move |event| Message::MarketWsEvent(exchange, event)),
+                                Exchange::BybitLinear => Subscription::run_with_id(
+                                    ticker,
+                                    bybit::connect_market_stream(ticker),
+                                )
+                                .map(move |event| Message::MarketWsEvent(exchange, event)),
+                                Exchange::BinanceSpot => Subscription::run_with_id(
+                                    ticker,
+                                    binance::connect_market_stream(ticker),
+                                )
+                                .map(move |event| Message::MarketWsEvent(exchange, event)),
+                                Exchange::BybitSpot => Subscription::run_with_id(
+                                    ticker,
+                                    bybit::connect_market_stream(ticker),
+                                )
+                                .map(move |event| Message::MarketWsEvent(exchange, event)),
                             };
                             };
                             depth_streams.push(depth_stream);
                             depth_streams.push(depth_stream);
-                        },
-                        _ => {}
-                    }
-                }
-            }
-    
-            if !kline_streams.is_empty() {
-                let kline_streams_id = kline_streams.clone();
-
-                let kline_subscription = match exchange {
-                    Exchange::BinanceFutures => {
-                        Subscription::run_with_id(kline_streams_id, binance::market_data::connect_kline_stream(kline_streams))
-                            .map(|event| Message::MarketWsEvent(MarketEvents::Binance(event)))
-                    },
-                    Exchange::BybitLinear => {
-                        Subscription::run_with_id(kline_streams_id, bybit::market_data::connect_kline_stream(kline_streams))
-                            .map(|event| Message::MarketWsEvent(MarketEvents::Bybit(event)))
-                    },
-                };
-                all_subscriptions.push(kline_subscription);
-            }
-    
-            if !depth_streams.is_empty() {
-                all_subscriptions.push(Subscription::batch(depth_streams));
-            }
-        }
-
-        all_subscriptions.push(events().map(Message::Event));
-    
-        Subscription::batch(all_subscriptions)
-    }    
-    
-    fn get_mut_dashboard(&mut self) -> &mut Dashboard {
-        self.layouts
-            .get_mut(&self.last_active_layout)
-            .expect("No active layout")
-    }
-
-    fn get_dashboard(&self) -> &Dashboard {
-        self.layouts
-            .get(&self.last_active_layout)
-            .expect("No active layout")
-    }
-
-    fn update_exchange_latency(&mut self) {
-        let mut depth_latency_sum: i64 = 0;
-        let mut depth_latency_count: i64 = 0;
-        let mut trade_latency_sum: i64 = 0;
-        let mut trade_latency_count: i64 = 0;
-
-        for feed_latency in self.feed_latency_cache.iter() {
-            depth_latency_sum += feed_latency.depth_latency;
-            depth_latency_count += 1;
-
-            if let Some(trade_latency) = feed_latency.trade_latency {
-                trade_latency_sum += trade_latency;
-                trade_latency_count += 1;
-            }
-        }
-
-        let average_depth_latency: Option<i64> = if depth_latency_count > 0 {
-            Some(depth_latency_sum / depth_latency_count)
-        } else {
-            None
-        };
-
-        let average_trade_latency: Option<i64> = if trade_latency_count > 0 {
-            Some(trade_latency_sum / trade_latency_count)
-        } else {
-            None
-        };
+                        }
+                        StreamType::None => {}
+                    });
 
 
-        if let (Some(average_depth_latency), Some(average_trade_latency)) = (average_depth_latency, average_trade_latency) {
-            self.exchange_latency = Some((average_depth_latency as u32, average_trade_latency as u32));
-        }
+                if !kline_streams.is_empty() {
+                    let kline_streams_id: Vec<(Ticker, Timeframe)> = kline_streams.clone();
 
 
-        while self.feed_latency_cache.len() > 100 {
-            self.feed_latency_cache.pop_front();
-        }
-    }
-}
+                    let kline_subscription = match exchange {
+                        Exchange::BinanceFutures => Subscription::run_with_id(
+                            kline_streams_id,
+                            binance::connect_kline_stream(kline_streams, MarketType::LinearPerps),
+                        )
+                        .map(move |event| Message::MarketWsEvent(exchange, event)),
+                        Exchange::BybitLinear => Subscription::run_with_id(
+                            kline_streams_id,
+                            bybit::connect_kline_stream(kline_streams, MarketType::LinearPerps),
+                        )
+                        .map(move |event| Message::MarketWsEvent(exchange, event)),
+                        Exchange::BinanceSpot => Subscription::run_with_id(
+                            kline_streams_id,
+                            binance::connect_kline_stream(kline_streams, MarketType::Spot),
+                        )
+                        .map(move |event| Message::MarketWsEvent(exchange, event)),
+                        Exchange::BybitSpot => Subscription::run_with_id(
+                            kline_streams_id,
+                            bybit::connect_kline_stream(kline_streams, MarketType::Spot),
+                        )
+                        .map(move |event| Message::MarketWsEvent(exchange, event)),
+                    };
+                    market_subscriptions.push(kline_subscription);
+                }
 
 
-fn modal<'a, Message>(
-    base: impl Into<Element<'a, Message>>,
-    content: impl Into<Element<'a, Message>>,
-    on_blur: Message,
-) -> Element<'a, Message>
-where
-    Message: Clone + 'a,
-{
-    stack![
-        base.into(),
-        mouse_area(center(opaque(content)).style(|_theme| {
-            container::Style {
-                background: Some(
-                    Color {
-                        a: 0.8,
-                        ..Color::BLACK
-                    }
-                    .into(),
-                ),
-                ..container::Style::default()
-            }
-        }))
-        .on_press(on_blur)
-    ]
-    .into()
-}
+                if !depth_streams.is_empty() {
+                    market_subscriptions.push(Subscription::batch(depth_streams));
+                }
+            });
 
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum Event {
-    CloseRequested(window::Id),
-    Copy,
-    Escape,
-    Home,
-    End,
-}
+        let tickers_table_fetch = iced::time::every(std::time::Duration::from_secs(
+            if self.show_tickers_dashboard { 25 } else { 300 },
+        ))
+        .map(|_| Message::FetchAndUpdateTickersTable);
 
 
-pub fn events() -> Subscription<Event> {
-    iced::event::listen_with(filtered_events)
-}
+        let window_events = window_events().map(Message::WindowEvent);
 
 
-fn filtered_events(
-    event: iced::Event,
-    _status: iced::event::Status,
-    window: window::Id,
-) -> Option<Event> {
-    match &event {
-        iced::Event::Window(window::Event::CloseRequested) => Some(Event::CloseRequested(window)),
-        _ => None,
+        Subscription::batch(vec![
+            Subscription::batch(market_subscriptions),
+            tickers_table_fetch,
+            window_events,
+        ])
     }
     }
-}
 
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
-pub enum LayoutId {
-    Layout1,
-    Layout2,
-    Layout3,
-    Layout4,
-}
-impl std::fmt::Display for LayoutId {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            LayoutId::Layout1 => write!(f, "Layout 1"),
-            LayoutId::Layout2 => write!(f, "Layout 2"),
-            LayoutId::Layout3 => write!(f, "Layout 3"),
-            LayoutId::Layout4 => write!(f, "Layout 4"),
-        }
+    fn get_mut_dashboard(&mut self, layout_id: layout::LayoutId) -> &mut Dashboard {
+        self.layouts.get_mut(&layout_id).expect("No active layout")
     }
     }
-}
-impl LayoutId {
-    const ALL: [LayoutId; 4] = [LayoutId::Layout1, LayoutId::Layout2, LayoutId::Layout3, LayoutId::Layout4];
-}
 
 
-struct SavedState {
-    layouts: HashMap<LayoutId, Dashboard>,
-    last_active_layout: LayoutId,
-    window_size: Option<(f32, f32)>,
-    window_position: Option<(f32, f32)>,
-}
-impl Default for SavedState {
-    fn default() -> Self {
-        let mut layouts = HashMap::new();
-        layouts.insert(LayoutId::Layout1, Dashboard::default());
-        layouts.insert(LayoutId::Layout2, Dashboard::default());
-        layouts.insert(LayoutId::Layout3, Dashboard::default());
-        layouts.insert(LayoutId::Layout4, Dashboard::default());
-        
-        SavedState {
-            layouts,
-            last_active_layout: LayoutId::Layout1,
-            window_size: None,
-            window_position: None,
-        }
+    fn get_dashboard(&self, layout_id: layout::LayoutId) -> &Dashboard {
+        self.layouts.get(&layout_id).expect("No active layout")
     }
     }
 }
 }
 
 
-use std::fs::File;
-use std::io::{Read, Write};
-use std::path::Path;
-use serde::{Deserialize, Serialize};
-
-fn write_json_to_file(json: &str, file_path: &str) -> std::io::Result<()> {
-    let path = Path::new(file_path);
-    let mut file = File::create(path)?;
-    file.write_all(json.as_bytes())?;
-    Ok(())
-}
-
-fn read_layout_from_file(file_path: &str) -> Result<SerializableState, Box<dyn std::error::Error>> {
-    let path = Path::new(file_path);
-    let mut file = File::open(path)?;
-    let mut contents = String::new();
-    file.read_to_string(&mut contents)?;
-   
-    Ok(serde_json::from_str(&contents)?)
+fn fetch_ticker_info<F>(exchange: Exchange, fetch_fn: F) -> Task<Message>
+where
+    F: Future<
+            Output = Result<
+                HashMap<Ticker, Option<data_providers::TickerInfo>>,
+                data_providers::StreamError,
+            >,
+        > + MaybeSend
+        + 'static,
+{
+    Task::perform(
+        fetch_fn.map_err(|err| format!("{err}")),
+        move |ticksize| match ticksize {
+            Ok(ticksize) => Message::SetTickersInfo(exchange, ticksize),
+            Err(err) => Message::ErrorOccurred(InternalError::Fetch(err)),
+        },
+    )
 }
 }
 
 
-#[derive(Debug, Clone, Deserialize, Serialize)]
-struct SerializableState {
-    pub layouts: HashMap<LayoutId, SerializableDashboard>,
-    pub last_active_layout: LayoutId,
-    pub window_size: Option<(f32, f32)>,
-    pub window_position: Option<(f32, f32)>,
-}
-impl SerializableState {
-    fn from_parts(
-        layouts: HashMap<LayoutId, SerializableDashboard>,
-        last_active_layout: LayoutId,
-        size: Option<Size>,
-        position: Option<Point>,
-    ) -> Self {
-        SerializableState {
-            layouts,
-            last_active_layout,
-            window_size: size.map(|s| (s.width, s.height)),
-            window_position: position.map(|p| (p.x, p.y)),
-        }
-    }
+fn fetch_ticker_prices<F>(exchange: Exchange, fetch_fn: F) -> Task<Message>
+where
+    F: Future<Output = Result<HashMap<Ticker, TickerStats>, data_providers::StreamError>>
+        + MaybeSend
+        + 'static,
+{
+    Task::perform(
+        fetch_fn.map_err(|err| format!("{err}")),
+        move |tickers_table| match tickers_table {
+            Ok(tickers_table) => Message::UpdateTickersTable(exchange, tickers_table),
+            Err(err) => Message::ErrorOccurred(InternalError::Fetch(err)),
+        },
+    )
 }
 }

+ 265 - 9
src/screen.rs

@@ -1,17 +1,273 @@
+use std::{collections::HashMap, fmt};
+
+use iced::{
+    widget::{button, column, container, pane_grid, text, Column}, window, Alignment, Element, Task, Theme
+};
+use iced_futures::MaybeSend;
+use serde::{Deserialize, Serialize};
+
+use crate::{style, tooltip};
+
 pub mod dashboard;
 pub mod dashboard;
+pub mod modal;
+
+pub fn create_button<'a, M: Clone + 'a>(
+    content: impl Into<Element<'a, M>>,
+    message: M,
+    tooltip_text: Option<&'a str>,
+    tooltip_pos: tooltip::Position,
+    style_fn: impl Fn(&Theme, button::Status) -> button::Style + 'static,
+) -> Element<'a, M> {
+    let btn = button(content)
+        .style(style_fn)
+        .on_press(message);
+        
+    if let Some(text) = tooltip_text {
+        tooltip(btn, Some(text), tooltip_pos).into()
+    } else {
+        btn.into()
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Default)]
+pub enum UserTimezone {
+    #[default]
+    Utc,
+    Local,
+}
+
+impl fmt::Display for UserTimezone {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            UserTimezone::Utc => write!(f, "UTC"),
+            UserTimezone::Local => {
+                let local_offset = chrono::Local::now().offset().local_minus_utc();
+                let hours = local_offset / 3600;
+                let minutes = (local_offset % 3600) / 60;
+                write!(f, "Local (UTC {hours:+03}:{minutes:02})")
+            }
+        }
+    }
+}
+
+impl<'de> Deserialize<'de> for UserTimezone {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let timezone_str = String::deserialize(deserializer)?;
+        match timezone_str.to_lowercase().as_str() {
+            "utc" => Ok(UserTimezone::Utc),
+            "local" => Ok(UserTimezone::Local),
+            _ => Err(serde::de::Error::custom("Invalid UserTimezone")),
+        }
+    }
+}
+
+impl Serialize for UserTimezone {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        match self {
+            UserTimezone::Utc => serializer.serialize_str("UTC"),
+            UserTimezone::Local => serializer.serialize_str("Local"),
+        }
+    }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub enum InfoType {
+    FetchingKlines,
+}
 
 
 #[derive(Debug, Clone)]
 #[derive(Debug, Clone)]
 pub enum Notification {
 pub enum Notification {
     Error(String),
     Error(String),
-    Info(String),
+    Info(InfoType),
     Warn(String),
     Warn(String),
 }
 }
 
 
-#[derive(Debug, Clone)]
-pub enum Error {
-    FetchError(String),
-    ParseError(String),
-    PaneSetError(String),
-    StreamError(String),
-    UnknownError(String),
-}
+pub fn handle_error<M, F>(err: &str, report: &str, message: F) -> Task<M> 
+where
+    F: Fn(Notification) -> M + Send + 'static,
+    M: MaybeSend + 'static,
+{
+    log::error!("{err}: {report}");
+
+    Task::done(message(
+        Notification::Error(report.to_string())
+    ))
+}
+
+#[derive(Default)]
+pub struct NotificationManager {
+    notifications: HashMap<window::Id, HashMap<pane_grid::Pane, Vec<Notification>>>,
+    global_notifications: Vec<Notification>,
+}
+
+#[allow(dead_code)]
+impl NotificationManager {
+    pub fn new() -> Self {
+        Self {
+            notifications: HashMap::new(),
+            global_notifications: Vec::new(),
+        }
+    }
+
+    /// Helper method to get or create window entry
+    fn get_or_create_window(
+        &mut self,
+        window: window::Id,
+    ) -> &mut HashMap<pane_grid::Pane, Vec<Notification>> {
+        self.notifications.entry(window).or_default()
+    }
+
+    /// Helper method to get or create notification list
+    fn get_or_create_notifications(
+        &mut self,
+        window: window::Id,
+        pane: pane_grid::Pane,
+    ) -> &mut Vec<Notification> {
+        let window_map = self.get_or_create_window(window);
+        window_map.entry(pane).or_default()
+    }
+
+    /// Add a notification for a specific pane in a window
+    pub fn push(&mut self, window: window::Id, pane: pane_grid::Pane, notification: Notification) {
+        self.get_or_create_notifications(window, pane)
+            .push(notification);
+    }
+
+    /// Remove notifications of a specific type for a pane in a window
+    pub fn remove_info_type(
+        &mut self,
+        window: window::Id,
+        pane: &pane_grid::Pane,
+        info_type: &InfoType,
+    ) {
+        if let Some(window_map) = self.notifications.get_mut(&window) {
+            if let Some(notification_list) = window_map.get_mut(pane) {
+                notification_list.retain(|notification| {
+                    !matches!(notification,
+                        Notification::Info(current_type)
+                        if std::mem::discriminant(current_type) == std::mem::discriminant(info_type)
+                    )
+                });
+            }
+        }
+    }
+
+    /// Get notifications for a specific pane in a window
+    pub fn get(&self, window: &window::Id, pane: &pane_grid::Pane) -> Option<&Vec<Notification>> {
+        self.notifications
+            .get(window)
+            .and_then(|window_map| window_map.get(pane))
+    }
+
+    /// Get mutable notifications for a specific pane in a window
+    pub fn get_mut(
+        &mut self,
+        window: &window::Id,
+        pane: &pane_grid::Pane,
+    ) -> Option<&mut Vec<Notification>> {
+        self.notifications
+            .get_mut(window)
+            .and_then(|window_map| window_map.get_mut(pane))
+    }
+
+    /// Handle error notifications with special fetch error logic
+    pub fn handle_error(&mut self, window: window::Id, pane: pane_grid::Pane, err: DashboardError) {
+        log::error!("{:?}", err);
+
+        let notification_list = self.get_or_create_notifications(window, pane);
+        notification_list.push(Notification::Error(err.to_string()));
+
+        // If it's a fetch error, remove any pending fetch notifications
+        if matches!(err, DashboardError::Fetch(_)) {
+            notification_list.retain(|notification| {
+                !matches!(
+                    notification,
+                    Notification::Info(InfoType::FetchingKlines)
+                )
+            });
+        }
+    }
+
+    /// Remove the last notification for a specific pane in a window
+    pub fn remove_last(&mut self, window: &window::Id, pane: &pane_grid::Pane) {
+        if let Some(window_map) = self.notifications.get_mut(window) {
+            if let Some(notification_list) = window_map.get_mut(pane) {
+                notification_list.pop();
+            }
+        }
+    }
+
+    /// Clear all notifications for a specific pane in a window
+    pub fn clear(&mut self, window: &window::Id, pane: &pane_grid::Pane) {
+        if let Some(window_map) = self.notifications.get_mut(window) {
+            if let Some(notification_list) = window_map.get_mut(pane) {
+                notification_list.clear();
+            }
+        }
+    }
+
+    /// Clear all notifications for a window
+    pub fn clear_window(&mut self, window: &window::Id) {
+        self.notifications.remove(window);
+    }
+
+    /// Check if notifications exist for a specific pane in a window
+    pub fn has_notification(&self, window: &window::Id, pane: &pane_grid::Pane) -> bool {
+        self.notifications
+            .get(window)
+            .and_then(|window_map| window_map.get(pane))
+            .map_or(false, |notifications| !notifications.is_empty())
+    }
+
+    /// Get all notifications for a window
+    pub fn get_window_notifications(
+        &self,
+        window: &window::Id,
+    ) -> Option<&HashMap<pane_grid::Pane, Vec<Notification>>> {
+        self.notifications.get(window)
+    }
+}
+
+fn create_notis_column<'a, M: 'a>(notifications: &'a [Notification]) -> Column<'a, M> {
+    let mut notifications_column = column![].align_x(Alignment::End).spacing(6);
+
+    for (index, notification) in notifications.iter().rev().take(5).enumerate() {
+        let notification_str = match notification {
+            Notification::Error(error) => error.to_string(),
+            Notification::Warn(warn) => warn.to_string(),
+            Notification::Info(info) => match info {
+                InfoType::FetchingKlines => "Fetching klines...".to_string(),
+            },
+        };
+
+        let color_alpha = 1.0 - (index as f32 * 0.25);
+
+        notifications_column =
+            notifications_column.push(container(text(notification_str)).padding(12).style(
+                move |theme| match notification {
+                    Notification::Error(_) => style::pane_err_notification(theme, color_alpha),
+                    Notification::Warn(_) | Notification::Info(_) => {
+                        style::pane_info_notification(theme, color_alpha)
+                    }
+                },
+            ));
+    }
+
+    notifications_column
+}
+
+#[derive(thiserror::Error, Debug, Clone)]
+pub enum DashboardError {
+    #[error("Fetch error: {0}")]
+    Fetch(String),
+    #[error("Pane set error: {0}")]
+    PaneSet(String),
+    #[error("Unknown error: {0}")]
+    Unknown(String),
+}

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 1104 - 666
src/screen/dashboard.rs


+ 814 - 460
src/screen/dashboard/pane.rs

@@ -1,17 +1,39 @@
-use std::fmt;
-
-use iced::{alignment, widget::{button, container, pane_grid, pick_list, row, scrollable, text, tooltip, Column, Container, Row, Slider, Text}, Alignment, Element, Length, Renderer, Theme};
+use iced::{
+    alignment::{Horizontal, Vertical}, padding, widget::{
+        button, center, column, container, pane_grid, row, scrollable, text, tooltip, Container, Slider
+    }, Alignment, Element, Length, Renderer, Task, Theme
+};
 use serde::{Deserialize, Serialize};
 use serde::{Deserialize, Serialize};
-pub use uuid::Uuid;
 
 
 use crate::{
 use crate::{
     charts::{
     charts::{
-        self, candlestick::CandlestickChart, footprint::FootprintChart, heatmap::HeatmapChart, timeandsales::TimeAndSales
-    }, data_providers::{
-        Exchange, TickMultiplier, Ticker, Timeframe
-    }, modal, style::{self, Icon, ICON_FONT}, StreamType
+        self, candlestick::CandlestickChart, footprint::FootprintChart, heatmap::HeatmapChart, 
+        indicators::{CandlestickIndicator, FootprintIndicator, HeatmapIndicator, Indicator}, 
+        timeandsales::TimeAndSales
+    },
+    data_providers::{format_with_commas, Exchange, Kline, MarketType, OpenInterest, TickMultiplier, Ticker, TickerInfo, Timeframe},
+    screen::{
+        self, create_button, create_notis_column, modal::{pane_menu, pane_notification}, DashboardError, UserTimezone
+    },
+    style::{self, get_icon_text, Icon},
+    window::{self, Window},
+    StreamType,
 };
 };
 
 
+#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq)]
+pub enum PaneModal {
+    StreamModifier,
+    Settings,
+    Indicators,
+    None,
+}
+
+#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
+pub enum Axis {
+    Horizontal,
+    Vertical,
+}
+
 #[derive(Debug, Clone)]
 #[derive(Debug, Clone)]
 pub enum Message {
 pub enum Message {
     PaneClicked(pane_grid::Pane),
     PaneClicked(pane_grid::Pane),
@@ -21,134 +43,373 @@ pub enum Message {
     SplitPane(pane_grid::Axis, pane_grid::Pane),
     SplitPane(pane_grid::Axis, pane_grid::Pane),
     MaximizePane(pane_grid::Pane),
     MaximizePane(pane_grid::Pane),
     Restore,
     Restore,
-    TicksizeSelected(TickMultiplier, Uuid),
-    TimeframeSelected(Timeframe, Uuid),
-    TickerSelected(Ticker, Uuid),
-    ExchangeSelected(Exchange, Uuid),
-    ShowModal(pane_grid::Pane),
-    HideModal(Uuid),
-    PaneContentSelected(String, Uuid, Vec<StreamType>),
+    TicksizeSelected(TickMultiplier, pane_grid::Pane),
+    TimeframeSelected(Timeframe, pane_grid::Pane),
+    ToggleModal(pane_grid::Pane, PaneModal),
+    InitPaneContent(window::Id, String, Option<pane_grid::Pane>, Vec<StreamType>),
     ReplacePane(pane_grid::Pane),
     ReplacePane(pane_grid::Pane),
-    ChartUserUpdate(charts::Message, Uuid),
-    SliderChanged(Uuid, f32),
-    SetMinTickSize(Uuid, f32),
+    ChartUserUpdate(pane_grid::Pane, charts::Message),
+    SliderChanged(pane_grid::Pane, f32, bool),
+    ToggleIndicator(pane_grid::Pane, String),
+    Popout,
+    Merge,
 }
 }
 
 
-#[derive(Debug)]
 pub struct PaneState {
 pub struct PaneState {
-    pub id: Uuid,
-    pub show_modal: bool,
+    pub modal: PaneModal,
     pub stream: Vec<StreamType>,
     pub stream: Vec<StreamType>,
     pub content: PaneContent,
     pub content: PaneContent,
     pub settings: PaneSettings,
     pub settings: PaneSettings,
 }
 }
 
 
 impl PaneState {
 impl PaneState {
-    pub fn new(id: Uuid, stream: Vec<StreamType>, settings: PaneSettings) -> Self {
+    pub fn new(stream: Vec<StreamType>, settings: PaneSettings) -> Self {
         Self {
         Self {
-            id,
-            show_modal: false,
+            modal: PaneModal::None,
             stream,
             stream,
             content: PaneContent::Starter,
             content: PaneContent::Starter,
             settings,
             settings,
         }
         }
     }
     }
 
 
-    pub fn from_config(content: PaneContent, stream: Vec<StreamType>, settings: PaneSettings) -> Self {
+    pub fn from_config(
+        content: PaneContent,
+        stream: Vec<StreamType>,
+        settings: PaneSettings,
+    ) -> Self {
         Self {
         Self {
-            id: Uuid::new_v4(),
-            show_modal: false,
+            modal: PaneModal::None,
             stream,
             stream,
             content,
             content,
             settings,
             settings,
         }
         }
     }
     }
 
 
-    pub fn view<'a>(
-        &'a self,
-        id: pane_grid::Pane,
-        panes: usize,
-        is_focused: bool,
-        maximized: bool,
-    ) -> iced::widget::pane_grid::Content<'a, Message, Theme, Renderer> {
-        let stream_info = self.stream.iter().find_map(|stream: &StreamType| {
+    /// sets the tick size. returns the tick size with the multiplier applied
+    pub fn set_tick_size(&mut self, multiplier: TickMultiplier, min_tick_size: f32) -> f32 {
+        self.settings.tick_multiply = Some(multiplier);
+        self.settings.min_tick_size = Some(min_tick_size);
+
+        multiplier.multiply_with_min_tick_size(min_tick_size)
+    }
+
+    /// gets the timeframe if exists, otherwise sets timeframe w given
+    pub fn set_timeframe(&mut self, timeframe: Timeframe) -> Timeframe {
+        if self.settings.selected_timeframe.is_none() {
+            self.settings.selected_timeframe = Some(timeframe);
+        }
+
+        timeframe
+    }
+
+    pub fn get_ticker_exchange(&self) -> Option<(Exchange, Ticker)> {
+        for stream in &self.stream {
             match stream {
             match stream {
-                StreamType::Kline { exchange, ticker, timeframe } => {
-                    Some(
-                        Some((exchange, format!("{} {}", ticker, timeframe)))
-                    )
+                StreamType::DepthAndTrades { exchange, ticker } => {
+                    return Some((*exchange, *ticker));
                 }
                 }
-                _ => None,
-            }
-        }).or_else(|| {
-            self.stream.iter().find_map(|stream: &StreamType| {
-                match stream {
-                    StreamType::DepthAndTrades { exchange, ticker } => {
-                        Some(
-                            Some((exchange, ticker.to_string()))
-                        )
-                    }
-                    _ => None,
+                StreamType::Kline {
+                    exchange, ticker, ..
+                } => {
+                    return Some((*exchange, *ticker));
                 }
                 }
-            })
-        }).unwrap_or(None);
+                _ => {}
+            }
+        }
+        None
+    }
 
 
-        let mut stream_info_element: Row<Message> = Row::new();
+    pub fn init_content_task(
+        &mut self,
+        content: &str,
+        exchange: Exchange,
+        ticker: Ticker,
+        pane: pane_grid::Pane,
+        window: window::Id,
+    ) -> Task<Message> {
+        let streams = match content {
+            "heatmap" | "time&sales" => {
+                vec![StreamType::DepthAndTrades { exchange, ticker }]
+            }
+            "footprint" => {
+                let timeframe = self
+                    .settings
+                    .selected_timeframe
+                    .unwrap_or(Timeframe::M15);
+
+                vec![
+                    StreamType::DepthAndTrades { exchange, ticker },
+                    StreamType::Kline {
+                        exchange,
+                        ticker,
+                        timeframe,
+                    },
+                ]
+            }
+            "candlestick" => {
+                let timeframe = self
+                    .settings
+                    .selected_timeframe
+                    .unwrap_or(Timeframe::M5);
+
+                vec![StreamType::Kline {
+                    exchange,
+                    ticker,
+                    timeframe,
+                }]
+            }
+            _ => vec![],
+        };
 
 
-        if let Some((exchange, info)) = stream_info {
-            stream_info_element = Row::new()
-                .spacing(3)
-                .push(
-                    match exchange {
-                        Exchange::BinanceFutures => text(char::from(Icon::BinanceLogo).to_string()).font(ICON_FONT),
-                        Exchange::BybitLinear => text(char::from(Icon::BybitLogo).to_string()).font(ICON_FONT),
-                    }
+        self.stream = streams.clone();
+
+        Task::done(Message::InitPaneContent(
+            window,
+            content.to_string(),
+            Some(pane),
+            streams,
+        ))
+    }
+
+    pub fn set_content(
+        &mut self, 
+        ticker_info: TickerInfo, 
+        content_str: &str, 
+        timezone: UserTimezone
+    ) -> Result<(), DashboardError> {
+        self.content = match content_str {
+            "heatmap" => {
+                let tick_size = self.set_tick_size(
+                    TickMultiplier(10),
+                    ticker_info.tick_size,
+                );
+
+                PaneContent::Heatmap(
+                    HeatmapChart::new(
+                        tick_size,
+                        100,
+                        timezone,
+                    ),
+                    vec![],
                 )
                 )
-                .push(Text::new(info));
-        }
-        
-        let mut content: pane_grid::Content<'_, Message, _, Renderer> = 
-            pane_grid::Content::new({
-                match self.content {
-                    PaneContent::Starter => view_starter(&self.id, &self.settings),
+            }
+            "footprint" => {
+                let tick_size = self.set_tick_size(
+                    TickMultiplier(50),
+                    ticker_info.tick_size,
+                );
+                let timeframe = self.set_timeframe(Timeframe::M15);
+                PaneContent::Footprint(
+                    FootprintChart::new(
+                        timeframe,
+                        tick_size,
+                        vec![],
+                        vec![],
+                        timezone,
+                    ),
+                    vec![
+                        FootprintIndicator::Volume,
+                        FootprintIndicator::OpenInterest,
+                    ],
+                )
+            }
+            "candlestick" => {
+                let tick_size = self.set_tick_size(
+                    TickMultiplier(1),
+                    ticker_info.tick_size,
+                );
+                let timeframe = self.set_timeframe(Timeframe::M5);
+                PaneContent::Candlestick(
+                    CandlestickChart::new(
+                        vec![],
+                        timeframe,
+                        tick_size,
+                        timezone,
+                    ),
+                    vec![
+                        CandlestickIndicator::Volume,
+                        CandlestickIndicator::OpenInterest,
+                    ],
+                )
+            }
+            "time&sales" => PaneContent::TimeAndSales(TimeAndSales::new()),
+            _ => {
+                log::error!("content not found: {}", content_str);
+                return Err(DashboardError::PaneSet("content not found: ".to_string() + content_str));
+            }
+        };
 
 
-                    PaneContent::Heatmap(ref chart) => view_chart(self, chart),
+        Ok(())
+    }
 
 
-                    PaneContent::Footprint(ref chart) => view_chart(self, chart),
+    pub fn insert_oi_vec(&mut self, req_id: Option<uuid::Uuid>, oi: Vec<OpenInterest>) {
+        match &mut self.content {
+            PaneContent::Candlestick(chart, _) => {
+                chart.insert_open_interest(req_id, oi);
+            }
+            PaneContent::Footprint(chart, _) => {
+                chart.insert_open_interest(req_id, oi);
+            }
+            _ => {
+                log::error!("pane content not candlestick");
+            }
+        }
+    }
 
 
-                    PaneContent::Candlestick(ref chart) => view_chart(self, chart),
+    pub fn insert_klines_vec(
+        &mut self,
+        req_id: Option<uuid::Uuid>,
+        timeframe: Timeframe,
+        klines: &Vec<Kline>,
+        timezone: UserTimezone,
+    ) {
+        match &mut self.content {
+            PaneContent::Candlestick(chart, _) => {
+                if let Some(id) = req_id {
+                    chart.insert_new_klines(id, klines);
+                } else {
+                    let tick_size = chart.get_tick_size();
 
 
-                    PaneContent::TimeAndSales(ref chart) => view_chart(self, chart),
+                    *chart = CandlestickChart::new(klines.clone(), timeframe, tick_size, timezone);
                 }
                 }
-            })
-            .style(
-                if is_focused {
-                    style::pane_focused
+            }
+            PaneContent::Footprint(chart, _) => {
+                if let Some(id) = req_id {
+                    chart.insert_new_klines(id, klines);
                 } else {
                 } else {
-                    style::pane_active
+                    let (raw_trades, tick_size) = (chart.get_raw_trades(), chart.get_tick_size());
+
+                    *chart = FootprintChart::new(
+                        timeframe,
+                        tick_size,
+                        klines.clone(),
+                        raw_trades,
+                        timezone,
+                    );
                 }
                 }
+            }
+            _ => {
+                log::error!("pane content not candlestick or footprint");
+            }
+        }
+    }
+
+    pub fn view<'a>(
+        &'a self,
+        id: pane_grid::Pane,
+        panes: usize,
+        is_focused: bool,
+        maximized: bool,
+        window: window::Id,
+        main_window: &'a Window,
+        notifications: Option<&'a Vec<screen::Notification>>,
+    ) -> pane_grid::Content<'a, Message, Theme, Renderer> {
+        let mut stream_info_element = row![]
+            .padding(padding::left(8))
+            .align_y(Vertical::Center)
+            .spacing(8)
+            .height(Length::Fixed(32.0));
+
+        if let Some((exchange, ticker)) = self.get_ticker_exchange() {
+            let (ticker_str, market) = ticker.get_string();
+
+            stream_info_element = stream_info_element.push(
+                row![
+                    match exchange {
+                        Exchange::BinanceFutures | Exchange::BinanceSpot => get_icon_text(Icon::BinanceLogo, 14),
+                        Exchange::BybitLinear | Exchange::BybitSpot => get_icon_text(Icon::BybitLogo, 14),
+                    },
+                    text({
+                        if market == MarketType::LinearPerps {
+                            ticker_str + " PERP"
+                        } else {
+                            ticker_str
+                        }
+                    }).size(14),
+                ]
+                .spacing(4),
             );
             );
+        }
+
+        let mut is_chart = false;
+        let is_stream_modifier = self.modal == PaneModal::StreamModifier;
+
+        match self.content {
+            PaneContent::Heatmap(_, _) => {
+                stream_info_element = stream_info_element.push(
+                    button(text(
+                        self.settings
+                            .tick_multiply
+                            .unwrap_or(TickMultiplier(1))
+                            .to_string(),
+                    ))
+                    .style(move |theme, status| {
+                        style::button_modifier(theme, status, !is_stream_modifier)
+                    })
+                    .on_press(Message::ToggleModal(id, PaneModal::StreamModifier)),
+                );
+
+                is_chart = true;
+            }
+            PaneContent::Footprint(_, _) => {
+                stream_info_element = stream_info_element.push(
+                    button(text(format!(
+                        "{} - {}",
+                        self.settings.selected_timeframe.unwrap_or(Timeframe::M1),
+                        self.settings.tick_multiply.unwrap_or(TickMultiplier(1)),
+                    )))
+                    .style(move |theme, status| {
+                        style::button_modifier(theme, status, !is_stream_modifier)
+                    })
+                    .on_press(Message::ToggleModal(id, PaneModal::StreamModifier)),
+                );
+
+                is_chart = true;
+            }
+            PaneContent::Candlestick(_, _) => {
+                stream_info_element = stream_info_element.push(
+                    button(text(
+                        self.settings
+                            .selected_timeframe
+                            .unwrap_or(Timeframe::M1)
+                            .to_string(),
+                    ))
+                    .style(move |theme, status| {
+                        style::button_modifier(theme, status, !is_stream_modifier)
+                    })
+                    .on_press(Message::ToggleModal(id, PaneModal::StreamModifier)),
+                );
+
+                is_chart = true;
+            }
+            _ => {}
+        }
+
+        let content = pane_grid::Content::new(match &self.content {
+            PaneContent::Starter => 
+                center(text("select a ticker to start").size(16)).into(),
+            PaneContent::Heatmap(content, indicators) => 
+                view_chart(id, self, content, notifications, indicators),
+            PaneContent::Footprint(content, indicators) => 
+                view_chart(id, self, content, notifications, indicators),
+            PaneContent::Candlestick(content, indicators) => 
+                view_chart(id, self, content, notifications, indicators),
+            PaneContent::TimeAndSales(content) => 
+                view_panel(id, self, content, notifications),
+        })
+        .style(move |theme| style::pane_primary(theme, is_focused));
 
 
         let title_bar = pane_grid::TitleBar::new(stream_info_element)
         let title_bar = pane_grid::TitleBar::new(stream_info_element)
             .controls(view_controls(
             .controls(view_controls(
                 id,
                 id,
-                self.id,
-                &self.content,
                 panes,
                 panes,
                 maximized,
                 maximized,
-                &self.settings,
+                window != main_window.id,
+                is_chart,
             ))
             ))
-            .padding(4)
-            .style(
-                if is_focused {
-                    style::title_bar_focused
-                } else {
-                    style::title_bar_active
-                }
-            );
-        content = content.title_bar(title_bar);
+            .style(style::title_bar);
 
 
-        content
+        content.title_bar(title_bar)
     }
     }
 
 
     pub fn matches_stream(&self, stream_type: &StreamType) -> bool {
     pub fn matches_stream(&self, stream_type: &StreamType) -> bool {
@@ -157,429 +418,522 @@ impl PaneState {
 }
 }
 
 
 trait ChartView {
 trait ChartView {
-    fn view(&self, id: &PaneState) -> Element<Message>;
+    fn view<'a, I: Indicator>(
+        &'a self, 
+        pane: pane_grid::Pane, 
+        state: &PaneState, 
+        indicators: &'a [I],
+    ) -> Element<Message>;
 }
 }
 
 
-impl ChartView for HeatmapChart {
-    fn view(&self, pane: &PaneState) -> Element<Message> {
-        let pane_id = pane.id;
-
-        let underlay = self.view().map(move |message| Message::ChartUserUpdate(message, pane_id));
-
-        if pane.show_modal {
-            let size_filter = &self.get_size_filter();
-
-            let signup: Container<Message, Theme, _> = container(
-                Column::new()
-                    .spacing(10)
-                    .align_x(Alignment::Center)
-                    .push(
-                        Text::new("Heatmap > Settings")
-                            .size(16)
-                    )
-                    .push(
-                        Column::new()
-                            .align_x(Alignment::Center)
-                            .push(Text::new("Size Filtering"))
-                            .push(
-                                Slider::new(0.0..=50000.0, *size_filter, move |value| Message::SliderChanged(pane_id, value))
-                                    .step(500.0)
-                            )
-                            .push(
-                                Text::new(format!("${size_filter}")).size(16)
-                            )
-                    )
-                    .push( 
-                        Row::new()
-                            .spacing(10)
-                            .push(
-                                button("Close")
-                                .on_press(Message::HideModal(pane_id))
-                            )
-                    )
-            )
-            .width(Length::Shrink)
-            .padding(20)
-            .max_width(500)
-            .style(style::chart_modal);
+trait PanelView {
+    fn view(&self, pane: pane_grid::Pane, state: &PaneState) -> Element<Message>;
+}
 
 
-            return modal(underlay, signup, Message::HideModal(pane_id));
-        } else {
-            underlay
+impl ChartView for HeatmapChart {
+    fn view<'a, I: Indicator>(
+        &'a self, 
+        pane: pane_grid::Pane, 
+        state: &PaneState, 
+        indicators: &'a [I],
+    ) -> Element<Message> {
+        let underlay = self
+            .view(indicators)
+            .map(move |message| Message::ChartUserUpdate(pane, message));
+
+        match state.modal {
+            PaneModal::Settings => {
+                let (trade_size_filter, order_size_filter) = self.get_size_filters();
+                pane_menu(
+                    underlay,
+                    size_filter_view(Some(trade_size_filter), Some(order_size_filter), pane),
+                    Message::ToggleModal(pane, PaneModal::None),
+                    padding::right(12).left(12),
+                    Alignment::End,
+                )
+            }
+            PaneModal::StreamModifier => pane_menu(
+                underlay,
+                stream_modifier_view(
+                    pane,
+                    state.settings.tick_multiply,
+                    None,
+                ),
+                Message::ToggleModal(pane, PaneModal::None),
+                padding::left(36),
+                Alignment::Start,
+            ),
+            PaneModal::Indicators => pane_menu(
+                underlay,
+                indicators_view::<I>(pane, indicators),
+                Message::ToggleModal(pane, PaneModal::None),
+                padding::right(12).left(12),
+                Alignment::End,
+            ),
+            _ => underlay,
         }
         }
     }
     }
 }
 }
+
 impl ChartView for FootprintChart {
 impl ChartView for FootprintChart {
-    fn view(&self, pane: &PaneState) -> Element<Message> {
-        let pane_id = pane.id;
+    fn view<'a, I: Indicator>(
+        &'a self, 
+        pane: pane_grid::Pane, 
+        state: &PaneState, 
+        indicators: &'a [I],
+    ) -> Element<Message> {
+        let underlay = self
+            .view(indicators)
+            .map(move |message| Message::ChartUserUpdate(pane, message));
+
+        match state.modal {
+            PaneModal::StreamModifier => pane_menu(
+                underlay,
+                stream_modifier_view(
+                    pane,
+                    state.settings.tick_multiply,
+                    state.settings.selected_timeframe,
+                ),
+                Message::ToggleModal(pane, PaneModal::None),
+                padding::left(36),
+                Alignment::Start,
+            ),
+            PaneModal::Indicators => pane_menu(
+                underlay,
+                indicators_view::<I>(pane, indicators),
+                Message::ToggleModal(pane, PaneModal::None),
+                padding::right(12).left(12),
+                Alignment::End,
+            ),
+            _ => underlay,
+        }
+    }
+}
 
 
-        self.view().map(move |message| Message::ChartUserUpdate(message, pane_id))
+impl ChartView for CandlestickChart {
+    fn view<'a, I: Indicator>(
+        &'a self,
+        pane: pane_grid::Pane, 
+        state: &PaneState, 
+        indicators: &'a [I],
+    ) -> Element<Message> {
+        let underlay = self
+            .view(indicators)
+            .map(move |message| Message::ChartUserUpdate(pane, message));
+
+        match state.modal {
+            PaneModal::StreamModifier => pane_menu(
+                underlay,
+                stream_modifier_view(
+                    pane,
+                    None,
+                    state.settings.selected_timeframe,
+                ),
+                Message::ToggleModal(pane, PaneModal::None),
+                padding::left(36),
+                Alignment::Start,
+            ),
+            PaneModal::Indicators => pane_menu(
+                underlay,
+                indicators_view::<I>(pane, indicators),
+                Message::ToggleModal(pane, PaneModal::None),
+                padding::right(12).left(12),
+                Alignment::End,
+            ),
+            _ => underlay,
+        }
     }
     }
 }
 }
-impl ChartView for TimeAndSales {
-    fn view(&self, pane: &PaneState) -> Element<Message> {
-        let pane_id = pane.id;
 
 
+impl PanelView for TimeAndSales {
+    fn view(
+        &self, 
+        pane: pane_grid::Pane, 
+        state: &PaneState, 
+    ) -> Element<Message> {
         let underlay = self.view();
         let underlay = self.view();
 
 
-        if pane.show_modal {
-            let size_filter = &self.get_size_filter();
-
-            let signup = container(
-                Column::new()
-                    .spacing(10)
-                    .align_x(Alignment::Center)
-                    .push(
-                        Text::new("Time&Sales > Settings")
-                            .size(16)
-                    )
-                    .push(
-                        Column::new()
-                            .align_x(Alignment::Center)
-                            .push(Text::new("Size Filtering"))
-                            .push(
-                                Slider::new(0.0..=50000.0, *size_filter, move |value| Message::SliderChanged(pane_id, value))
-                                    .step(500.0)
-                            )
-                            .push(
-                                Text::new(format!("${size_filter}")).size(16)
-                            )
-                    )
-                    .push( 
-                        Row::new()
-                            .spacing(10)
-                            .push(
-                                button("Close")
-                                .on_press(Message::HideModal(pane_id))
-                            )
-                    )
-            )
-            .width(Length::Shrink)
-            .padding(20)
-            .max_width(500)
-            .style(style::chart_modal);
-
-            return modal(underlay, signup, Message::HideModal(pane_id));
-        } else {
-            underlay
+        match state.modal {
+            PaneModal::Settings => {
+                let trade_size_filter = self.get_size_filter();
+                pane_menu(
+                    underlay,
+                    size_filter_view(Some(trade_size_filter), None, pane),
+                    Message::ToggleModal(pane, PaneModal::None),
+                    padding::right(12).left(12),
+                    Alignment::End,
+                )
+            }
+            _ => underlay,
         }
         }
     }
     }
 }
 }
-impl ChartView for CandlestickChart {
-    fn view(&self, pane: &PaneState) -> Element<Message> {
-        let pane_id = pane.id;
 
 
-        self.view().map(move |message| Message::ChartUserUpdate(message, pane_id))
+fn indicators_view<I: Indicator> (
+    pane: pane_grid::Pane,
+    selected: &[I]
+) -> Element<Message> {
+    let mut content_row = column![
+        container(
+            text("Indicators").size(14)
+        )
+        .padding(padding::bottom(8)),
+    ]
+    .spacing(4);
+
+    for indicator in I::get_available() {
+        content_row = content_row.push(
+            if selected.contains(indicator) {
+                button(text(indicator.to_string()))
+                    .on_press(Message::ToggleIndicator(pane, indicator.to_string()))
+                    .width(Length::Fill)
+                    .style(move |theme, status| style::button_transparent(theme, status, true))
+            } else {
+                button(text(indicator.to_string()))
+                    .on_press(Message::ToggleIndicator(pane, indicator.to_string()))
+                    .width(Length::Fill)
+                    .style(move |theme, status| style::button_transparent(theme, status, false))
+            }
+        );
     }
     }
+
+    container(content_row)
+        .max_width(200)
+        .padding(16)
+        .style(style::chart_modal)
+        .into()
 }
 }
 
 
-fn view_chart<'a, C: ChartView>(
-    pane: &'a PaneState,
-    chart: &'a C,
+fn size_filter_view<'a>(
+    trade_size_filter: Option<f32>,
+    order_size_filter: Option<f32>,
+    pane: pane_grid::Pane,
 ) -> Element<'a, Message> {
 ) -> Element<'a, Message> {
-    let chart_view: Element<Message> = chart.view(pane);
+    container(
+        column![
+            text("Size Filtering").size(14),
+            if let Some(trade_filter) = trade_size_filter {
+                container(
+                    row![
+                        text("Trade size"),
+                        column![
+                            Slider::new(0.0..=50000.0, trade_filter, move |value| {
+                                Message::SliderChanged(pane, value, true)
+                            })
+                            .step(500.0),
+                            text(format!("${}", format_with_commas(trade_filter))).size(13),
+                        ]
+                        .spacing(2)
+                        .align_x(Alignment::Center),
+                    ]
+                    .align_y(Alignment::Center)
+                    .spacing(8)
+                    .padding(8),
+                )
+                .style(style::modal_container)
+            } else {
+                container(row![])
+            },
+            if let Some(order_filter) = order_size_filter {
+                container(
+                    row![
+                        text("Order size"),
+                        column![
+                            Slider::new(0.0..=500_000.0, order_filter, move |value| {
+                                Message::SliderChanged(pane, value, false)
+                            })
+                            .step(1000.0),
+                            text(format!("${}", format_with_commas(order_filter))).size(13),
+                        ]
+                        .spacing(2)
+                        .align_x(Alignment::Center),
+                    ]
+                    .align_y(Alignment::Center)
+                    .spacing(8)
+                    .padding(8),
+                )
+                .style(style::modal_container)
+            } else {
+                container(row![])
+            },
+        ]
+        .spacing(20)
+        .padding(16)
+        .align_x(Alignment::Center),
+    )
+    .width(Length::Shrink)
+    .padding(16)
+    .max_width(500)
+    .style(style::chart_modal)
+    .into()
+}
+
+fn stream_modifier_view<'a>(
+    pane: pane_grid::Pane,
+    selected_ticksize: Option<TickMultiplier>,
+    selected_timeframe: Option<Timeframe>,
+) -> iced::Element<'a, Message> {
+    let create_button = |content: String, msg: Option<Message>| {
+        let btn = button(text(content))
+            .width(Length::Fill)
+            .style(move |theme, status| style::button_transparent(theme, status, false));
+            
+        if let Some(msg) = msg {
+            btn.on_press(msg)
+        } else {
+            btn
+        }
+    };
+
+    let mut content_row = row![]
+        .align_y(Vertical::Center)
+        .spacing(16);
+
+    let mut timeframes_column = column![]
+        .padding(4)
+        .align_x(Horizontal::Center);
+
+    if selected_timeframe.is_some() {
+        timeframes_column =
+            timeframes_column.push(container(text("Timeframe"))
+                .padding(padding::bottom(8)));
+
+        for timeframe in &Timeframe::ALL {
+            let msg = if selected_timeframe == Some(*timeframe) {
+                None
+            } else {
+                Some(Message::TimeframeSelected(*timeframe, pane))
+            };
+            timeframes_column = timeframes_column.push(
+                create_button(timeframe.to_string(), msg)
+            );
+        }
+
+        content_row = content_row.push(timeframes_column);
+    }
 
 
-    let container = Container::new(chart_view)
-        .width(Length::Fill)
-        .height(Length::Fill);
+    let mut ticksizes_column = column![]
+        .padding(4)
+        .align_x(Horizontal::Center);
+
+    if selected_ticksize.is_some() {
+        ticksizes_column =
+            ticksizes_column.push(container(text("Ticksize Mltp."))
+                .padding(padding::bottom(8)));
+
+        for ticksize in &TickMultiplier::ALL {
+            let msg = if selected_ticksize == Some(*ticksize) {
+                None
+            } else {
+                Some(Message::TicksizeSelected(*ticksize, pane))
+            };
+            ticksizes_column = ticksizes_column.push(
+                create_button(ticksize.to_string(), msg)
+            );
+        }
 
 
-    container.into()
+        content_row = content_row.push(ticksizes_column);
+    }
+
+    container(
+        scrollable::Scrollable::with_direction(
+            content_row, 
+            scrollable::Direction::Vertical(
+                scrollable::Scrollbar::new().width(4).scroller_width(4),
+            )
+        ))
+        .padding(16)
+        .max_width(
+            if selected_ticksize.is_some() && selected_timeframe.is_some() {
+                240
+            } else {
+                120
+            },
+        )
+        .style(style::chart_modal)
+        .into()
 }
 }
 
 
-fn view_controls<'a>(
+fn view_panel<'a, C: PanelView>(
     pane: pane_grid::Pane,
     pane: pane_grid::Pane,
-    pane_id: Uuid,
-    pane_type: &PaneContent,
-    total_panes: usize,
-    is_maximized: bool,
-    settings: &PaneSettings,
+    state: &'a PaneState,
+    content: &'a C,
+    notifications: Option<&'a Vec<screen::Notification>>,
 ) -> Element<'a, Message> {
 ) -> Element<'a, Message> {
-    let mut row = row![].spacing(5);
+    let base: Container<'_, Message> = center(content.view(pane, state));
 
 
-    let (icon, message) = if is_maximized {
-        (Icon::ResizeSmall, Message::Restore)
+    if let Some(notifications) = notifications {
+        if !notifications.is_empty() {
+            pane_notification(base, create_notis_column(notifications))
+        } else {
+            base.into()
+        }
     } else {
     } else {
-        (Icon::ResizeFull, Message::MaximizePane(pane))
-    };
-
-    match pane_type {
-        PaneContent::Heatmap(_) => {
-            let ticksize_picker = pick_list(
-                [TickMultiplier(1), TickMultiplier(2), TickMultiplier(5), TickMultiplier(10), TickMultiplier(25), TickMultiplier(50)],
-                settings.tick_multiply, 
-                move |tick_multiply| Message::TicksizeSelected(tick_multiply, pane_id)
-            ).placeholder("Ticksize multiplier...").text_size(11).width(iced::Pixels(80.0));
-
-            let ticksize_tooltip = tooltip(
-                ticksize_picker
-                    .style(style::picklist_primary)
-                    .menu_style(style::picklist_menu_primary),
-                    "Ticksize multiplier",
-                    tooltip::Position::FollowCursor
-                )
-                .style(style::tooltip);
-    
-            row = row.push(ticksize_tooltip);
-        },
-        PaneContent::TimeAndSales(_) => {
-        },
-        PaneContent::Footprint(_) => {
-            let timeframe_picker = pick_list(
-                &Timeframe::ALL[..],
-                settings.selected_timeframe,
-                move |timeframe| Message::TimeframeSelected(timeframe, pane_id),
-            ).placeholder("Choose a timeframe...").text_size(11).width(iced::Pixels(80.0));
-    
-            let tf_tooltip = tooltip(
-                timeframe_picker
-                    .style(style::picklist_primary)
-                    .menu_style(style::picklist_menu_primary),
-                    "Timeframe",
-                    tooltip::Position::FollowCursor
-                )
-                .style(style::tooltip);
-    
-            row = row.push(tf_tooltip);
-
-            let ticksize_picker = pick_list(
-                [TickMultiplier(1), TickMultiplier(2), TickMultiplier(5), TickMultiplier(10), TickMultiplier(25), TickMultiplier(50), TickMultiplier(100), TickMultiplier(200)],
-                settings.tick_multiply, 
-                move |tick_multiply| Message::TicksizeSelected(tick_multiply, pane_id)
-            ).placeholder("Ticksize multiplier...").text_size(11).width(iced::Pixels(80.0));
-            
-            let ticksize_tooltip = tooltip(
-                ticksize_picker
-                    .style(style::picklist_primary)
-                    .menu_style(style::picklist_menu_primary),
-                    "Ticksize multiplier",
-                    tooltip::Position::FollowCursor
-                )
-                .style(style::tooltip);
-    
-            row = row.push(ticksize_tooltip);
-        },
-        PaneContent::Candlestick(_) => {
-            let timeframe_picker = pick_list(
-                &Timeframe::ALL[..],
-                settings.selected_timeframe,
-                move |timeframe| Message::TimeframeSelected(timeframe, pane_id),
-            ).placeholder("Choose a timeframe...").text_size(11).width(iced::Pixels(80.0));
-    
-            let tooltip = tooltip(
-                timeframe_picker
-                    .style(style::picklist_primary)
-                    .menu_style(style::picklist_menu_primary),
-                    "Timeframe", 
-                    tooltip::Position::FollowCursor
-                )
-                .style(style::tooltip);
-    
-            row = row.push(tooltip);
-        },
-        PaneContent::Starter => {
-        },
+        base.into()
     }
     }
+}
 
 
-    let mut buttons = vec![
-        (container(text(char::from(Icon::Cog).to_string()).font(ICON_FONT).size(14)).width(25).center_x(iced::Pixels(25.0)), Message::ShowModal(pane)),
-        (container(text(char::from(icon).to_string()).font(ICON_FONT).size(14)).width(25).center_x(iced::Pixels(25.0)), message),
-    ];
+fn view_chart<'a, C: ChartView, I: Indicator>(
+    pane: pane_grid::Pane,
+    state: &'a PaneState,
+    content: &'a C,
+    notifications: Option<&'a Vec<screen::Notification>>,
+    indicators: &'a [I],
+) -> Element<'a, Message> {
+    let base: Container<'_, Message> = center(content.view(pane, state, indicators));
 
 
-    if total_panes > 1 {
-        buttons.push((container(text(char::from(Icon::Close).to_string()).font(ICON_FONT).size(14)).width(25).center_x(iced::Pixels(25.0)), Message::ClosePane(pane)));
+    if let Some(notifications) = notifications {
+        if !notifications.is_empty() {
+            pane_notification(base, create_notis_column(notifications))
+        } else {
+            base.into()
+        }
+    } else {
+        base.into()
     }
     }
-
-    for (content, message) in buttons {        
-        row = row.push(
-            button(content)
-                .style(style::button_primary)
-                .padding(3)
-                .on_press(message),
-        );
-    } 
-
-    row.into()
 }
 }
 
 
-fn view_starter<'a>(
-    pane_id: &'a Uuid,
-    pane_settings: &'a PaneSettings,
+fn view_controls<'a>(
+    pane: pane_grid::Pane,
+    total_panes: usize,
+    is_maximized: bool,
+    is_popout: bool,
+    is_chart: bool,
 ) -> Element<'a, Message> {
 ) -> Element<'a, Message> {
-    let content_names = ["Heatmap chart", "Footprint chart", "Candlestick chart", "Time&Sales"];
-    
-    let content_selector = content_names.iter().fold(
-        Column::new()
-            .spacing(6)
-            .align_x(Alignment::Center), |column, &label| {
-                let mut btn = button(label).width(Length::Fill);
-                if let (Some(exchange), Some(ticker)) = (pane_settings.selected_exchange, pane_settings.selected_ticker) {
-                    let timeframe = pane_settings.selected_timeframe.unwrap_or_else(
-                        || { log::error!("No timeframe found"); Timeframe::M1 }
-                    );
+    let button_style = |theme: &Theme, status: button::Status| 
+        style::button_transparent(theme, status, false);
+    let tooltip_pos = tooltip::Position::Bottom;
+
+    let mut buttons = row![
+        create_button(
+            get_icon_text(Icon::Cog, 12),
+            Message::ToggleModal(pane, PaneModal::Settings),
+            None,
+            tooltip_pos,
+            button_style,
+        )
+    ];
 
 
-                    let pane_stream: Vec<StreamType> = match label {
-                        "Heatmap chart" | "Time&Sales" => vec![
-                            StreamType::DepthAndTrades { exchange, ticker }
-                        ],
-                        "Footprint chart" => vec![
-                            StreamType::DepthAndTrades { exchange, ticker }, 
-                            StreamType::Kline { exchange, ticker, timeframe }
-                        ],
-                        "Candlestick chart" => vec![
-                            StreamType::Kline { exchange, ticker, timeframe }
-                        ],
-                        _ => vec![]
-                    };
-                
-                    btn = btn.on_press(
-                        Message::PaneContentSelected(
-                            label.to_string(),
-                            *pane_id,
-                            pane_stream
-                        )
-                    );
-                }
-                column.push(btn)
-            }
-    );
-
-    let symbol_selector = pick_list(
-        &Ticker::ALL[..],
-        pane_settings.selected_ticker,
-        move |ticker| Message::TickerSelected(ticker, *pane_id),
-    ).placeholder("ticker...").text_size(13).width(Length::Fill);
-
-    let exchange_selector = pick_list(
-        &Exchange::ALL[..],
-        pane_settings.selected_exchange,
-        move |exchange| Message::ExchangeSelected(exchange, *pane_id),
-    ).placeholder("exchange...").text_size(13).width(Length::Fill);
-
-    let picklists = Row::new()
-        .spacing(6)
-        .align_y(Alignment::Center)
-        .push(exchange_selector.style(style::picklist_primary).menu_style(style::picklist_menu_primary))
-        .push(symbol_selector.style(style::picklist_primary).menu_style(style::picklist_menu_primary));
-
-    let column = Column::new()
-        .padding(10)
-        .spacing(10)
-        .align_x(Alignment::Center)
-        .push(picklists)
-        .push(content_selector);
+    if is_chart {
+        buttons = buttons.push(create_button(
+            get_icon_text(Icon::ChartOutline, 12),
+            Message::ToggleModal(pane, PaneModal::Indicators),
+            Some("Indicators"),
+            tooltip_pos,
+            button_style,
+        ));
+    }
+
+    if is_popout {
+        buttons = buttons.push(create_button(
+            get_icon_text(Icon::Popout, 12),
+            Message::Merge,
+            Some("Merge"),
+            tooltip_pos,
+            button_style,
+        ));
+    } else if total_panes > 1 {
+        buttons = buttons.push(create_button(
+            get_icon_text(Icon::Popout, 12),
+            Message::Popout,
+            Some("Pop out"),
+            tooltip_pos,
+            button_style,
+        ));
+    }
+
+    if total_panes > 1 {
+        let (resize_icon, message) = if is_maximized {
+            (Icon::ResizeSmall, Message::Restore)
+        } else {
+            (Icon::ResizeFull, Message::MaximizePane(pane))
+        };
         
         
-    let container = Container::new(
-        Column::new()
-            .spacing(10)
-            .padding(20)
-            .align_x(Alignment::Center)
-            .max_width(300)
-            .push(
-                Text::new("Initialize the pane").size(16)
-            )
-            .push(scrollable(column))
-        ).align_x(alignment::Horizontal::Center);
-    
-    container.into()
+        buttons = buttons.push(create_button(
+            get_icon_text(resize_icon, 12),
+            message,
+            None,
+            tooltip_pos,
+            button_style,
+        ));
+
+        buttons = buttons.push(create_button(
+            get_icon_text(Icon::Close, 12),
+            Message::ClosePane(pane),
+            None,
+            tooltip_pos,
+            button_style,
+        ));
+    }
+
+    buttons
+        .padding(padding::right(4))
+        .align_y(Vertical::Center)
+        .height(Length::Fixed(32.0))
+        .into()
 }
 }
 
 
 pub enum PaneContent {
 pub enum PaneContent {
-    Heatmap(HeatmapChart),
-    Footprint(FootprintChart),
-    Candlestick(CandlestickChart),
+    Heatmap(HeatmapChart, Vec<HeatmapIndicator>),
+    Footprint(FootprintChart, Vec<FootprintIndicator>),
+    Candlestick(CandlestickChart, Vec<CandlestickIndicator>),
     TimeAndSales(TimeAndSales),
     TimeAndSales(TimeAndSales),
     Starter,
     Starter,
 }
 }
 
 
-impl fmt::Debug for PaneContent {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+impl PaneContent {
+    pub fn change_timezone(&mut self, timezone: UserTimezone) {
+        match self {
+            PaneContent::Heatmap(chart, _) => chart.change_timezone(timezone),
+            PaneContent::Footprint(chart, _) => chart.change_timezone(timezone),
+            PaneContent::Candlestick(chart, _) => chart.change_timezone(timezone),
+            _ => {}
+        }
+    }
+
+    pub fn toggle_indicator(&mut self, indicator_str: String) {
         match self {
         match self {
-            PaneContent::Heatmap(_) => write!(f, "Heatmap"),
-            PaneContent::Footprint(_) => write!(f, "Footprint"),
-            PaneContent::Candlestick(_) => write!(f, "Candlestick"),
-            PaneContent::TimeAndSales(_) => write!(f, "TimeAndSales"),
-            PaneContent::Starter => write!(f, "Starter"),
+            PaneContent::Footprint(_, indicators) => {
+                let indicator = match indicator_str.as_str() {
+                    "Volume" => FootprintIndicator::Volume,
+                    "Open Interest" => FootprintIndicator::OpenInterest,
+                    _ => {
+                        log::error!("indicator not found: {}", indicator_str);
+                        return
+                    },
+                };
+
+                if indicators.contains(&indicator) {
+                    indicators.retain(|i| i != &indicator);
+                } else {
+                    indicators.push(indicator);
+                }
+            }
+            PaneContent::Candlestick(_, indicators) => {
+                let indicator = match indicator_str.as_str() {
+                    "Volume" => CandlestickIndicator::Volume,
+                    "Open Interest" => CandlestickIndicator::OpenInterest,
+                    _ => {
+                        log::error!("indicator not found: {}", indicator_str);
+                        return
+                    },
+                };
+
+                if indicators.contains(&indicator) {
+                    indicators.retain(|i| i != &indicator);
+                } else {
+                    indicators.push(indicator);
+                }
+            }
+            _ => {}
         }
         }
     }
     }
 }
 }
 
 
-#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
+#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default)]
 pub struct PaneSettings {
 pub struct PaneSettings {
     pub min_tick_size: Option<f32>,
     pub min_tick_size: Option<f32>,
     pub trade_size_filter: Option<f32>,
     pub trade_size_filter: Option<f32>,
     pub tick_multiply: Option<TickMultiplier>,
     pub tick_multiply: Option<TickMultiplier>,
-    pub selected_ticker: Option<Ticker>,
-    pub selected_exchange: Option<Exchange>,
     pub selected_timeframe: Option<Timeframe>,
     pub selected_timeframe: Option<Timeframe>,
 }
 }
-impl Default for PaneSettings {
-    fn default() -> Self {
-        Self {
-            min_tick_size: None,
-            trade_size_filter: Some(0.0),
-            tick_multiply: Some(TickMultiplier(10)),
-            selected_ticker: None,
-            selected_exchange: None,
-            selected_timeframe: Some(Timeframe::M1),
-        }
-    }
-}
-
-#[derive(Debug, Clone, Deserialize, Serialize)]
-pub enum SerializablePane {
-    Split {
-        axis: Axis,
-        ratio: f32,
-        a: Box<SerializablePane>,
-        b: Box<SerializablePane>,
-    },
-    Starter,
-    HeatmapChart {
-        stream_type: Vec<StreamType>,
-        settings: PaneSettings,
-    },
-    FootprintChart {
-        stream_type: Vec<StreamType>,
-        settings: PaneSettings,
-    },
-    CandlestickChart {
-        stream_type: Vec<StreamType>,
-        settings: PaneSettings,
-    },
-    TimeAndSales {
-        stream_type: Vec<StreamType>,
-        settings: PaneSettings,
-    },
-}
-
-#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
-pub enum Axis {
-    Horizontal,
-    Vertical,
-}
-
-impl From<&PaneState> for SerializablePane {
-    fn from(pane: &PaneState) -> Self {
-        let pane_stream = pane.stream.clone();
-
-        match pane.content {
-            PaneContent::Starter => SerializablePane::Starter,
-            PaneContent::Heatmap(_) => SerializablePane::HeatmapChart {
-                stream_type: pane_stream,
-                settings: pane.settings,
-            },
-            PaneContent::Footprint(_) => SerializablePane::FootprintChart {
-                stream_type: pane_stream,
-                settings: pane.settings,
-            },
-            PaneContent::Candlestick(_) => SerializablePane::CandlestickChart {
-                stream_type: pane_stream,
-                settings: pane.settings,
-            },
-            PaneContent::TimeAndSales(_) => SerializablePane::TimeAndSales {
-                stream_type: pane_stream,
-                settings: pane.settings,
-            }
-        }
-    }
-}

+ 98 - 0
src/screen/modal.rs

@@ -0,0 +1,98 @@
+use iced::{
+    padding,
+    widget::{container, mouse_area, opaque, stack},
+    Alignment, Element, Length,
+};
+
+pub fn dashboard_modal<'a, Message>(
+    base: impl Into<Element<'a, Message>>,
+    content: impl Into<Element<'a, Message>>,
+    on_blur: Message,
+    padding: padding::Padding,
+    align_y: Alignment,
+    align_x: Alignment,
+) -> Element<'a, Message>
+where
+    Message: Clone + 'a,
+{
+    stack![
+        base.into(),
+        mouse_area(
+            container(opaque(content))
+                .width(Length::Fill)
+                .height(Length::Fill)
+                .padding(padding)
+                .align_y(align_y)
+                .align_x(align_x)
+        )
+        .on_press(on_blur)
+    ]
+    .into()
+}
+
+pub fn pane_menu<'a, Message>(
+    base: impl Into<Element<'a, Message>>,
+    content: impl Into<Element<'a, Message>>,
+    on_blur: Message,
+    padding: padding::Padding,
+    alignment: Alignment,
+) -> Element<'a, Message>
+where
+    Message: Clone + 'a,
+{
+    stack![
+        base.into(),
+        mouse_area(
+            container(opaque(content))
+                .width(Length::Fill)
+                .height(Length::Fill)
+                .padding(padding)
+                .align_x(alignment)
+        )
+        .on_press(on_blur)
+    ]
+    .into()
+}
+
+// Notifications
+pub fn dashboard_notification<'a, Message>(
+    base: impl Into<Element<'a, Message>>,
+    content: impl Into<Element<'a, Message>>,
+) -> Element<'a, Message>
+where
+    Message: Clone + 'a,
+{
+    stack![
+        base.into(),
+        mouse_area(
+            container(opaque(content))
+                .padding(20)
+                .style(|_theme| {
+                    container::Style {
+                        ..container::Style::default()
+                    }
+                })
+        )
+    ]
+    .into()
+}
+
+pub fn pane_notification<'a, Message>(
+    base: impl Into<Element<'a, Message>>,
+    content: impl Into<Element<'a, Message>>,
+) -> Element<'a, Message>
+where
+    Message: Clone + 'a,
+{
+    stack![
+        base.into(),
+        mouse_area(
+            container(opaque(content))
+                .width(Length::Fill)
+                .height(Length::Fill)
+                .padding(12)
+                .align_x(Alignment::End)
+        )
+    ]
+    .into()
+}

+ 604 - 139
src/style.rs

@@ -1,7 +1,10 @@
+use iced::theme::{Custom, Palette};
 use iced::widget::button::Status;
 use iced::widget::button::Status;
-use iced::widget::container::Style;
-use iced::{Border, Color, Font, Theme, overlay};
-use iced::widget::pick_list;
+use iced::widget::container::{self, Style};
+use iced::widget::pane_grid::{Highlight, Line};
+use iced::widget::scrollable::{Rail, Scroller};
+use iced::widget::{text, Text};
+use iced::{widget, Border, Color, Font, Renderer, Shadow, Theme};
 
 
 pub const ICON_BYTES: &[u8] = include_bytes!("fonts/icons.ttf");
 pub const ICON_BYTES: &[u8] = include_bytes!("fonts/icons.ttf");
 pub const ICON_FONT: Font = Font::with_name("icons");
 pub const ICON_FONT: Font = Font::with_name("icons");
@@ -17,117 +20,109 @@ pub enum Icon {
     Link,
     Link,
     BinanceLogo,
     BinanceLogo,
     BybitLogo,
     BybitLogo,
+    Search,
+    Sort,
+    SortDesc,
+    SortAsc,
+    Star,
+    StarFilled,
+    Return,
+    Popout,
+    ChartOutline,
 }
 }
 
 
 impl From<Icon> for char {
 impl From<Icon> for char {
     fn from(icon: Icon) -> Self {
     fn from(icon: Icon) -> Self {
         match icon {
         match icon {
-            Icon::Unlocked => '\u{E800}',
-            Icon::Locked => '\u{E801}',
-            Icon::ResizeFull => '\u{E802}',
-            Icon::ResizeSmall => '\u{E803}',
-            Icon::Close => '\u{E804}',
-            Icon::Layout => '\u{E805}',
-            Icon::Cog => '\u{E806}',
+            Icon::Locked => '\u{E800}',
+            Icon::Unlocked => '\u{E801}',
+            Icon::Search => '\u{E802}',
+            Icon::ResizeFull => '\u{E803}',
+            Icon::ResizeSmall => '\u{E804}',
+            Icon::Close => '\u{E805}',
+            Icon::Layout => '\u{E806}',
             Icon::Link => '\u{E807}',
             Icon::Link => '\u{E807}',
             Icon::BybitLogo => '\u{E808}',
             Icon::BybitLogo => '\u{E808}',
             Icon::BinanceLogo => '\u{E809}',
             Icon::BinanceLogo => '\u{E809}',
+            Icon::Cog => '\u{E810}',
+            Icon::Sort => '\u{F0DC}',
+            Icon::SortDesc => '\u{F0DD}',
+            Icon::SortAsc => '\u{F0DE}',
+            Icon::Star => '\u{E80A}',
+            Icon::StarFilled => '\u{E80B}',
+            Icon::Return => '\u{E80C}',
+            Icon::Popout => '\u{E80D}',
+            Icon::ChartOutline => '\u{E80E}',
         }
         }
     }
     }
 }
 }
 
 
-pub fn tooltip(theme: &Theme) -> Style {
-    let palette = theme.extended_palette();
-
-    Style {
-        background: Some(palette.background.weak.color.into()),
-        border: Border {
-            width: 1.0,
-            color: palette.primary.weak.color,
-            radius: 4.0.into(),
-        },
-        ..Default::default()
-    }
+pub fn get_icon_text<'a>(icon: Icon, size: u16) -> Text<'a, Theme, Renderer> {
+    text(char::from(icon).to_string())
+        .font(ICON_FONT)
+        .size(size)
 }
 }
 
 
-pub fn notification(theme: &Theme) -> Style {
-    let palette = theme.extended_palette();
-
-    Style {
-        text_color: Some(palette.background.weak.text),
-        background: Some(Color::BLACK.into()),
-        border: Border {
-            width: 1.0,
-            color: palette.background.weak.color,
-            radius: 2.0.into(),
+pub fn custom_theme() -> Custom {
+    Custom::new(
+        "Flowsurface".to_string(),
+        Palette {
+            background: Color::from_rgb8(24, 22, 22),
+            text: Color::from_rgb8(197, 201, 197),
+            primary: Color::from_rgb8(200, 200, 200),
+            success: Color::from_rgb8(81, 205, 160),
+            danger: Color::from_rgb8(192, 80, 77),
         },
         },
-        ..Default::default()
-    }
+    )
 }
 }
 
 
-pub fn title_bar_active(theme: &Theme) -> Style {
+pub fn branding_text(theme: &Theme) -> iced::widget::text::Style {
     let palette = theme.extended_palette();
     let palette = theme.extended_palette();
 
 
-    Style {
-        text_color: Some(palette.background.base.text),
-        background: Some(Color::BLACK.into()),
+    iced::widget::text::Style {
+        color: Some(
+            palette
+                .secondary
+                .weak
+                .color
+                .scale_alpha(if palette.is_dark { 0.1 } else { 0.8 })
+        ),
         ..Default::default()
         ..Default::default()
     }
     }
 }
 }
-pub fn title_bar_focused(theme: &Theme) -> Style {
-    let palette = theme.extended_palette();
 
 
-    Style {
-        text_color: Some(palette.background.weak.text),
-        background: Some(Color::TRANSPARENT.into()),
-        ..Default::default()
-    }
-}
-pub fn pane_active(theme: &Theme) -> Style {
-    let palette = theme.extended_palette();
-
-    Style {
-        text_color: Some(palette.background.base.text),
-        background: Some(Color::BLACK.into()),
-        ..Default::default()
-    }
-}
-pub fn pane_focused(theme: &Theme) -> Style {
+// Tooltips
+pub fn tooltip(theme: &Theme) -> Style {
     let palette = theme.extended_palette();
     let palette = theme.extended_palette();
 
 
     Style {
     Style {
-        text_color: Some(palette.background.weak.text),
-        background: Some(Color::BLACK.into()),
+        background: Some(palette.background.weak.color.into()),
         border: Border {
         border: Border {
             width: 1.0,
             width: 1.0,
-            color: palette.background.weak.color,
+            color: palette.secondary.weak.color,
             radius: 4.0.into(),
             radius: 4.0.into(),
         },
         },
         ..Default::default()
         ..Default::default()
     }
     }
 }
 }
 
 
-pub fn chart_modal(theme: &Theme) -> Style {
+// Buttons
+pub fn button_transparent(
+    theme: &Theme,
+    status: Status,
+    is_active: bool,
+) -> iced::widget::button::Style {
     let palette = theme.extended_palette();
     let palette = theme.extended_palette();
 
 
-    Style {
-        text_color: Some(palette.background.base.text),
-        background: Some(palette.background.base.color.into()),
-        border: Border {
-            width: 1.0,
-            color: palette.background.weak.color,
-            radius: 4.0.into(),
-        },
-        ..Default::default()
-    }
-}
-
-pub fn button_for_info(theme: &Theme, status: Status) -> iced::widget::button::Style {
-    let palette = theme.extended_palette();
+    let color_alpha = if palette.is_dark { 0.2 } else { 0.6 };
 
 
     match status {
     match status {
         Status::Active => iced::widget::button::Style {
         Status::Active => iced::widget::button::Style {
-            background: Some(Color::BLACK.into()),
+            background: if is_active {
+                Some(palette.secondary.weak.color.scale_alpha(color_alpha).into())
+            } else {
+                None
+            },
             text_color: palette.background.base.text,
             text_color: palette.background.base.text,
             border: Border {
             border: Border {
                 radius: 3.0.into(),
                 radius: 3.0.into(),
@@ -136,45 +131,91 @@ pub fn button_for_info(theme: &Theme, status: Status) -> iced::widget::button::S
             ..Default::default()
             ..Default::default()
         },
         },
         Status::Pressed => iced::widget::button::Style {
         Status::Pressed => iced::widget::button::Style {
-            background: Some(Color::BLACK.into()),
             text_color: palette.background.base.text,
             text_color: palette.background.base.text,
+            background: Some(
+                palette
+                    .background
+                    .strong
+                    .color
+                    .scale_alpha(color_alpha)
+                    .into(),
+            ),
             border: Border {
             border: Border {
-                color: palette.primary.weak.color,
-                width: 2.0,
-                radius: 6.0.into(),
+                radius: 3.0.into(),
                 ..Default::default()
                 ..Default::default()
             },
             },
             ..Default::default()
             ..Default::default()
         },
         },
         Status::Hovered => iced::widget::button::Style {
         Status::Hovered => iced::widget::button::Style {
-            background: Some(Color::BLACK.into()),
-            text_color: palette.background.weak.text,
+            background: if palette.is_dark {
+                Some(palette.background.weak.color.into())
+            } else {
+                Some(palette.background.strong.color.into())
+            },
+            text_color: palette.background.base.text,
             border: Border {
             border: Border {
-                color: palette.primary.strong.color,
-                width: 1.0,
                 radius: 3.0.into(),
                 radius: 3.0.into(),
                 ..Default::default()
                 ..Default::default()
             },
             },
             ..Default::default()
             ..Default::default()
         },
         },
         Status::Disabled => iced::widget::button::Style {
         Status::Disabled => iced::widget::button::Style {
-            background: Some(Color::BLACK.into()),
+            background: if is_active {
+                None
+            } else {
+                Some(palette.secondary.weak.color.scale_alpha(color_alpha).into())
+            },
             text_color: palette.background.base.text,
             text_color: palette.background.base.text,
             border: Border {
             border: Border {
                 radius: 3.0.into(),
                 radius: 3.0.into(),
                 ..Default::default()
                 ..Default::default()
             },
             },
             ..Default::default()
             ..Default::default()
-        }
+        },
     }
     }
 }
 }
 
 
-pub fn button_primary(theme: &Theme, status: Status) -> iced::widget::button::Style {
+pub fn button_modifier(
+    theme: &Theme,
+    status: Status,
+    disabled: bool,
+) -> iced::widget::button::Style {
     let palette = theme.extended_palette();
     let palette = theme.extended_palette();
 
 
+    let color_alpha = if palette.is_dark { 0.2 } else { 0.6 };
+
     match status {
     match status {
         Status::Active => iced::widget::button::Style {
         Status::Active => iced::widget::button::Style {
-            background: Some(Color::BLACK.into()),
+            background: if disabled {
+                if palette.is_dark {
+                    Some(
+                        palette
+                            .background
+                            .weak
+                            .color
+                            .scale_alpha(color_alpha)
+                            .into(),
+                    )
+                } else {
+                    Some(
+                        palette
+                            .background
+                            .base
+                            .color
+                            .scale_alpha(color_alpha)
+                            .into(),
+                    )
+                }
+            } else {
+                Some(
+                    palette
+                        .background
+                        .strong
+                        .color
+                        .scale_alpha(color_alpha)
+                        .into(),
+                )
+            },
             text_color: palette.background.base.text,
             text_color: palette.background.base.text,
             border: Border {
             border: Border {
                 radius: 3.0.into(),
                 radius: 3.0.into(),
@@ -183,119 +224,543 @@ pub fn button_primary(theme: &Theme, status: Status) -> iced::widget::button::St
             ..Default::default()
             ..Default::default()
         },
         },
         Status::Pressed => iced::widget::button::Style {
         Status::Pressed => iced::widget::button::Style {
-            background: Some(Color::BLACK.into()),
             text_color: palette.background.base.text,
             text_color: palette.background.base.text,
+            background: Some(
+                palette
+                    .background
+                    .strong
+                    .color
+                    .scale_alpha(color_alpha)
+                    .into(),
+            ),
             border: Border {
             border: Border {
-                color: palette.primary.weak.color,
-                width: 2.0,
-                radius: 6.0.into(),
+                radius: 3.0.into(),
                 ..Default::default()
                 ..Default::default()
             },
             },
             ..Default::default()
             ..Default::default()
         },
         },
         Status::Hovered => iced::widget::button::Style {
         Status::Hovered => iced::widget::button::Style {
-            background: Some(Color::BLACK.into()),
-            text_color: palette.background.weak.text,
+            background: if palette.is_dark {
+                Some(palette.background.weak.color.into())
+            } else {
+                Some(palette.background.strong.color.into())
+            },
+            text_color: palette.background.base.text,
             border: Border {
             border: Border {
-                color: palette.primary.strong.color,
-                width: 1.0,
                 radius: 3.0.into(),
                 radius: 3.0.into(),
                 ..Default::default()
                 ..Default::default()
             },
             },
             ..Default::default()
             ..Default::default()
         },
         },
         Status::Disabled => iced::widget::button::Style {
         Status::Disabled => iced::widget::button::Style {
-            background: Some(Color::BLACK.into()),
+            background: if disabled {
+                None
+            } else {
+                Some(palette.secondary.weak.color.scale_alpha(color_alpha).into())
+            },
             text_color: palette.background.base.text,
             text_color: palette.background.base.text,
             border: Border {
             border: Border {
                 radius: 3.0.into(),
                 radius: 3.0.into(),
                 ..Default::default()
                 ..Default::default()
             },
             },
             ..Default::default()
             ..Default::default()
-        }
+        },
+    }
+}
+
+// Panes
+pub fn pane_grid(theme: &Theme) -> widget::pane_grid::Style {
+    let palette = theme.extended_palette();
+
+    widget::pane_grid::Style {
+        hovered_region: Highlight {
+            background: palette.background.strong.color.into(),
+            border: Border {
+                width: 1.0,
+                color: palette.primary.base.color,
+                radius: 4.0.into(),
+            },
+        },
+        picked_split: Line {
+            color: palette.primary.strong.color,
+            width: 4.0,
+        },
+        hovered_split: Line {
+            color: palette.primary.weak.color,
+            width: 4.0,
+        },
+    }
+}
+
+pub fn title_bar(theme: &Theme) -> Style {
+    let palette = theme.extended_palette();
+
+    Style {
+        background: {
+            if palette.is_dark {
+                Some(palette.background.weak.color.scale_alpha(0.1).into())
+            } else {
+                Some(palette.background.strong.color.scale_alpha(0.1).into())
+            }
+        },
+        ..Default::default()
+    }
+}
+
+pub fn pane_primary(theme: &Theme, is_focused: bool) -> Style {
+    let palette = theme.extended_palette();
+
+    Style {
+        text_color: Some(palette.background.base.text),
+        background: Some(
+            palette
+                .background
+                .weak
+                .color
+                .scale_alpha(if palette.is_dark { 0.1 } else { 0.6 })
+                .into(),
+        ),
+        border: {
+            if is_focused {
+                Border {
+                    width: 1.0,
+                    color: {
+                        if palette.is_dark {
+                            palette.background.strong.color.scale_alpha(0.4)
+                        } else {
+                            palette.background.strong.color.scale_alpha(0.8)
+                        }
+                    },
+                    radius: 4.0.into(),
+                }
+            } else {
+                Border {
+                    width: 1.0,
+                    color: {
+                        if palette.is_dark {
+                            palette.background.weak.color.scale_alpha(0.2)
+                        } else {
+                            palette.background.strong.color.scale_alpha(0.2)
+                        }
+                    },
+                    radius: 2.0.into(),
+                }
+            }
+        },
+        ..Default::default()
+    }
+}
+
+// Modals
+pub fn pane_info_notification(theme: &Theme, alpha_factor: f32) -> Style {
+    let palette = theme.extended_palette();
+
+    Style {
+        text_color: Some(
+            palette
+                .background
+                .weak
+                .text
+                .scale_alpha(alpha_factor.max(0.3)),
+        ),
+        background: Some(
+            palette
+                .secondary
+                .base
+                .color
+                .scale_alpha(alpha_factor.max(0.3))
+                .into(),
+        ),
+        border: Border {
+            width: 1.0,
+            color: palette.secondary.strong.color.scale_alpha(alpha_factor),
+            radius: 4.0.into(),
+        },
+        shadow: Shadow {
+            offset: iced::Vector { x: 0.0, y: 0.0 },
+            blur_radius: 4.0,
+            color: Color::BLACK.scale_alpha(
+                if palette.is_dark {
+                    1.0
+                } else {
+                    0.4
+                }
+            ),
+        },
+        ..Default::default()
     }
     }
 }
 }
 
 
-pub fn picklist_primary(theme: &Theme, status: pick_list::Status) -> pick_list::Style {
+pub fn pane_err_notification(theme: &Theme, alpha_factor: f32) -> Style {
+    let palette = theme.extended_palette();
+
+    Style {
+        text_color: Some(
+            palette
+                .background
+                .weak
+                .text
+                .scale_alpha(alpha_factor.max(0.3)),
+        ),
+        background: Some(
+            palette
+                .secondary
+                .base
+                .color
+                .scale_alpha(alpha_factor.max(0.3))
+                .into(),
+        ),
+        border: Border {
+            width: 1.0,
+            color: palette.danger.base.color.scale_alpha(alpha_factor),
+            radius: 4.0.into(),
+        },
+        shadow: Shadow {
+            offset: iced::Vector { x: 0.0, y: 0.0 },
+            blur_radius: 4.0,
+            color: Color::BLACK.scale_alpha(
+                if palette.is_dark {
+                    1.0
+                } else {
+                    0.4
+                }
+            ),
+        },
+        ..Default::default()
+    }
+}
+
+pub fn chart_modal(theme: &Theme) -> Style {
     let palette = theme.extended_palette();
     let palette = theme.extended_palette();
-    
+
+    Style {
+        text_color: Some(palette.background.base.text),
+        background: Some(
+            Color {
+                a: 0.99,
+                ..palette.background.base.color
+            }
+            .into(),
+        ),
+        border: Border {
+            width: 1.0,
+            color: palette.secondary.weak.color,
+            radius: 6.0.into(),
+        },
+        shadow: Shadow {
+            offset: iced::Vector { x: 0.0, y: 0.0 },
+            blur_radius: 12.0,
+            color: Color::BLACK.scale_alpha(
+                if palette.is_dark {
+                    0.4
+                } else {
+                    0.2
+                }
+            ),
+        },
+        ..Default::default()
+    }
+}
+
+pub fn dashboard_modal(theme: &Theme) -> Style {
+    let palette = theme.extended_palette();
+
+    Style {
+        background: Some(
+            Color {
+                a: 0.99,
+                ..palette.background.base.color
+            }
+            .into(),
+        ),
+        border: Border {
+            width: 1.0,
+            color: palette.secondary.weak.color,
+            radius: 6.0.into(),
+        },
+        shadow: Shadow {
+            offset: iced::Vector { x: 0.0, y: 0.0 },
+            blur_radius: 20.0,
+            color: Color::BLACK.scale_alpha(
+                if palette.is_dark {
+                    0.4
+                } else {
+                    0.2
+                }
+            ),
+        },
+        ..Default::default()
+    }
+}
+
+pub fn modal_container(theme: &Theme) -> Style {
+    let palette = theme.extended_palette();
+
+    let color = if palette.is_dark {
+        palette.background.weak.color.scale_alpha(0.6)
+    } else {
+        palette.background.strong.color.scale_alpha(0.6)
+    };
+
+    Style {
+        text_color: Some(palette.background.base.text),
+        background: Some(color.into()),
+        border: Border {
+            width: 1.0,
+            color,
+            radius: 6.0.into(),
+        },
+        shadow: Shadow {
+            offset: iced::Vector { x: 0.0, y: 0.0 },
+            blur_radius: 2.0,
+            color: Color::BLACK.scale_alpha(
+                if palette.is_dark {
+                    0.8
+                } else {
+                    0.2
+                }
+            ),
+        },
+        ..Default::default()
+    }
+}
+
+pub fn sorter_container(theme: &Theme) -> Style {
+    let palette = theme.extended_palette();
+
+    let color = if palette.is_dark {
+        palette.background.weak.color.scale_alpha(0.4)
+    } else {
+        palette.background.strong.color.scale_alpha(0.4)
+    };
+
+    Style {
+        text_color: Some(palette.background.base.text),
+        background: Some(color.into()),
+        border: Border {
+            width: 1.0,
+            color,
+            radius: 3.0.into(),
+        },
+        shadow: Shadow {
+            offset: iced::Vector { x: 0.0, y: 0.0 },
+            blur_radius: 2.0,
+            color: Color::BLACK.scale_alpha(
+                if palette.is_dark {
+                    0.8
+                } else {
+                    0.2
+                }
+            ),
+        },
+        ..Default::default()
+    }
+}
+
+// Time&Sales Table
+pub fn ts_table_container(theme: &Theme, is_sell: bool, color_alpha: f32) -> Style {
+    let palette = theme.extended_palette();
+
+    let color = if is_sell {
+        palette.danger.base.color
+    } else {
+        palette.success.base.color
+    };
+
+    Style {
+        text_color: color.into(),
+        border: Border {
+            width: 1.0,
+            color: color.scale_alpha(color_alpha),
+            ..Border::default()
+        },
+        ..Default::default()
+    }
+}
+
+// Tickers Table
+pub fn search_input(
+    theme: &Theme,
+    status: widget::text_input::Status,
+) -> widget::text_input::Style {
+    let palette = theme.extended_palette();
+
     match status {
     match status {
-        pick_list::Status::Active => pick_list::Style {
-            text_color: palette.background.base.text,
-            placeholder_color: palette.background.base.text,
-            handle_color: palette.background.base.text,
-            background: palette.background.base.color.into(),
+        widget::text_input::Status::Active => widget::text_input::Style {
+            background: palette.background.weak.color.into(),
             border: Border {
             border: Border {
                 radius: 3.0.into(),
                 radius: 3.0.into(),
                 width: 1.0,
                 width: 1.0,
-                color: palette.background.weak.color,
-                ..Default::default()
+                color: palette.secondary.base.color,
             },
             },
+            icon: palette.background.strong.text,
+            placeholder: palette.background.base.text,
+            value: palette.background.weak.text,
+            selection: palette.background.strong.color,
         },
         },
-        pick_list::Status::Opened => pick_list::Style {
-            text_color: palette.background.base.text,
-            placeholder_color: palette.background.base.text,
-            handle_color: palette.background.base.text,
-            background: palette.background.base.color.into(),
+        widget::text_input::Status::Hovered => widget::text_input::Style {
+            background: palette.background.weak.color.into(),
             border: Border {
             border: Border {
                 radius: 3.0.into(),
                 radius: 3.0.into(),
                 width: 1.0,
                 width: 1.0,
-                color: palette.primary.base.color,
-                ..Default::default()
+                color: palette.secondary.strong.color,
             },
             },
+            icon: palette.background.strong.text,
+            placeholder: palette.background.base.text,
+            value: palette.background.weak.text,
+            selection: palette.background.strong.color,
         },
         },
-        pick_list::Status::Hovered => pick_list::Style {
-            text_color: palette.background.weak.text,
-            placeholder_color: palette.background.weak.text,
-            handle_color: palette.background.weak.text,
-            background: palette.background.base.color.into(),
+        widget::text_input::Status::Focused { .. } => widget::text_input::Style {
+            background: palette.background.weak.color.into(),
+            border: Border {
+                radius: 3.0.into(),
+                width: 2.0,
+                color: palette.secondary.strong.color,
+            },
+            icon: palette.background.strong.text,
+            placeholder: palette.background.base.text,
+            value: palette.background.weak.text,
+            selection: palette.background.strong.color,
+        },
+        widget::text_input::Status::Disabled => widget::text_input::Style {
+            background: palette.background.weak.color.into(),
             border: Border {
             border: Border {
                 radius: 3.0.into(),
                 radius: 3.0.into(),
                 width: 1.0,
                 width: 1.0,
-                color: palette.primary.strong.color,
-                ..Default::default()
+                color: palette.secondary.weak.color,
             },
             },
+            icon: palette.background.weak.text,
+            placeholder: palette.background.weak.text,
+            value: palette.background.weak.text,
+            selection: palette.background.weak.text,
         },
         },
     }
     }
 }
 }
 
 
-pub fn picklist_menu_primary(theme: &Theme) -> overlay::menu::Style {
+pub fn ticker_card(theme: &Theme, _color_alpha: f32) -> Style {
     let palette = theme.extended_palette();
     let palette = theme.extended_palette();
 
 
-    overlay::menu::Style {
-        text_color: palette.background.base.text,
-        background: palette.background.base.color.into(),
+    let color_alpha = if palette.is_dark { 0.2 } else { 0.8 };
+
+    Style {
+        background: Some(
+            palette
+                .background
+                .weak
+                .color
+                .scale_alpha(color_alpha)
+                .into(),
+        ),
         border: Border {
         border: Border {
-            radius: 3.0.into(),
+            radius: 4.0.into(),
             width: 1.0,
             width: 1.0,
-            color: palette.background.base.color,
-            ..Default::default()
+            ..Border::default()
         },
         },
-        selected_text_color: palette.background.weak.text,
-        selected_background: palette.secondary.weak.color.into(),
+        ..Default::default()
     }
     }
 }
 }
 
 
-pub fn sell_side_red(color_alpha: f32) -> Style {
+pub fn ticker_card_bar(theme: &Theme, color_alpha: f32) -> Style {
+    let palette = theme.extended_palette();
+
     Style {
     Style {
-        text_color: Color::from_rgba(192.0 / 255.0, 80.0 / 255.0, 77.0 / 255.0, 1.0).into(),
+        background: {
+            if color_alpha > 0.0 {
+                Some(palette.success.strong.color.scale_alpha(color_alpha).into())
+            } else {
+                Some(palette.danger.strong.color.scale_alpha(-color_alpha).into())
+            }
+        },
         border: Border {
         border: Border {
+            radius: 4.0.into(),
             width: 1.0,
             width: 1.0,
-            color: Color::from_rgba(192.0 / 255.0, 80.0 / 255.0, 77.0 / 255.0, color_alpha),
-            ..Border::default()
+            color: if color_alpha > 0.0 {
+                palette.success.strong.color.scale_alpha(color_alpha)
+            } else {
+                palette.danger.strong.color.scale_alpha(-color_alpha)
+            },
         },
         },
         ..Default::default()
         ..Default::default()
     }
     }
 }
 }
 
 
-pub fn buy_side_green(color_alpha: f32) -> Style {
-    Style {
-        text_color: Color::from_rgba(81.0 / 255.0, 205.0 / 255.0, 160.0 / 255.0, 1.0).into(),
+pub fn ticker_card_button(theme: &Theme, status: Status) -> iced::widget::button::Style {
+    let palette = theme.extended_palette();
+
+    match status {
+        Status::Hovered => iced::widget::button::Style {
+            text_color: palette.background.base.text,
+            background: Some(palette.background.weak.color.scale_alpha(0.1).into()),
+            border: Border {
+                radius: 4.0.into(),
+                width: 1.0,
+                color: {
+                    if palette.is_dark {
+                        palette.background.strong.color.scale_alpha(0.4)
+                    } else {
+                        palette.background.strong.color.scale_alpha(0.8)
+                    }
+                },
+            },
+            ..Default::default()
+        },
+        _ => iced::widget::button::Style {
+            text_color: palette.background.base.text,
+            ..Default::default()
+        },
+    }
+}
+
+// Scrollable
+pub fn scroll_bar(theme: &Theme, status: widget::scrollable::Status) -> widget::scrollable::Style {
+    let palette = theme.extended_palette();
+
+    let light_factor = if palette.is_dark { 1.0 } else { 4.0 };
+
+    let (rail_bg, scroller_bg) = match status {
+        widget::scrollable::Status::Dragged { .. } 
+        | widget::scrollable::Status::Hovered { .. } => {
+            (
+                palette.background.weak.color.scale_alpha(0.2 * light_factor).into(),
+                palette.secondary.weak.color.scale_alpha(0.8 * light_factor).into(),
+            )
+        },
+        _ => (
+            palette.background.weak.color.scale_alpha(0.1 * light_factor).into(),
+            palette.secondary.weak.color.scale_alpha(0.4 * light_factor).into(),
+        ),
+    };
+
+    let rail = Rail {
+        background: Some(rail_bg),
         border: Border {
         border: Border {
+            radius: 4.0.into(),
             width: 1.0,
             width: 1.0,
-            color: Color::from_rgba(81.0 / 255.0, 205.0 / 255.0, 160.0 / 255.0, color_alpha),
-            ..Border::default()
+            color: Color::TRANSPARENT,
         },
         },
-        ..Default::default()
+        scroller: Scroller {
+            color: scroller_bg,
+            border: Border {
+                radius: 4.0.into(),
+                width: 0.0,
+                color: Color::TRANSPARENT,
+            },
+        },
+    };
+
+    widget::scrollable::Style {
+        container: container::Style {
+            text_color: None,
+            background: None,
+            border: Border {
+                radius: 4.0.into(),
+                width: 1.0,
+                color: Color::TRANSPARENT,
+            },
+            shadow: Shadow::default(),
+        },
+        vertical_rail: rail,
+        horizontal_rail: rail,
+        gap: None,
     }
     }
 }
 }

+ 684 - 0
src/tickers_table.rs

@@ -0,0 +1,684 @@
+use std::collections::HashMap;
+
+use iced::{
+    alignment::{self, Horizontal, Vertical},
+    padding,
+    widget::{
+        button, column, container, row,
+        scrollable::{self, AbsoluteOffset},
+        text, text_input, Button, Column, Container, Space, Text,
+    },
+    Element, Length, Renderer, Size, Task, Theme,
+};
+use crate::{
+    data_providers::{Exchange, MarketType, Ticker, TickerStats}, style::{self, get_icon_text, Icon, ICON_FONT}
+};
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum TickerTab {
+    All,
+    Bybit,
+    Binance,
+    Favorites,
+}
+
+#[derive(Clone)]
+struct TickerDisplayData {
+    display_ticker: String,
+    price_change_display: String,
+    volume_display: String,
+    mark_price_display: String,
+    card_color_alpha: f32,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum SortOptions {
+    VolumeAsc,
+    VolumeDesc,
+    ChangeAsc,
+    ChangeDesc,
+}
+
+#[derive(Debug, Clone)]
+pub enum Message {
+    ChangeTickersTableTab(TickerTab),
+    UpdateSearchQuery(String),
+    ChangeSortOption(SortOptions),
+    ShowSortingOptions,
+    TickerSelected(Ticker, Exchange, String),
+    ExpandTickerCard(Option<(Ticker, Exchange)>),
+    FavoriteTicker(Exchange, Ticker),
+    Scrolled(scrollable::Viewport),
+    SetMarketFilter(Option<MarketType>),
+}
+
+pub struct TickersTable {
+    tickers_info: HashMap<Exchange, Vec<(Ticker, TickerStats)>>,
+    combined_tickers: Vec<(Exchange, Ticker, TickerStats, bool)>,
+    favorited_tickers: Vec<(Exchange, Ticker)>,
+    display_cache: HashMap<(Exchange, Ticker), TickerDisplayData>,
+    selected_tab: TickerTab,
+    search_query: String,
+    show_sort_options: bool,
+    selected_sort_option: SortOptions,
+    selected_market: Option<MarketType>,
+    expand_ticker_card: Option<(Ticker, Exchange)>,
+    scroll_offset: AbsoluteOffset,
+}
+
+impl TickersTable {
+    pub fn new(favorited_tickers: Vec<(Exchange, Ticker)>) -> Self {
+        Self {
+            tickers_info: HashMap::new(),
+            combined_tickers: Vec::new(),
+            display_cache: HashMap::new(),
+            favorited_tickers,
+            selected_tab: TickerTab::All,
+            search_query: String::new(),
+            show_sort_options: false,
+            selected_sort_option: SortOptions::VolumeDesc,
+            expand_ticker_card: None,
+            scroll_offset: AbsoluteOffset::default(),
+            selected_market: None,
+        }
+    }
+
+    pub fn update_table(&mut self, exchange: Exchange, tickers_info: HashMap<Ticker, TickerStats>) {
+        self.display_cache.retain(|(ex, _), _| ex != &exchange);
+
+        let tickers_vec: Vec<_> = tickers_info
+            .into_iter()
+            .map(|(ticker, stats)| {
+                self.display_cache.insert(
+                    (exchange, ticker),
+                    Self::compute_display_data(&ticker, &stats),
+                );
+                (ticker, stats)
+            })
+            .collect();
+
+        self.tickers_info.insert(exchange, tickers_vec);
+        self.update_combined_tickers();
+    }
+
+    fn update_combined_tickers(&mut self) {
+        self.combined_tickers.clear();
+
+        self.tickers_info.iter().for_each(|(exchange, tickers)| {
+            for (ticker, stats) in tickers {
+                let is_fav = self
+                    .favorited_tickers
+                    .iter()
+                    .any(|(ex, tick)| ex == exchange && tick == ticker);
+                self.combined_tickers
+                    .push((*exchange, *ticker, *stats, is_fav));
+            }
+        });
+
+        match self.selected_sort_option {
+            SortOptions::VolumeDesc => {
+                self.combined_tickers
+                    .sort_by(|a: &(Exchange, Ticker, TickerStats, bool), b| {
+                        b.2.daily_volume
+                            .partial_cmp(&a.2.daily_volume)
+                            .unwrap_or(std::cmp::Ordering::Equal)
+                    })
+            }
+            SortOptions::VolumeAsc => {
+                self.combined_tickers
+                    .sort_by(|a: &(Exchange, Ticker, TickerStats, bool), b| {
+                        a.2.daily_volume
+                            .partial_cmp(&b.2.daily_volume)
+                            .unwrap_or(std::cmp::Ordering::Equal)
+                    })
+            }
+            SortOptions::ChangeDesc => {
+                self.combined_tickers
+                    .sort_by(|a: &(Exchange, Ticker, TickerStats, bool), b| {
+                        b.2.daily_price_chg
+                            .partial_cmp(&a.2.daily_price_chg)
+                            .unwrap_or(std::cmp::Ordering::Equal)
+                    })
+            }
+            SortOptions::ChangeAsc => {
+                self.combined_tickers
+                    .sort_by(|a: &(Exchange, Ticker, TickerStats, bool), b| {
+                        a.2.daily_price_chg
+                            .partial_cmp(&b.2.daily_price_chg)
+                            .unwrap_or(std::cmp::Ordering::Equal)
+                    })
+            }
+        }
+    }
+
+    fn change_sort_option(&mut self, option: SortOptions) {
+        if self.selected_sort_option != option {
+            self.selected_sort_option = option;
+        } else {
+            self.selected_sort_option = match self.selected_sort_option {
+                SortOptions::VolumeDesc => SortOptions::VolumeAsc,
+                SortOptions::VolumeAsc => SortOptions::VolumeDesc,
+                SortOptions::ChangeDesc => SortOptions::ChangeAsc,
+                SortOptions::ChangeAsc => SortOptions::ChangeDesc,
+            };
+        }
+
+        self.update_combined_tickers();
+    }
+
+    fn favorite_ticker(&mut self, exchange: Exchange, ticker: Ticker) {
+        for (ex, tick, _, is_fav) in &mut self.combined_tickers {
+            if ex == &exchange && tick == &ticker {
+                *is_fav = !*is_fav;
+            }
+        }
+
+        self.favorited_tickers = self
+            .combined_tickers
+            .iter()
+            .filter(|(_, _, _, is_fav)| *is_fav)
+            .map(|(exchange, ticker, _, _)| (*exchange, *ticker))
+            .collect();
+    }
+
+    pub fn get_favorited_tickers(&self) -> Vec<(Exchange, Ticker)> {
+        self.combined_tickers
+            .iter()
+            .filter(|(_, _, _, is_fav)| *is_fav)
+            .map(|(exchange, ticker, _, _)| (*exchange, *ticker))
+            .collect()
+    }
+
+    fn compute_display_data(ticker: &Ticker, stats: &TickerStats) -> TickerDisplayData {
+        let (ticker_str, market) = ticker.get_string();
+        let display_ticker = if ticker_str.len() >= 11 {
+            ticker_str[..9].to_string() + "..."
+        } else {
+            ticker_str + {
+                match market {
+                    MarketType::Spot => "",
+                    MarketType::LinearPerps => "P",
+                }
+            }
+        };
+
+        TickerDisplayData {
+            display_ticker,
+            price_change_display: convert_to_pct_change(stats.daily_price_chg).to_string(),
+            volume_display: convert_to_currency_abbr(stats.daily_volume).to_string(),
+            mark_price_display: stats.mark_price.to_string(),
+            card_color_alpha: { (stats.daily_price_chg / 8.0).clamp(-1.0, 1.0) },
+        }
+    }
+
+    fn matches_exchange(ex: &Exchange, tab: &TickerTab) -> bool {
+        match tab {
+            TickerTab::Bybit => matches!(ex, Exchange::BybitLinear | Exchange::BybitSpot),
+            TickerTab::Binance => matches!(ex, Exchange::BinanceFutures | Exchange::BinanceSpot),
+            _ => false,
+        }
+    }
+
+    fn create_ticker_container<'a>(
+        &'a self,
+        is_visible: bool,
+        exchange: Exchange,
+        ticker: &'a Ticker,
+        is_fav: bool,
+    ) -> Container<'a, Message> {
+        if !is_visible {
+            return container(column![].width(Length::Fill).height(Length::Fixed(60.0)));
+        }
+
+        let display_data = &self.display_cache[&(exchange, *ticker)];
+
+        container(
+            if let Some((selected_ticker, selected_exchange)) = &self.expand_ticker_card {
+                if ticker == selected_ticker && exchange == *selected_exchange {
+                    create_expanded_ticker_card(&exchange, ticker, display_data, is_fav)
+                } else {
+                    create_ticker_card(&exchange, ticker, display_data)
+                }
+            } else {
+                create_ticker_card(&exchange, ticker, display_data)
+            },
+        )
+        .style(move |theme| style::ticker_card(theme, display_data.card_color_alpha))
+    }
+
+    fn is_container_visible(&self, index: usize, bounds: Size) -> bool {
+        let ticker_container_height = 64.0;
+        let base_search_bar_height = 120.0;
+
+        let item_top = base_search_bar_height + (index as f32 * ticker_container_height);
+        let item_bottom = item_top + ticker_container_height;
+
+        (item_bottom >= (self.scroll_offset.y - (2.0 * ticker_container_height)))
+            && (item_top
+                <= (self.scroll_offset.y + bounds.height + (2.0 * ticker_container_height)))
+    }
+
+    pub fn update(&mut self, message: Message) -> Task<Message> {
+        match message {
+            Message::ChangeTickersTableTab(tab) => {
+                self.selected_tab = tab;
+            }
+            Message::UpdateSearchQuery(query) => {
+                self.search_query = query.to_uppercase();
+            }
+            Message::ChangeSortOption(option) => {
+                self.change_sort_option(option);
+            }
+            Message::ShowSortingOptions => {
+                self.show_sort_options = !self.show_sort_options;
+            }
+            Message::ExpandTickerCard(is_ticker) => {
+                self.expand_ticker_card = is_ticker;
+            }
+            Message::FavoriteTicker(exchange, ticker) => {
+                self.favorite_ticker(exchange, ticker);
+            }
+            Message::Scrolled(viewport) => {
+                self.scroll_offset = viewport.absolute_offset();
+            }
+            Message::SetMarketFilter(market) => {
+                if self.selected_market != market {
+                    self.selected_market = market;
+                } else {
+                    self.selected_market = None;
+                }
+            }
+            _ => {}
+        }
+        Task::none()
+    }
+
+    pub fn view(&self, bounds: Size) -> Element<'_, Message> {
+        let all_button = create_tab_button(text("ALL"), &self.selected_tab, TickerTab::All);
+        let bybit_button = create_tab_button(text("Bybit"), &self.selected_tab, TickerTab::Bybit);
+        let binance_button =
+            create_tab_button(text("Binance"), &self.selected_tab, TickerTab::Binance);
+        let favorites_button = create_tab_button(
+            text(char::from(Icon::StarFilled).to_string())
+                .font(ICON_FONT)
+                .width(11),
+            &self.selected_tab,
+            TickerTab::Favorites,
+        );
+
+        let spot_market_button = button(text("Spot"))
+            .on_press(Message::SetMarketFilter(Some(MarketType::Spot)))
+            .style(move |theme, status| style::button_transparent(theme, status, false));
+
+        let perp_market_button = button(text("Linear Perps"))
+            .on_press(Message::SetMarketFilter(Some(MarketType::LinearPerps)))
+            .style(move |theme, status| style::button_transparent(theme, status, false));
+
+        let show_sorting_button = button(get_icon_text(Icon::Sort, 14).align_x(Horizontal::Center))
+            .on_press(Message::ShowSortingOptions);
+
+        let volume_sort_button = button(
+            row![
+                text("Volume"),
+                get_icon_text(
+                    if self.selected_sort_option == SortOptions::VolumeDesc {
+                        Icon::SortDesc
+                    } else {
+                        Icon::SortAsc
+                    },
+                    14
+                )
+            ]
+            .spacing(4)
+            .align_y(Vertical::Center),
+        )
+        .on_press(Message::ChangeSortOption(SortOptions::VolumeAsc));
+
+        let change_sort_button = button(
+            row![
+                text("Change"),
+                get_icon_text(
+                    if self.selected_sort_option == SortOptions::ChangeDesc {
+                        Icon::SortDesc
+                    } else {
+                        Icon::SortAsc
+                    },
+                    14
+                )
+            ]
+            .spacing(4)
+            .align_y(Vertical::Center),
+        )
+        .on_press(Message::ChangeSortOption(SortOptions::ChangeAsc));
+
+        let mut content = column![
+            row![
+                text_input("Search for a ticker...", &self.search_query)
+                    .style(style::search_input)
+                    .on_input(Message::UpdateSearchQuery)
+                    .align_x(Horizontal::Left),
+                if self.show_sort_options {
+                    show_sorting_button
+                        .style(move |theme, status| style::button_transparent(theme, status, true))
+                } else {
+                    show_sorting_button
+                        .style(move |theme, status| style::button_transparent(theme, status, false))
+                }
+            ]
+            .align_y(Vertical::Center)
+            .spacing(4),
+            if self.show_sort_options {
+                container(column![
+                    row![
+                        Space::new(Length::FillPortion(2), Length::Shrink),
+                        match self.selected_sort_option {
+                            SortOptions::VolumeAsc | SortOptions::VolumeDesc => volume_sort_button
+                                .style(move |theme, status| style::button_transparent(
+                                    theme, status, true
+                                )),
+                            _ => volume_sort_button.style(move |theme, status| {
+                                style::button_transparent(theme, status, false)
+                            }),
+                        },
+                        Space::new(Length::FillPortion(1), Length::Shrink),
+                        match self.selected_sort_option {
+                            SortOptions::ChangeAsc | SortOptions::ChangeDesc => change_sort_button
+                                .style(move |theme, status| style::button_transparent(
+                                    theme, status, true
+                                )),
+                            _ => change_sort_button.style(move |theme, status| {
+                                style::button_transparent(theme, status, false)
+                            }),
+                        },
+                        Space::new(Length::FillPortion(2), Length::Shrink),
+                    ],
+                    row![
+                        Space::new(Length::FillPortion(1), Length::Shrink),
+                        match self.selected_market {
+                            Some(MarketType::Spot) => spot_market_button.style(move |theme, status| {
+                                style::button_transparent(theme, status, true)
+                            }),
+                            _ => spot_market_button.style(move |theme, status| {
+                                style::button_transparent(theme, status, false)
+                            }),
+                        },
+                        Space::new(Length::FillPortion(1), Length::Shrink),
+                        match self.selected_market {
+                            Some(MarketType::LinearPerps) => perp_market_button.style(move |theme, status| {
+                                style::button_transparent(theme, status, true)
+                            }),
+                            _ => perp_market_button.style(move |theme, status| {
+                                style::button_transparent(theme, status, false)
+                            }),
+                        },
+                        Space::new(Length::FillPortion(1), Length::Shrink),
+                    ],
+                ]
+                .spacing(4))
+                .padding(4)
+                .style(style::sorter_container)
+            } else {
+                container(column![])
+            },
+            row![
+                favorites_button,
+                Space::new(Length::FillPortion(1), Length::Shrink),
+                all_button,
+                Space::new(Length::FillPortion(1), Length::Shrink),
+                bybit_button,
+                Space::new(Length::FillPortion(1), Length::Shrink),
+                binance_button,
+            ]
+            .padding(padding::bottom(4)),
+        ]
+        .spacing(4)
+        .padding(padding::right(8))
+        .width(Length::Fill);
+
+        match self.selected_tab {
+            TickerTab::All => {
+                content =
+                    self.combined_tickers
+                        .iter()
+                        .filter(|(_, ticker, _, _)| {
+                            let (ticker, market) = ticker.get_string();
+                            ticker.contains(&self.search_query) && match self.selected_market {
+                                Some(market_type) => market == market_type,
+                                None => true,
+                            }
+                        })
+                        .enumerate()
+                        .fold(
+                            content,
+                            |content, (index, (exchange, ticker, _, is_fav))| {
+                                let is_visible = self.is_container_visible(index, bounds);
+                                content.push(self.create_ticker_container(
+                                    is_visible, *exchange, ticker, *is_fav,
+                                ))
+                            },
+                        );
+            }
+            TickerTab::Favorites => {
+                content = self
+                    .combined_tickers
+                    .iter()
+                    .filter(|(_, ticker, _, is_fav)| {
+                        let (ticker, market) = ticker.get_string();
+                        *is_fav && ticker.contains(&self.search_query) && match self.selected_market {
+                            Some(market_type) => market == market_type,
+                            None => true,
+                        }
+                    })
+                    .enumerate()
+                    .fold(
+                        content,
+                        |content, (index, (exchange, ticker, _, is_fav))| {
+                            let is_visible = self.is_container_visible(index, bounds);
+                            content.push(
+                                self.create_ticker_container(
+                                    is_visible, *exchange, ticker, *is_fav,
+                                ),
+                            )
+                        },
+                    );
+            }
+            _ => {
+                content = self
+                    .combined_tickers
+                    .iter()
+                    .filter(|(ex, ticker, _, _)| {
+                        let (ticker, market) = ticker.get_string();
+                        Self::matches_exchange(ex, &self.selected_tab)
+                            && ticker.contains(&self.search_query)
+                            && match self.selected_market {
+                                Some(market_type) => market == market_type,
+                                None => true,
+                            }
+                    })
+                    .enumerate()
+                    .fold(content, |content, (index, (ex, ticker, _, is_fav))| {
+                        let is_visible = self.is_container_visible(index, bounds);
+                        content.push(
+                            self.create_ticker_container(is_visible, *ex, ticker, *is_fav),
+                        )
+                    });
+            }
+        }
+
+        scrollable::Scrollable::with_direction(
+            content,
+            scrollable::Direction::Vertical(
+                scrollable::Scrollbar::new().width(8).scroller_width(6),
+            )
+        )
+        .on_scroll(Message::Scrolled)
+        .style(style::scroll_bar)
+        .into()
+    }
+}
+
+fn create_ticker_card<'a>(
+    exchange: &Exchange,
+    ticker: &Ticker,
+    display_data: &'a TickerDisplayData,
+) -> Column<'a, Message> {
+    let color_column = container(column![])
+        .height(Length::Fill)
+        .width(Length::Fixed(2.0))
+        .style(move |theme| style::ticker_card_bar(theme, display_data.card_color_alpha));
+
+    column![button(row![
+        color_column,
+        column![
+            row![
+                row![
+                    match exchange {
+                        Exchange::BybitLinear | Exchange::BybitSpot => get_icon_text(Icon::BybitLogo, 12),
+                        Exchange::BinanceFutures | Exchange::BinanceSpot => get_icon_text(Icon::BinanceLogo, 12),
+                    },
+                    text(&display_data.display_ticker),
+                ]
+                .spacing(2)
+                .align_y(alignment::Vertical::Center),
+                Space::new(Length::Fill, Length::Shrink),
+                text(&display_data.price_change_display),
+            ]
+            .spacing(4)
+            .align_y(alignment::Vertical::Center),
+            row![
+                text(&display_data.mark_price_display),
+                Space::new(Length::Fill, Length::Shrink),
+                text(&display_data.volume_display),
+            ]
+            .spacing(4),
+        ]
+        .padding(8)
+        .spacing(4),
+    ])
+    .style(style::ticker_card_button)
+    .on_press(Message::ExpandTickerCard(Some((*ticker, *exchange))))]
+    .height(Length::Fixed(60.0))
+}
+
+fn create_expanded_ticker_card<'a>(
+    exchange: &Exchange,
+    ticker: &Ticker,
+    display_data: &'a TickerDisplayData,
+    is_fav: bool,
+) -> Column<'a, Message> {
+    let (ticker_str, market) = ticker.get_string();
+
+    column![
+        row![
+            button(get_icon_text(Icon::Return, 11))
+                .on_press(Message::ExpandTickerCard(None))
+                .style(move |theme, status| style::button_transparent(theme, status, false)),
+            button(if is_fav {
+                get_icon_text(Icon::StarFilled, 11)
+            } else {
+                get_icon_text(Icon::Star, 11)
+            })
+            .on_press(Message::FavoriteTicker(*exchange, *ticker))
+            .style(move |theme, status| style::button_transparent(theme, status, false)),
+        ]
+        .spacing(2),
+        row![
+            match exchange {
+                Exchange::BybitLinear | Exchange::BybitSpot => get_icon_text(Icon::BybitLogo, 12),
+                Exchange::BinanceFutures | Exchange::BinanceSpot => get_icon_text(Icon::BinanceLogo, 12),
+            },
+            text(ticker_str + {
+                match market {
+                    MarketType::Spot => "",
+                    MarketType::LinearPerps => " Perp",
+                }
+            }),
+        ]
+        .spacing(2),
+        column![
+            row![
+                text("Last Updated Price: ").size(11),
+                Space::new(Length::Fill, Length::Shrink),
+                text(&display_data.mark_price_display)
+            ],
+            row![
+                text("Daily Change: ").size(11),
+                Space::new(Length::Fill, Length::Shrink),
+                text(&display_data.price_change_display),
+            ],
+            row![
+                text("Daily Volume: ").size(11),
+                Space::new(Length::Fill, Length::Shrink),
+                text(&display_data.volume_display),
+            ],
+        ]
+        .spacing(4),
+        column![
+            button(text("Heatmap Chart").align_x(Horizontal::Center))
+                .on_press(Message::TickerSelected(
+                    *ticker,
+                    *exchange,
+                    "heatmap".to_string()
+                ))
+                .width(Length::Fixed(180.0)),
+            button(text("Footprint Chart").align_x(Horizontal::Center))
+                .on_press(Message::TickerSelected(
+                    *ticker,
+                    *exchange,
+                    "footprint".to_string()
+                ))
+                .width(Length::Fixed(180.0)),
+            button(text("Candlestick Chart").align_x(Horizontal::Center))
+                .on_press(Message::TickerSelected(
+                    *ticker,
+                    *exchange,
+                    "candlestick".to_string()
+                ))
+                .width(Length::Fixed(180.0)),
+            button(text("Time&Sales").align_x(Horizontal::Center))
+                .on_press(Message::TickerSelected(
+                    *ticker,
+                    *exchange,
+                    "time&sales".to_string()
+                ))
+                .width(Length::Fixed(160.0)),
+        ]
+        .width(Length::Fill)
+        .spacing(2),
+    ]
+    .padding(padding::top(8).right(16).left(16).bottom(16))
+    .spacing(12)
+}
+
+fn create_tab_button<'a>(
+    text: Text<'a, Theme, Renderer>,
+    current_tab: &TickerTab,
+    target_tab: TickerTab,
+) -> Button<'a, Message, Theme, Renderer> {
+    let mut btn =
+        button(text).style(move |theme, status| style::button_transparent(theme, status, false));
+    if *current_tab != target_tab {
+        btn = btn.on_press(Message::ChangeTickersTableTab(target_tab));
+    }
+    btn
+}
+
+fn convert_to_currency_abbr(price: f32) -> String {
+    if price > 1_000_000_000.0 {
+        format!("${:.2}b", price / 1_000_000_000.0)
+    } else if price > 1_000_000.0 {
+        format!("${:.1}m", price / 1_000_000.0)
+    } else if price > 1000.0 {
+        format!("${:.2}k", price / 1000.0)
+    } else {
+        format!("${price:.2}")
+    }
+}
+
+fn convert_to_pct_change(change: f32) -> String {
+    if change > 0.0 {
+        format!("+{change:.2}%")
+    } else {
+        format!("{change:.2}%")
+    }
+}

+ 23 - 0
src/tooltip.rs

@@ -0,0 +1,23 @@
+use iced::widget::{container, text};
+
+use crate::style;
+
+pub use iced::widget::tooltip::Position;
+
+use super::Element;
+
+pub fn tooltip<'a, Message: 'a>(
+    content: impl Into<Element<'a, Message>>,
+    tooltip: Option<&'a str>,
+    position: Position,
+) -> Element<'a, Message> {
+    match tooltip {
+        Some(tooltip) => iced::widget::tooltip(
+            content,
+            container(text(tooltip)).style(style::tooltip).padding(8),
+            position,
+        )
+        .into(),
+        None => content.into(),
+    }
+}

+ 120 - 0
src/window.rs

@@ -0,0 +1,120 @@
+use std::collections::HashMap;
+
+use iced::{window, Point, Size, Subscription, Task};
+
+pub use iced::window::{close, open, Id, Position, Settings};
+use iced_futures::MaybeSend;
+
+#[allow(dead_code)]
+#[derive(Debug, Clone, Copy)]
+pub struct Window {
+    pub id: Id,
+    pub position: Option<Point>,
+    pub size: Size,
+    pub focused: bool,
+}
+
+#[allow(dead_code)]
+impl Window {
+    pub fn new(id: Id) -> Self {
+        Self {
+            id,
+            position: None,
+            size: Size::default(),
+            focused: false,
+        }
+    }
+
+    pub fn opened(&mut self, position: Option<Point>, size: Size) {
+        self.position = position;
+        self.size = size;
+        self.focused = true;
+    }
+
+    pub fn resized(&mut self, size: Size) {
+        self.size = size;
+    }
+
+    pub fn moved(&mut self, position: Point) {
+        self.position = Some(position);
+    }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub enum WindowEvent {
+    CloseRequested(window::Id),
+}
+
+pub fn window_events() -> Subscription<WindowEvent> {
+    iced::event::listen_with(filtered_events)
+}
+
+fn filtered_events(
+    event: iced::Event,
+    _status: iced::event::Status,
+    window: window::Id,
+) -> Option<WindowEvent> {
+    match &event {
+        iced::Event::Window(iced::window::Event::CloseRequested) => {
+            Some(WindowEvent::CloseRequested(window))
+        }
+        _ => None,
+    }
+}
+
+pub fn collect_window_specs<M, F>(window_ids: Vec<window::Id>, message: F) -> Task<M>
+where
+    F: Fn(HashMap<window::Id, (Point, Size)>) -> M + Send + 'static,
+    M: MaybeSend + 'static,
+{
+    // Create a task that collects specs for each window
+    let window_spec_tasks: Vec<Task<(window::Id, (Option<Point>, Size))>> = window_ids
+        .into_iter()
+        .map(|window_id| {
+            // Map both tasks to produce an enum or tuple to distinguish them
+            let pos_task: Task<(Option<Point>, Option<Size>)> =
+                iced::window::get_position(window_id).map(|pos| (pos, None));
+
+            let size_task: Task<(Option<Point>, Option<Size>)> =
+                iced::window::get_size(window_id).map(|size| (None, Some(size)));
+
+            Task::batch(vec![pos_task, size_task])
+                .collect()
+                .map(move |results| {
+                    let position = results.iter().find_map(|(pos, _)| *pos);
+                    let size = results
+                        .iter()
+                        .find_map(|(_, size)| *size)
+                        .unwrap_or_else(|| Size::new(1024.0, 768.0));
+
+                    (window_id, (position, size))
+                })
+        })
+        .collect();
+
+    // Batch all window tasks together and collect results
+    Task::batch(window_spec_tasks)
+        .collect()
+        .map(move |results| {
+            let specs: HashMap<window::Id, (Point, Size)> = results
+                .into_iter()
+                .filter_map(|(id, (pos, size))| pos.map(|position| (id, (position, size))))
+                .collect();
+
+            message(specs)
+        })
+}
+
+#[cfg(target_os = "macos")]
+pub fn settings() -> Settings {
+    use iced::window;
+
+    Settings {
+        platform_specific: window::settings::PlatformSpecific {
+            title_hidden: true,
+            titlebar_transparent: true,
+            fullsize_content_view: true,
+        },
+        ..Default::default()
+    }
+}

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно