From 68a04f28b06c7ad2169a7965a1f7e75eb66eafbe Mon Sep 17 00:00:00 2001 From: akenshaw Date: Wed, 21 Oct 2026 11:38:40 +0300 Subject: [PATCH 1/7] fix(exchange): update upstream TLS dependencies Upgrade certificate verification, bundled roots and the WebSocket TLS stack. Preserve Flowdepth dependencies and reconnect handling while adapting HTTPS proxy connections. Upstream-Commit: 0f870dc06091e56fb100700f85115a24c5466414 --- Cargo.lock | 151 +++++++--------------------------- exchange/Cargo.toml | 4 +- exchange/src/adapter/proxy.rs | 9 +- exchange/src/adapter/ws.rs | 17 ++-- 4 files changed, 44 insertions(+), 137 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 04b66981..d9d1ff4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1279,7 +1279,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1548,10 +1548,10 @@ dependencies = [ "sonic-rs", "thiserror 2.0.18", "tokio", - "tokio-rustls 0.24.1", + "tokio-rustls", "tokio-socks", "url", - "webpki-roots 0.23.1", + "webpki-roots", "zip", ] @@ -2049,11 +2049,11 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls 0.23.40", + "rustls", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower-service", - "webpki-roots 1.0.7", + "webpki-roots", ] [[package]] @@ -3573,7 +3573,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -4022,7 +4022,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.2", - "rustls 0.23.40", + "rustls", "socket2", "thiserror 2.0.18", "tokio", @@ -4032,17 +4032,17 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", "rand 0.9.4", - "ring 0.17.14", + "ring", "rustc-hash 2.1.2", - "rustls 0.23.40", + "rustls", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -4427,14 +4427,14 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.40", + "rustls", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -4442,7 +4442,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.7", + "webpki-roots", ] [[package]] @@ -4492,21 +4492,6 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "ring" -version = "0.16.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" -dependencies = [ - "cc", - "libc", - "once_cell", - "spin", - "untrusted 0.7.1", - "web-sys", - "winapi", -] - [[package]] name = "ring" version = "0.17.14" @@ -4517,7 +4502,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted 0.9.0", + "untrusted", "windows-sys 0.52.0", ] @@ -4613,31 +4598,20 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.21.12" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "log", - "ring 0.17.14", - "rustls-webpki 0.101.7", - "sct", -] - -[[package]] -name = "rustls" -version = "0.23.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" -dependencies = [ "once_cell", - "ring 0.17.14", + "ring", "rustls-pki-types", - "rustls-webpki 0.103.13", + "rustls-webpki", "subtle", "zeroize", ] @@ -4654,33 +4628,13 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.100.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6a5fc258f1c1276dfe3016516945546e2d5383911efc0fc4f1cdc5df3a4ae3" -dependencies = [ - "ring 0.16.20", - "untrusted 0.7.1", -] - -[[package]] -name = "rustls-webpki" -version = "0.101.7" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ - "ring 0.17.14", - "untrusted 0.9.0", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring 0.17.14", + "ring", "rustls-pki-types", - "untrusted 0.9.0", + "untrusted", ] [[package]] @@ -4716,16 +4670,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring 0.17.14", - "untrusted 0.9.0", -] - [[package]] name = "sctk-adwaita" version = "0.10.1" @@ -5027,7 +4971,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5100,12 +5044,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -5318,7 +5256,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5515,23 +5453,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.40", + "rustls", "tokio", ] @@ -5738,7 +5666,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5783,12 +5711,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - [[package]] name = "untrusted" version = "0.9.0" @@ -6170,18 +6092,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.23.1" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b03058f88386e5ff5310d9111d53f48b17d732b401aeb83a8d5190f2ac459338" -dependencies = [ - "rustls-webpki 0.100.3", -] - -[[package]] -name = "webpki-roots" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] @@ -6382,7 +6295,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/exchange/Cargo.toml b/exchange/Cargo.toml index 6dfa724c..a3b062de 100644 --- a/exchange/Cargo.toml +++ b/exchange/Cargo.toml @@ -34,8 +34,8 @@ fastwebsockets = { version = "0.10.0", default-features = false, features = ["up http-body-util = "0.1.3" hyper = { version = "1", default-features = false, features = ["http1", "client"] } hyper-util = { version = "0.1.20", default-features = false } -tokio-rustls = "0.24.1" -webpki-roots = "0.23.1" +tokio-rustls = { version = "0.26.4", default-features = false, features = ["logging", "ring", "tls12"] } +webpki-roots = "1.0.10" zip = { version = "8.6.0", default-features = false, features = ["deflate-flate2-zlib-rs"] } csv = "1.4.0" tokio-socks = "0.5.2" diff --git a/exchange/src/adapter/proxy.rs b/exchange/src/adapter/proxy.rs index baf3888b..b26e6dd0 100644 --- a/exchange/src/adapter/proxy.rs +++ b/exchange/src/adapter/proxy.rs @@ -433,10 +433,11 @@ impl Proxy { })? .map_err(|e| AdapterError::WebsocketError(e.to_string()))?; - let server_name: tokio_rustls::rustls::ServerName = - tokio_rustls::rustls::ServerName::try_from(self.host.as_str()).map_err( - |_| AdapterError::ParseError("invalid proxy dnsname".to_string()), - )?; + let server_name = + tokio_rustls::rustls::pki_types::ServerName::try_from(self.host.clone()) + .map_err(|_| { + AdapterError::ParseError("invalid proxy dnsname".to_string()) + })?; let mut tls = super::ws::TLS_CONNECTOR .connect(server_name, tcp) diff --git a/exchange/src/adapter/ws.rs b/exchange/src/adapter/ws.rs index 5cded9d8..56324caa 100644 --- a/exchange/src/adapter/ws.rs +++ b/exchange/src/adapter/ws.rs @@ -19,7 +19,7 @@ use tokio::io::{AsyncRead, AsyncWrite}; use tokio::time::Instant; use tokio_rustls::{ TlsConnector, - rustls::{ClientConfig, OwnedTrustAnchor}, + rustls::{ClientConfig, pki_types::ServerName}, }; use url::Url; @@ -42,18 +42,11 @@ pub(super) const TCP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const MAX_DRAIN_PER_TICK: usize = 256; pub(super) static TLS_CONNECTOR: LazyLock = LazyLock::new(|| { - 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 root_store = tokio_rustls::rustls::RootCertStore::from_iter( + webpki_roots::TLS_SERVER_ROOTS.iter().cloned(), + ); let config = ClientConfig::builder() - .with_safe_defaults() .with_root_certificates(root_store) .with_no_client_auth(); @@ -929,7 +922,7 @@ impl WsTransport { stream: ProxyStream, domain: &str, ) -> Result>, AdapterError> { - let server_name = tokio_rustls::rustls::ServerName::try_from(domain) + let server_name = ServerName::try_from(domain.to_owned()) .map_err(|_| AdapterError::ParseError("invalid dnsname".to_string()))?; let tls_stream = TLS_CONNECTOR From 13678f00195756154effbef8035f401d3c5ffebf Mon Sep 17 00:00:00 2001 From: akenshaw Date: Wed, 21 Oct 2026 08:48:25 +0300 Subject: [PATCH 2/7] fix(heatmap): smooth axis wheel and drag zoom Upstream-Commit: 82e3cf5186d8fdd56f745a118e379853be07b16e --- src/widget/chart/heatmap/ui.rs | 9 +++++++++ src/widget/chart/heatmap/ui/axisx.rs | 19 +++++++------------ src/widget/chart/heatmap/ui/axisy.rs | 10 ++-------- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/widget/chart/heatmap/ui.rs b/src/widget/chart/heatmap/ui.rs index 3a413099..0c9f07ca 100644 --- a/src/widget/chart/heatmap/ui.rs +++ b/src/widget/chart/heatmap/ui.rs @@ -166,6 +166,15 @@ fn world_y_for_y_bin_center(y_bin: i64, steps_per_y_bin: i64, row_h: f32) -> f32 -((y_bin as f32 + 0.5) * (steps_per_y_bin.max(1) as f32) * row_h) } +fn wheel_zoom_factor(delta: iced_core::mouse::ScrollDelta) -> f32 { + let amount = match delta { + iced_core::mouse::ScrollDelta::Lines { y, .. } => y * 0.02, + iced_core::mouse::ScrollDelta::Pixels { y, .. } => y * 0.002, + }; + + (1.0 + amount).clamp(0.01, 100.0) +} + fn paused_control_hovered( is_paused: bool, plot_bounds: Option, diff --git a/src/widget/chart/heatmap/ui/axisx.rs b/src/widget/chart/heatmap/ui/axisx.rs index 40577af9..165df777 100644 --- a/src/widget/chart/heatmap/ui/axisx.rs +++ b/src/widget/chart/heatmap/ui/axisx.rs @@ -8,7 +8,8 @@ use data::config::timezone::TimeLabelKind; use iced::{Rectangle, Renderer, Theme, widget::canvas}; use iced_core::mouse; -const DRAG_ZOOM_SENS: f32 = 0.005; +const MIN_DRAG_ZOOM_WIDTH_PX: f32 = 200.0; +const DRAG_ZOOM_SENS: f32 = 1.5; const PHASE_MAX: f64 = 0.999_999; const COL_EPS: f64 = 1e-18; const BUCKET_EPS: f64 = 1e-9; @@ -103,13 +104,13 @@ impl<'a> canvas::Program for AxisXLabelCanvas<'a> { let delta_px = *position - *last_position; *last_position = *position; - let scroll_amount = -delta_px.x * DRAG_ZOOM_SENS; - let factor = (1.0 + scroll_amount).clamp(0.01, 100.0); + let vw = self.plot_bounds.map(|r| r.width).unwrap_or(bounds.width); + let factor = (-delta_px.x * DRAG_ZOOM_SENS / vw.max(MIN_DRAG_ZOOM_WIDTH_PX)) + .exp() + .clamp(0.01, 100.0); match *zoom_anchor { Some(AxisZoomAnchor::World { screen, .. }) => { - let vw = self.plot_bounds.map(|r| r.width).unwrap_or(bounds.width); - Some(canvas::Action::publish(Message::DragZoomAxisXKeepAnchor { factor, anchor_screen_x: screen, @@ -131,15 +132,9 @@ impl<'a> canvas::Program for AxisXLabelCanvas<'a> { } iced::Event::Mouse(mouse::Event::WheelScrolled { delta }) => { let cursor_rel_pos = cursor.position_in(bounds)?; - let scroll_amount = match delta { - mouse::ScrollDelta::Lines { y, .. } => *y * 0.1, - mouse::ScrollDelta::Pixels { y, .. } => *y * 0.01, - }; - - let factor = (1.0 + scroll_amount).clamp(0.01, 100.0); Some(canvas::Action::publish(Message::ScrolledAxisX { - factor, + factor: super::wheel_zoom_factor(*delta), cursor_x: cursor_rel_pos.x, viewport_w: bounds.width, })) diff --git a/src/widget/chart/heatmap/ui/axisy.rs b/src/widget/chart/heatmap/ui/axisy.rs index dfcbd5dd..c73ecd99 100644 --- a/src/widget/chart/heatmap/ui/axisy.rs +++ b/src/widget/chart/heatmap/ui/axisy.rs @@ -7,7 +7,7 @@ use iced_core::mouse; /// Rough vertical spacing (in screen pixels) between Y-axis labels. const LABEL_TARGET_PX: f32 = 48.0; -const DRAG_ZOOM_SENS: f32 = 0.005; +const DRAG_ZOOM_SENS: f32 = 0.004; pub struct AxisYLabelCanvas<'a> { pub cache: &'a iced::widget::canvas::Cache, @@ -123,15 +123,9 @@ impl canvas::Program for AxisYLabelCanvas<'_> { } iced::Event::Mouse(mouse::Event::WheelScrolled { delta }) => { let p = cursor.position_in(bounds)?; - let scroll_amount = match delta { - mouse::ScrollDelta::Lines { y, .. } => *y * 0.1, - mouse::ScrollDelta::Pixels { y, .. } => *y * 0.01, - }; - - let factor = (1.0 + scroll_amount).clamp(0.01, 100.0); Some(canvas::Action::publish(Message::ScrolledAxisY { - factor, + factor: super::wheel_zoom_factor(*delta), cursor_y: p.y, viewport_h: bounds.height, })) From a54e0ef401ee2105bafe668d1a6af35c9182af0a Mon Sep 17 00:00:00 2001 From: akenshaw Date: Sat, 24 Oct 2026 09:42:30 +0300 Subject: [PATCH 3/7] fix(exchange): stabilize stream subscription ordering Upstream-Commit: 35610562c9b9c6242b654f814125c58818840105 --- exchange/src/adapter.rs | 17 ++++++++++++++--- exchange/src/lib.rs | 10 ++++++---- exchange/src/unit.rs | 2 +- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/exchange/src/adapter.rs b/exchange/src/adapter.rs index a4fb4092..48486389 100644 --- a/exchange/src/adapter.rs +++ b/exchange/src/adapter.rs @@ -187,9 +187,14 @@ impl UniqueStreams { }); } + /// Collects stream specs in a stable total order. + /// + /// `Subscription::run_with` hashes the chunked `StreamConfig` to identify a + /// stream, so the order must depend only on the set of streams. fn streams(&self, exchange_filter: Option, stream_extractor: F) -> Vec where F: Fn(Exchange, &StreamKind) -> Option, + T: Ord, { let f = &stream_extractor; @@ -201,10 +206,14 @@ impl UniqueStreams { .filter_map(move |stream| f(exchange, stream)) }; - match exchange_filter { + let mut specs: Vec = match exchange_filter { Some(exchange) => per_exchange(exchange).collect(), None => Exchange::ALL.into_iter().flat_map(per_exchange).collect(), - } + }; + + // Specs are unique, so the unstable sort is deterministic. + specs.sort_unstable(); + specs } pub fn depth_streams( @@ -233,7 +242,9 @@ impl UniqueStreams { } } -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)] +#[derive( + Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, PartialOrd, Ord, +)] pub enum StreamTicksize { ServerSide(TickMultiplier), #[default] diff --git a/exchange/src/lib.rs b/exchange/src/lib.rs index 9b5fabfb..ac39ea26 100644 --- a/exchange/src/lib.rs +++ b/exchange/src/lib.rs @@ -24,7 +24,9 @@ use std::{fmt, hash::Hash}; /// Maps user-selected update intervals to exchange-specific depth levels. /// Used for some exchanges that determine push frequency based on subscribed depth level /// (e.g., Bybit pushes every 300ms for 1000-level depth, 100ms for 200-level). -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)] +#[derive( + Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, PartialOrd, Ord, +)] pub enum PushFrequency { #[default] ServerDefault, @@ -278,7 +280,7 @@ impl fmt::Display for SerTicker { } } -#[derive(Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct Ticker { bytes: [u8; Ticker::MAX_LEN as usize], pub exchange: Exchange, @@ -515,7 +517,7 @@ pub enum StreamPairKind { MultiSource(Vec), } -#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize, Hash, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize, Hash, Eq, PartialOrd, Ord)] pub struct TickerInfo { pub ticker: Ticker, pub min_ticksize: MinTicksize, @@ -691,7 +693,7 @@ pub struct OpenInterest { pub value: f64, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Hash, PartialOrd, Ord)] pub struct TickMultiplier(pub u16); impl std::fmt::Display for TickMultiplier { diff --git a/exchange/src/unit.rs b/exchange/src/unit.rs index d67e799a..d066523f 100644 --- a/exchange/src/unit.rs +++ b/exchange/src/unit.rs @@ -10,7 +10,7 @@ pub type ContractSize = Power10<-4, 6>; pub type MinTicksize = Power10<-11, 2>; pub type MinQtySize = Power10<-6, 8>; -#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord)] pub struct Power10 { pub power: i8, } From 7a16828d68446dee6a93457cdbc11cce869820ee Mon Sep 17 00:00:00 2001 From: akenshaw Date: Wed, 21 Oct 2026 13:39:08 +0300 Subject: [PATCH 4/7] feat(indicators): add upstream catalog generation command Upstream-Commit: 4e3b93fef8a49b060260e540740e2e97fa526021 --- Cargo.lock | 2 + indicator-engine/Cargo.toml | 2 + indicator-engine/README.md | 30 +++ .../src/bin/flowsurface-indicator.rs | 178 +++++++++++++++++- 4 files changed, 211 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index d9d1ff4b..452ad5bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1560,7 +1560,9 @@ name = "flowsurface-indicator-engine" version = "0.8.10" dependencies = [ "chrono", + "ring", "serde", + "serde_json", "thiserror 2.0.18", "url", ] diff --git a/indicator-engine/Cargo.toml b/indicator-engine/Cargo.toml index 43799aab..6b0d5d41 100644 --- a/indicator-engine/Cargo.toml +++ b/indicator-engine/Cargo.toml @@ -10,5 +10,7 @@ homepage.workspace = true [dependencies] chrono.workspace = true serde = { version = "1.0.228", features = ["derive", "std"] } +serde_json.workspace = true +ring = "0.17.14" thiserror.workspace = true url.workspace = true diff --git a/indicator-engine/README.md b/indicator-engine/README.md index 01d1fdb7..a1e221ec 100644 --- a/indicator-engine/README.md +++ b/indicator-engine/README.md @@ -13,6 +13,7 @@ cargo build --locked -p flowsurface-indicator-engine --bin flowsurface-indicator target/debug/flowsurface-indicator new /tmp/my-indicators --id community.example --title "Example" target/debug/flowsurface-indicator check /tmp/my-indicators target/debug/flowsurface-indicator test /tmp/my-indicators +target/debug/flowsurface-indicator catalog /tmp/my-indicators --output /tmp/catalog.json ``` `new` creates `.ind` without overwriting an existing file. @@ -24,5 +25,34 @@ collection's namespace. the [application limits](LANGUAGE.md#resource-limits). Verify formulas, other input settings, performance on real histories, and chart appearance separately. +### Catalog generation + +`catalog --output [--namespace ]` +uses the same recursive discovery and compile-time validation as `check`. Invalid +sources, filenames, namespace mismatches, and duplicate IDs fail the whole export. +Run `test` separately for runtime smoke validation. + +The JSON contains `schema_version` (currently `1`), `generator_version`, +`language_version` (currently `1`), and an `indicators` array sorted by full ID. +Each entry contains: + +| Field | Meaning | +| ---------------------- | ------------------------------------------------------------------------------------------------- | +| `id`, `name` | Full indicator ID and declared title | +| `description` | Declared description, or `null` | +| `author` | Declared `{ "name": "...", "url": "..." }`, or `null` | +| `source_url` | Declared upstream source URL, or `null` | +| `path` | Source path relative to the input directory (or filename for a single file), using `/` separators | +| `sha256`, `size_bytes` | SHA-256 hex digest and byte count of the exact validated UTF-8 source | +| `bases` | Supported bar bases: `time`, `tick`, or both | + +The generator version records which engine validated the sources; it does not +claim a minimum compatible app version. + +Output is deterministic for the same sources and generator version, with no local +absolute paths or build timestamps. The output's parent directory must exist. +The command replaces the output only after validation, serialization, and writing +a temporary file beside it succeed. An `.ind` output filename is rejected. + The engine is [Apache-2.0 licensed](LICENSE). The application and bundled indicator sources are GPL-3.0-or-later; indicator source licenses are separate. diff --git a/indicator-engine/src/bin/flowsurface-indicator.rs b/indicator-engine/src/bin/flowsurface-indicator.rs index ab2490b7..d809a024 100644 --- a/indicator-engine/src/bin/flowsurface-indicator.rs +++ b/indicator-engine/src/bin/flowsurface-indicator.rs @@ -6,6 +6,7 @@ use flowsurface_indicator_engine::{ use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; use std::process; @@ -19,6 +20,7 @@ fn main() { Some("new") => command_new(&arguments[1..]), Some("check") => command_check(&arguments[1..], "check"), Some("test") => command_test(&arguments[1..]), + Some("catalog") => command_catalog(&arguments[1..]), Some(command) => Err(format!("unknown subcommand '{command}'")), None => Err(usage()), }; @@ -29,7 +31,7 @@ fn main() { } fn usage() -> String { - "usage: flowsurface-indicator ...\n\n flowsurface-indicator new [directory] --id --title \n flowsurface-indicator check <file.ind|directory> [--namespace <prefix>]\n flowsurface-indicator test <file.ind|directory> [--namespace <prefix>]" + "usage: flowsurface-indicator <new|check|test|catalog> ...\n\n flowsurface-indicator new [directory] --id <namespace.name> --title <Title>\n flowsurface-indicator check <file.ind|directory> [--namespace <prefix>]\n flowsurface-indicator test <file.ind|directory> [--namespace <prefix>]\n flowsurface-indicator catalog <file.ind|directory> --output <catalog.json> [--namespace <prefix>]" .to_owned() } @@ -139,6 +141,82 @@ fn command_test(arguments: &[String]) -> Result<(), String> { } } +fn command_catalog(arguments: &[String]) -> Result<(), String> { + if wants_help(arguments) { + println!( + "usage: flowsurface-indicator catalog <file.ind|directory> --output <catalog.json> [--namespace <prefix>]" + ); + return Ok(()); + } + + let CatalogArguments { + path, + namespace, + output, + } = parse_catalog_arguments(arguments)?; + + let paths = source_paths(&path)?; + let mut report = load_sources(&paths, namespace.as_deref()); + print_diagnostics(&report.diagnostics); + if !report.diagnostics.is_empty() { + return Err(failure_summary("catalog", report.diagnostics.len())); + } + + report.sources.sort_by(|left, right| { + left.indicator + .descriptor + .id + .cmp(&right.indicator.descriptor.id) + }); + + let root = if path.is_dir() { + path.as_path() + } else { + path.parent().unwrap_or_else(|| Path::new("")) + }; + let entries = report + .sources + .iter() + .map(|source| source.catalog_entry(root)) + .collect::<Result<Vec<_>, _>>()?; + let catalog = serde_json::json!({ + "schema_version": 1, + "generator_version": env!("CARGO_PKG_VERSION"), + "language_version": 1, + "indicators": entries, + }); + let mut json = serde_json::to_vec_pretty(&catalog).map_err(|error| error.to_string())?; + json.push(b'\n'); + + write_catalog(&output, &json)?; + println!( + "catalog written: {} ({})", + output.display(), + source_summary(report.sources.len()) + ); + Ok(()) +} + +fn write_catalog(output: &Path, json: &[u8]) -> Result<(), String> { + let name = output + .file_name() + .ok_or_else(|| "catalog output must name a file".to_owned())?; + let temporary = + output.with_file_name(format!(".{}.{}.tmp", name.to_string_lossy(), process::id())); + let mut file = fs::File::create_new(&temporary) + .map_err(|error| format!("cannot create {}: {error}", temporary.display()))?; + + let result = file.write_all(json); + drop(file); + + if let Err(error) = result.and_then(|()| fs::rename(&temporary, output)) { + let _ = fs::remove_file(&temporary); + return Err(format!("cannot write {}: {error}", output.display())); + } + + Ok(()) +} + fn validate_source_filename(path: &Path, id: &IndicatorId) -> Result<(), String> { let expected_file_name = id.source_file_name(); let actual_file_name = path @@ -160,9 +238,66 @@ fn validate_source_filename(path: &Path, id: &IndicatorId) -> Result<(), String> struct CompiledSource { path: PathBuf, + source: String, indicator: CompiledIndicator, } +impl CompiledSource { + fn catalog_entry(&self, root: &Path) -> Result<serde_json::Value, String> { + let relative = self + .path + .strip_prefix(root) + .map_err(|error| error.to_string())?; + let path = relative + .components() + .map(|component| { + component.as_os_str().to_str().ok_or_else(|| { + format!( + "{}: catalog source paths must be UTF-8", + self.path.display() + ) + }) + }) + .collect::<Result<Vec<_>, _>>()? + .join("/"); + + let digest = ring::digest::digest(&ring::digest::SHA256, self.source.as_bytes()); + let sha256 = digest + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::<String>(); + + let descriptor = &self.indicator.descriptor; + let author = descriptor + .author + .as_ref() + .map(|author| serde_json::json!({ "name": author.name, "url": author.url })); + let bases = descriptor + .bases + .as_deref() + .unwrap_or(&[TimeOrTick::Time, TimeOrTick::Tick]) + .iter() + .map(|basis| match basis { + TimeOrTick::Time => "time", + TimeOrTick::Tick => "tick", + }) + .collect::<Vec<_>>(); + + Ok(serde_json::json!({ + "id": descriptor.id.as_str(), + "name": descriptor.title, + "description": descriptor.description, + "author": author, + "source_url": descriptor.source, + "path": path, + "sha256": sha256, + "size_bytes": self.source.len(), + "bases": bases, + })) + } +} + struct SourceReport { sources: Vec<CompiledSource>, diagnostics: Vec<String>, @@ -253,6 +388,7 @@ fn load_sources(paths: &[PathBuf], namespace: Option<&str>) -> SourceReport { } report.sources.push(CompiledSource { path: path.clone(), + source, indicator, }); } @@ -409,6 +545,46 @@ fn parse_new_arguments(arguments: &[String]) -> Result<NewArguments, String> { }) } +struct CatalogArguments { + path: PathBuf, + namespace: Option<String>, + output: PathBuf, +} + +fn parse_catalog_arguments(arguments: &[String]) -> Result<CatalogArguments, String> { + let mut source_arguments = Vec::new(); + let mut output = None; + let mut index = 0; + + while index < arguments.len() { + if arguments[index] == "--output" { + let value = arguments + .get(index + 1) + .filter(|value| !value.starts_with('-')) + .ok_or_else(|| "catalog requires --output <catalog.json>".to_owned())?; + if output.replace(PathBuf::from(value)).is_some() { + return Err("catalog received duplicate --output".to_owned()); + } + index += 2; + } else { + source_arguments.push(arguments[index].clone()); + index += 1; + } + } + + let output = output.ok_or_else(|| "catalog requires --output <catalog.json>".to_owned())?; + if output.extension().and_then(|extension| extension.to_str()) == Some("ind") { + return Err("catalog output must not be an .ind source file".to_owned()); + } + + let (path, namespace) = parse_source_arguments(&source_arguments, "catalog")?; + Ok(CatalogArguments { + path, + namespace, + output, + }) +} + fn parse_source_arguments( arguments: &[String], command: &str, From a50cdee981ed092a7c86228d9d3c88417917f0a3 Mon Sep 17 00:00:00 2001 From: akenshaw <akenshaw@pm.me> Date: Sat, 24 Oct 2026 09:09:03 +0300 Subject: [PATCH 5/7] fix(charts): show drawing tool cursor state Adapt the upstream cursor behavior to Flowdepth drawing state. Upstream-Commit: 005caded90256fe38b8185cde5d3c537b401a0bd --- src/chart/kline.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/chart/kline.rs b/src/chart/kline.rs index b07988e3..39c47cb8 100644 --- a/src/chart/kline.rs +++ b/src/chart/kline.rs @@ -3057,7 +3057,14 @@ impl canvas::Program<Message> for KlineChart { Interaction::Zoomin { .. } => mouse::Interaction::ZoomIn, Interaction::None | Interaction::Ruler { .. } => { if cursor.is_over(bounds) { - mouse::Interaction::Crosshair + if self.drawings.active_tool != data::chart::kline::drawing::DrawingTool::Select + { + mouse::Interaction::Pointer + } else if self.drawings.selected.is_some() { + mouse::Interaction::default() + } else { + mouse::Interaction::Crosshair + } } else { mouse::Interaction::default() } From 63e7a4599d8f3d35fa93a0f7673ad6206879eda0 Mon Sep 17 00:00:00 2001 From: Niketion <nicolas.giuliani@hotmail.com> Date: Tue, 3 Nov 2026 18:17:16 +0100 Subject: [PATCH 6/7] test(upstream): cover stream ordering and catalog integrity --- exchange/src/adapter.rs | 63 +++++++++++++++++++ .../src/bin/flowsurface-indicator.rs | 59 +++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/exchange/src/adapter.rs b/exchange/src/adapter.rs index 48486389..63283407 100644 --- a/exchange/src/adapter.rs +++ b/exchange/src/adapter.rs @@ -255,6 +255,69 @@ fn default_depth_aggr() -> StreamTicksize { StreamTicksize::Client } +#[cfg(test)] +mod stream_order_tests { + use super::*; + + #[test] + fn subscription_specs_are_stable_across_pane_order_and_duplicates() { + let streams: Vec<_> = [Exchange::BinanceLinear, Exchange::BybitLinear] + .into_iter() + .flat_map(|exchange| { + (0..32).flat_map(move |index| { + let ticker_info = TickerInfo::new( + crate::Ticker::new(&format!("TEST{index}USDT"), exchange), + 0.1, + 0.001, + None, + ); + [ + StreamKind::Depth { + ticker_info, + depth_aggr: StreamTicksize::Client, + push_freq: PushFrequency::ServerDefault, + }, + StreamKind::Trades { ticker_info }, + StreamKind::Kline { + ticker_info, + timeframe: Timeframe::M1, + }, + ] + }) + }) + .collect(); + let original = UniqueStreams::from(streams.iter()); + let reordered = UniqueStreams::from(streams.iter().rev().chain(streams.iter())); + for filter in [ + None, + Some(Exchange::BinanceLinear), + Some(Exchange::BybitLinear), + ] { + assert_eq!( + original.depth_streams(filter), + reordered.depth_streams(filter) + ); + assert_eq!( + original.trade_streams(filter), + reordered.trade_streams(filter) + ); + assert_eq!( + original.kline_streams(filter), + reordered.kline_streams(filter) + ); + assert!(original.depth_streams(filter).is_sorted()); + assert!(original.trade_streams(filter).is_sorted()); + assert!(original.kline_streams(filter).is_sorted()); + } + for (exchange, specs) in original.combined_used() { + let other = reordered.combined()[exchange].as_ref().unwrap(); + assert_eq!(specs.depth, other.depth); + assert_eq!(specs.trade, other.trade); + assert_eq!(specs.kline, other.kline); + } + } +} + #[derive(Debug, Clone, Default)] pub struct StreamSpecs { pub depth: Vec<(TickerInfo, StreamTicksize, PushFrequency)>, diff --git a/indicator-engine/src/bin/flowsurface-indicator.rs b/indicator-engine/src/bin/flowsurface-indicator.rs index d809a024..9c021127 100644 --- a/indicator-engine/src/bin/flowsurface-indicator.rs +++ b/indicator-engine/src/bin/flowsurface-indicator.rs @@ -721,6 +721,65 @@ mod tests { values.iter().map(|value| (*value).to_owned()).collect() } + #[test] + fn catalog_is_deterministic_and_preserves_output_on_invalid_sources() { + let root = env::temp_dir().join(format!("flowsurface-catalog-{}", process::id())); + let sources = root.join("sources"); + fs::create_dir_all(&sources).unwrap(); + for name in ["zeta", "alpha"] { + command_new(&arguments(&[ + sources.to_str().unwrap(), + "--id", + &format!("test.{name}"), + "--title", + name, + ])) + .unwrap(); + } + let output = root.join("catalog.json"); + let args = arguments(&[ + sources.to_str().unwrap(), + "--output", + output.to_str().unwrap(), + "--namespace", + "test", + ]); + command_catalog(&args).unwrap(); + let first = fs::read(&output).unwrap(); + command_catalog(&args).unwrap(); + assert_eq!(first, fs::read(&output).unwrap()); + let catalog: serde_json::Value = serde_json::from_slice(&first).unwrap(); + let entries = catalog["indicators"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["id"], "test.alpha"); + assert_eq!(entries[1]["id"], "test.zeta"); + for entry in entries { + let relative = entry["path"].as_str().unwrap(); + assert!(!relative.contains('\\')); + let bytes = fs::read(sources.join(relative)).unwrap(); + let hash = ring::digest::digest(&ring::digest::SHA256, &bytes); + let expected: String = hash.as_ref().iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(entry["sha256"], expected); + assert_eq!(entry["size_bytes"], bytes.len()); + } + fs::write(sources.join("broken.ind"), "language(1)\nindicator(\n").unwrap(); + assert!(command_catalog(&args).is_err()); + assert_eq!(first, fs::read(&output).unwrap()); + fs::remove_file(sources.join("broken.ind")).unwrap(); + let source = sources.join("alpha.ind"); + let original = fs::read(&source).unwrap(); + assert!( + command_catalog(&arguments(&[ + sources.to_str().unwrap(), + "--output", + source.to_str().unwrap(), + ])) + .is_err() + ); + assert_eq!(original, fs::read(&source).unwrap()); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn cli_commands_validate_sources_and_create_without_overwriting() { let parsed = parse_new_arguments(&arguments(&[ From 3ad5733f93f95c32c2255bfd62f5e994d385c0b9 Mon Sep 17 00:00:00 2001 From: Niketion <nicolas.giuliani@hotmail.com> Date: Tue, 3 Nov 2026 18:58:41 +0100 Subject: [PATCH 7/7] fix(indicators): reject mixed-case source output extensions --- .../src/bin/flowsurface-indicator.rs | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/indicator-engine/src/bin/flowsurface-indicator.rs b/indicator-engine/src/bin/flowsurface-indicator.rs index 9c021127..9120ebf8 100644 --- a/indicator-engine/src/bin/flowsurface-indicator.rs +++ b/indicator-engine/src/bin/flowsurface-indicator.rs @@ -573,7 +573,11 @@ fn parse_catalog_arguments(arguments: &[String]) -> Result<CatalogArguments, Str } let output = output.ok_or_else(|| "catalog requires --output <catalog.json>".to_owned())?; - if output.extension().and_then(|extension| extension.to_str()) == Some("ind") { + if output + .extension() + .and_then(std::ffi::OsStr::to_str) + .is_some_and(|extension| extension.eq_ignore_ascii_case("ind")) + { return Err("catalog output must not be an .ind source file".to_owned()); } @@ -768,15 +772,19 @@ mod tests { fs::remove_file(sources.join("broken.ind")).unwrap(); let source = sources.join("alpha.ind"); let original = fs::read(&source).unwrap(); - assert!( - command_catalog(&arguments(&[ + for extension in ["ind", "IND", "Ind", "iND", "InD", "inD", "INd", "iNd"] { + let destination = source.with_extension(extension); + let result = command_catalog(&arguments(&[ sources.to_str().unwrap(), "--output", - source.to_str().unwrap(), - ])) - .is_err() - ); - assert_eq!(original, fs::read(&source).unwrap()); + destination.to_str().unwrap(), + ])); + assert!( + result.is_err(), + "source extension {extension} must be rejected" + ); + assert_eq!(original, fs::read(&source).unwrap()); + } fs::remove_dir_all(root).unwrap(); }