From 7c2701c5853ab5fd3f8c5878c2eaabddde4c7edc Mon Sep 17 00:00:00 2001 From: kirillDevPro <113171057+kirillDevPro@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:15:05 +0200 Subject: [PATCH 1/3] feat(chart): draw every same-exchange Auto Overview core's trades In Auto workspace mode, the Full summary state shows the whole group at once, but the chart's closed-trade history stayed pinned to the single core the coin was opened on. A trade the same account made on another core was simply absent from the picture, with nothing on screen to say so. A toggle on the chart strip and the detached header now widens the history to every Overview core trading on the SAME exchange as the chart's own core; the hover card names the core each trade came from. Another venue is excluded deliberately - its prices are a different market and its arrows would land beside the candles - and so is a core whose venue cannot be named. Default off, per chart tab, so an untouched chart behaves exactly as before. One function resolves the admitted core set and both the durable query and the per-pane draw filter consume that same vector, so the read and the drawing cannot disagree. The order-lines layer keeps its own single-core gate: a foreign record is forced "not lined" and keeps its arrows rather than widening that layer. The multi-core read sends NO_MATCH_CORE_UID for an empty admitted set, since an empty core list means "all cores" to the report filter. --- crates/moon-core/src/config/layout.rs | 10 + crates/moon-core/src/db/mod.rs | 3 +- crates/moon-core/src/db/report_read.rs | 44 ++- crates/moon-core/src/db/report_read/tests.rs | 169 +++++++++- .../src/chart_tabs/detached_host/render.rs | 18 +- .../src/chart_tabs/graphics_popup.rs | 41 ++- crates/moon-ui-gpui/src/chart_tabs/strip.rs | 19 +- .../src/chartdx/data_state/state.rs | 1 + crates/moon-ui-gpui/src/chartdx/engine.rs | 26 ++ crates/moon-ui-gpui/src/chartdx/mod.rs | 5 + .../src/chartdx/trade_history_sync.rs | 145 +++++++-- crates/moon-ui-gpui/src/panels/chart/mod.rs | 35 ++- .../src/panels/chart/report_trades.rs | 291 +++++++++++++++--- .../src/panels/chart/trade_history_hover.rs | 80 ++++- locales/shell.yml | 4 + 15 files changed, 796 insertions(+), 95 deletions(-) diff --git a/crates/moon-core/src/config/layout.rs b/crates/moon-core/src/config/layout.rs index 2569a261..bd0aea8c 100644 --- a/crates/moon-core/src/config/layout.rs +++ b/crates/moon-core/src/config/layout.rs @@ -1543,6 +1543,15 @@ pub struct ChartGraphicsCfg { /// keeps the picture it had. #[serde(default, deserialize_with = "de_trade_history_style")] pub trade_history_style: TradeHistoryStyle, + /// Draw the closed trades of every Auto-Overview core on the chart core's own exchange, + /// not only the chart's own core. + /// + /// OFF by default: an absent value keeps today's single-core picture, and ON would silently + /// add other cores' arrows to every existing Overview chart. The stored flag alone never + /// widens a read. The runtime gate (Auto + Overview, same exchange) is applied where the + /// request is built, so a stored `true` read outside Auto Overview still draws one core. + #[serde(default, deserialize_with = "de_lenient_false")] + pub history_all_cores: bool, /// Whether a CLOSED order hides its sell-price line. Live orders always keep theirs. /// /// On by default: after an order closes, its blue sell line stays on the chart at @@ -1731,6 +1740,7 @@ impl Default for ChartGraphicsCfg { show_real_trades: true, show_emulator_trades: true, trade_history_style: TradeHistoryStyle::Marks, + history_all_cores: false, hide_closed_sell_line: true, hide_order_move_history: false, hide_entry_fill_arrow: false, diff --git a/crates/moon-core/src/db/mod.rs b/crates/moon-core/src/db/mod.rs index 55d736ce..97c14c41 100644 --- a/crates/moon-core/src/db/mod.rs +++ b/crates/moon-core/src/db/mod.rs @@ -58,7 +58,8 @@ pub use report_read::{ ReportStrategyKey, ReportTable, ReportTotals, RowScope, SideFilter, StrategyPurgeRows, VALUATION_PROFIT_COLUMN, VALUATION_RATE_COLUMN, VALUATION_SOURCE_COLUMN, display_columns, distinct_cores, distinct_strategies, max_core_uid, open_rows_for_bound, - query_chart_trade_history, query_reports, query_totals, strategy_purge_rows, + query_chart_trade_history, query_chart_trade_history_for_cores, query_reports, query_totals, + strategy_purge_rows, }; pub use trade_meta::{TradeMeta, query_trade_meta}; diff --git a/crates/moon-core/src/db/report_read.rs b/crates/moon-core/src/db/report_read.rs index cfcfe51f..598b49f8 100644 --- a/crates/moon-core/src/db/report_read.rs +++ b/crates/moon-core/src/db/report_read.rs @@ -2164,7 +2164,13 @@ pub fn query_reports( /// and never references the `valuation` schema, so it does not pay for it. pub const CHART_TRADE_HISTORY_ATTACH: super::AttachSet = super::AttachSet::STRATEGIES_ONLY; -/// Read a bounded newest-first closed-trade history for one exact chart core and coin identity set. +/// Read a bounded newest-first closed-trade history for one exact chart market on an explicit +/// core set. +/// +/// `core_uids` is the chart's own core first, then every other core the caller admitted. An empty +/// slice means no core and is mapped to [`crate::config::NO_MATCH_CORE_UID`], not to every core: +/// [`ReportFilter::core_uids`] empty means the whole fleet, so a present-but-empty set must name +/// the sentinel or the read widens. /// /// The caller may provide a published Report filter to retain its date, side, emulator, deletion, /// and strategy predicates. This boundary always overwrites the core, substring coin, exact coin, @@ -2174,7 +2180,7 @@ pub const CHART_TRADE_HISTORY_ATTACH: super::AttachSet = super::AttachSet::STRAT /// /// Args: /// conn: Open report reader or pinned snapshot. -/// core_uid: Exact runtime core that owns the chart. +/// core_uids: Explicit runtime cores, the chart's own core first. Empty matches nothing. /// exact_coins: Case-insensitive stored coin identities accepted for the canonical market. /// filter: Optional published Report scope; `None` selects all durable closed trades. /// limit: Maximum returned records; one additional row detects truncation. @@ -2184,9 +2190,9 @@ pub const CHART_TRADE_HISTORY_ATTACH: super::AttachSet = super::AttachSet::STRAT /// /// Errors: /// Propagates replica readiness, schema, SQL, and row-conversion failures. -pub fn query_chart_trade_history( +pub fn query_chart_trade_history_for_cores( conn: &Connection, - core_uid: u64, + core_uids: &[u64], exact_coins: &[String], filter: Option<&ReportFilter>, limit: usize, @@ -2203,7 +2209,11 @@ pub fn query_chart_trade_history( "isshort", ]; let mut scope = filter.cloned().unwrap_or_default(); - scope.core_uids = vec![core_uid]; + scope.core_uids = if core_uids.is_empty() { + vec![crate::config::NO_MATCH_CORE_UID] + } else { + core_uids.to_vec() + }; scope.coin.clear(); scope.exact_coins = Some(exact_coins.to_vec()); scope.rows = RowScope::Closed; @@ -2412,6 +2422,30 @@ pub fn query_chart_trade_history( Ok(ChartTradeHistory { records, truncated }) } +/// Single-core form of [`query_chart_trade_history_for_cores`]: one chart core, not a set. +/// +/// Args: +/// conn: Open report reader or pinned snapshot. +/// core_uid: Exact runtime core that owns the chart. +/// exact_coins: Case-insensitive stored coin identities accepted for the canonical market. +/// filter: Optional published Report scope; `None` selects all durable closed trades. +/// limit: Maximum returned records; one additional row detects truncation. +/// +/// Returns: +/// Parsed chart records and whether older matches were truncated. +/// +/// Errors: +/// Propagates replica readiness, schema, SQL, and row-conversion failures. +pub fn query_chart_trade_history( + conn: &Connection, + core_uid: u64, + exact_coins: &[String], + filter: Option<&ReportFilter>, + limit: usize, +) -> ReadResult { + query_chart_trade_history_for_cores(conn, &[core_uid], exact_coins, filter, limit) +} + /// Convert one generic Report value to an integer without accepting lossy non-integral reals. /// /// Args: diff --git a/crates/moon-core/src/db/report_read/tests.rs b/crates/moon-core/src/db/report_read/tests.rs index 40037ec8..c6db9dd4 100644 --- a/crates/moon-core/src/db/report_read/tests.rs +++ b/crates/moon-core/src/db/report_read/tests.rs @@ -5,7 +5,7 @@ use rusqlite::{Connection, params}; use super::{ QuoteCurrency, ReportFilter, ReportStrategyKey, RowScope, SideFilter, distinct_strategies, - query_chart_trade_history, query_reports, query_totals, + query_chart_trade_history, query_chart_trade_history_for_cores, query_reports, query_totals, }; /// Removing the exact core, exact coin, or inclusive close-date predicate from @@ -2804,3 +2804,170 @@ fn chart_history_preserves_optional_millisecond_columns_and_legacy_rows() { "missing optional columns must project NULL for every legacy row" ); } + +thread_local! { + static CHART_HISTORY_SQL: std::cell::RefCell> = + std::cell::RefCell::new(Vec::new()); +} + +/// Keep expanded chart-history SELECTs so the test can `EXPLAIN` the statement the function prepared. +/// +/// Args: +/// event: One SQLite trace event from the connection under test. +/// +/// Returns: +/// Nothing. Matching statements are copied into the thread-local buffer. +fn capture_chart_history_sql(event: rusqlite::trace::TraceEvent<'_>) { + let rusqlite::trace::TraceEvent::Stmt(statement, _) = event else { + return; + }; + let Some(sql) = statement.expanded_sql() else { + return; + }; + let folded = sql.to_ascii_uppercase(); + if folded.contains("FROM") && folded.contains("ORDERS_REP") && folded.contains("CORE_UID") { + CHART_HISTORY_SQL.with(|slot| slot.borrow_mut().push(sql)); + } +} + +/// `report_read.rs:query_chart_trade_history_for_cores` must map an empty `core_uids` slice to +/// `NO_MATCH_CORE_UID`. Deleting the `if core_uids.is_empty()` arm and assigning +/// `core_uids.to_vec()` unconditionally makes a present-but-empty chart scope read every core in +/// the replica, so the chart draws the whole fleet's arrows with no error. +/// +/// The row oracle is the fixture's own record ids and close stamps. The plan oracle is SQLite's +/// `EXPLAIN QUERY PLAN` of the SELECT this function actually prepared, which must search +/// `idx_rep_core_close` rather than scan `orders_rep`. +#[test] +fn chart_history_empty_core_set_matches_nothing_and_multi_core_uses_index() { + let conn = Connection::open_in_memory().expect("open chart-history core-set fixture"); + conn.execute_batch( + "CREATE TABLE orders_rep ( + core_uid INTEGER NOT NULL, + newrecid INTEGER NOT NULL, + coin TEXT, + buydate INTEGER, + closedate INTEGER, + buyprice REAL, + sellprice REAL, + quantity REAL, + isshort INTEGER + ); + INSERT INTO orders_rep VALUES + (7, 11, 'BTCUSDT', 40, 100, 10.0, 11.0, 1.0, 0), + (7, 12, 'BTCUSDT', 50, 250, 10.0, 11.0, 1.0, 0), + (8, 22, 'BTCUSDT', 60, 300, 10.0, 11.0, 1.0, 0), + (9, 33, 'BTCUSDT', 70, 200, 10.0, 11.0, 1.0, 0);", + ) + .expect("seed three cores on one market"); + let seeded: i64 = conn + .query_row("SELECT COUNT(*) FROM orders_rep", [], |row| row.get(0)) + .expect("count seeded chart rows"); + assert_eq!( + seeded, 4, + "the fixture must hold four trades before the query" + ); + + let coins = ["BTCUSDT".to_string()]; + let empty = query_chart_trade_history_for_cores(&conn, &[], &coins, None, 10) + .expect("an empty core set is a successful no-match"); + assert!( + empty.records.is_empty(), + "empty core_uids must return no chart rows, got {:?}", + empty + .records + .iter() + .map(|record| record.record_id) + .collect::>() + ); + + let multi = query_chart_trade_history_for_cores(&conn, &[7, 8], &coins, None, 10) + .expect("query cores 7 and 8"); + assert_eq!( + multi + .records + .iter() + .map(|record| (record.record_id, record.core_uid, record.close_date)) + .collect::>(), + vec![(22, 8, 300), (12, 7, 250), (11, 7, 100)], + "cores 7 and 8 must both appear, newest close first, and core 9 must stay out" + ); + + let plan_conn = Connection::open_in_memory().expect("open chart-history plan fixture"); + plan_conn + .execute_batch( + "CREATE TABLE orders_rep ( + core_uid INTEGER NOT NULL, + newrecid INTEGER NOT NULL, + coin TEXT, + buydate INTEGER, + closedate INTEGER, + buyprice REAL, + sellprice REAL, + quantity REAL, + isshort INTEGER + ); + CREATE INDEX idx_rep_closedate ON orders_rep(closedate); + CREATE INDEX idx_rep_core_close ON orders_rep(core_uid, closedate);", + ) + .expect("create chart-history plan schema"); + { + let mut insert = plan_conn + .prepare( + "INSERT INTO orders_rep + (core_uid, newrecid, coin, buydate, closedate, buyprice, sellprice, quantity, isshort) + VALUES (?1, ?2, 'BTCUSDT', ?3, ?3, 10.0, 11.0, 1.0, 0)", + ) + .expect("prepare plan-fixture insert"); + // Cores 7 and 8 are a thin slice of a much larger third core. A balanced + // three-way split makes `core_uid IN (7, 8)` look cheaper as a scan. + for index in 0..8_080 { + let core = if index < 40 { + 7 + } else if index < 80 { + 8 + } else { + 9 + }; + let close = 1_700_000_000 + index; + insert + .execute(rusqlite::params![core, index + 1, close]) + .expect("insert plan-fixture row"); + } + } + plan_conn + .execute_batch("ANALYZE") + .expect("analyze chart-history plan fixture"); + CHART_HISTORY_SQL.with(|slot| slot.borrow_mut().clear()); + plan_conn.trace_v2( + rusqlite::trace::TraceEventCodes::SQLITE_TRACE_STMT, + Some(capture_chart_history_sql), + ); + let window = ReportFilter { + date_from: Some(1_700_000_000), + date_to: Some(1_700_008_080), + ..ReportFilter::default() + }; + query_chart_trade_history_for_cores(&plan_conn, &[7, 8], &coins, Some(&window), 10) + .expect("query the indexed multi-core window"); + plan_conn.trace_v2(rusqlite::trace::TraceEventCodes::SQLITE_TRACE_STMT, None); + let sql = CHART_HISTORY_SQL.with(|slot| slot.borrow().last().cloned()); + let sql = sql.expect("the multi-core chart query must prepare a SELECT"); + let mut explained = plan_conn + .prepare(&format!("EXPLAIN QUERY PLAN {sql}")) + .unwrap_or_else(|error| panic!("explain failed: {error}; sql: {sql}")); + let plan = explained + .query_map([], |row| row.get::<_, String>(3)) + .expect("read the query plan") + .map(|row| row.expect("plan row")) + .collect::>() + .join(" | "); + assert!( + plan.contains("idx_rep_core_close"), + "multi-core chart history must search idx_rep_core_close: {plan}; sql: {sql}" + ); + assert!( + !plan.contains("SCAN orders_rep") && !plan.contains("SCAN TABLE orders_rep"), + "multi-core chart history scanned orders_rep: {plan}" + ); +} diff --git a/crates/moon-ui-gpui/src/chart_tabs/detached_host/render.rs b/crates/moon-ui-gpui/src/chart_tabs/detached_host/render.rs index 156ddd06..d4ee3b4b 100644 --- a/crates/moon-ui-gpui/src/chart_tabs/detached_host/render.rs +++ b/crates/moon-ui-gpui/src/chart_tabs/detached_host/render.rs @@ -14,7 +14,7 @@ use rust_i18n::t; use super::super::candle_popup; use super::super::common; use super::super::common::LayoutPopupHost as _; -use super::super::graphics_popup; +use super::super::graphics_popup::{self, GraphicsPopupHost as _}; use super::super::history_popup; use super::super::labels_popup; use super::super::popup_slot::ChartPopup; @@ -107,6 +107,21 @@ impl Render for DetachedChartHost { let coin_search_live = self.popup_shows(ChartPopup::Coin) || self.coin_input.read(cx).focus_handle(cx).is_focused(window); let ends_search = coin_search_live.then(|| common::coin_toolbar_press_handler(cx)); + // Same toggle as the docked strip, and required here: apply-to-all can widen this window + // while it is the only chart on screen, and without the button the user cannot turn that + // off. Hidden outside Auto Overview, where the read stays single-core anyway. + let all_cores_btn = self + .backend + .read(cx) + .is_auto_overview_scope(&self.group) + .then(|| { + graphics_popup::all_cores_toggle_button( + &cx.entity(), + "detached-history-all-cores", + self.graphics_cfg(cx).history_all_cores, + ) + .render() + }); // The one button in this row that keeps a glyph: MoonUI ships no bin icon (its `delete.svg` // is a backspace key), and an X would read as "close the window" beside the real window // controls. So it is squared the way the column selectors are — a rendered width equal to @@ -299,6 +314,7 @@ impl Render for DetachedChartHost { .render(), cx, )) + .children(all_cores_btn) // The labels button edits THIS window's chart captions. .child(labels_popup::labels_popup_host( self, diff --git a/crates/moon-ui-gpui/src/chart_tabs/graphics_popup.rs b/crates/moon-ui-gpui/src/chart_tabs/graphics_popup.rs index 01611fab..da8e306e 100644 --- a/crates/moon-ui-gpui/src/chart_tabs/graphics_popup.rs +++ b/crates/moon-ui-gpui/src/chart_tabs/graphics_popup.rs @@ -23,7 +23,10 @@ use gpui::*; use moon_core::config::ChartGraphicsCfg; -use moon_ui::{MoonCheckbox, MoonPalette, MoonPopover, MoonPopoverPlacement, h_flex, v_flex}; +use moon_ui::{ + MoonButton, MoonButtonIconSlot, MoonButtonVariant, MoonCheckbox, MoonPalette, MoonPopover, + MoonPopoverPlacement, MoonSize, h_flex, v_flex, +}; use rust_i18n::t; use super::common::{LayoutPopupHost, StackSetting, seg_row}; @@ -137,6 +140,42 @@ pub(super) fn flag_cb( }) } +/// Build the Auto Overview toggle that widens closed-trade history to every same-exchange core. +/// +/// Host-generic so the docked strip and a detached window share one button, the way +/// [`flag_cb`] shares one checkbox. The element id stays per host: the two toolbars are on +/// screen together and a repeated id is a real GPUI collision. +/// +/// Args: +/// entity: Popup host whose graphics config the click writes. +/// id: Element id, unique to this host. +/// active: Whether the stored flag is currently on. +/// +/// Returns: +/// The icon-only button, not yet rendered. +pub(super) fn all_cores_toggle_button( + entity: &Entity, + id: &str, + active: bool, +) -> MoonButton { + let entity = entity.clone(); + MoonButton::new(SharedString::from(id.to_string())) + .leading_icon(MoonButtonIconSlot::new("icons/network.svg")) + .tooltip(t!("chart.history.all_cores.tip").to_string()) + .size(MoonSize::Xs) + .variant(if active { + MoonButtonVariant::Blue + } else { + MoonButtonVariant::Ghost + }) + .selected(active) + .on_click(move |_, _w, app| { + write_cfg(&entity, app, |c| { + c.history_all_cores = !c.history_all_cores; + }); + }) +} + /// Render popup content by reading the stored values on every render for the stateless controls. fn render_graphics_popup( id: &str, diff --git a/crates/moon-ui-gpui/src/chart_tabs/strip.rs b/crates/moon-ui-gpui/src/chart_tabs/strip.rs index 54ec87fc..1cdeafc2 100644 --- a/crates/moon-ui-gpui/src/chart_tabs/strip.rs +++ b/crates/moon-ui-gpui/src/chart_tabs/strip.rs @@ -15,7 +15,7 @@ use rust_i18n::t; use super::candle_popup; use super::common; use super::common::LayoutPopupHost as _; -use super::graphics_popup; +use super::graphics_popup::{self, GraphicsPopupHost as _}; use super::history_popup; use super::labels_popup; use super::popup_slot::ChartPopup; @@ -286,6 +286,22 @@ impl Render for ChartTabs { .render(), cx, ); + // Shown only in Auto Overview. Outside that state the history stays single-core whatever + // the stored flag says, so a hidden button is the control the user does not have. + // `write_cfg` is the popups' persistence path, including apply-to-all, so a detached + // window and this strip edit one flag. + let all_cores_btn = self + .backend + .read(cx) + .is_auto_overview_scope(&self.group) + .then(|| { + graphics_popup::all_cores_toggle_button( + &cx.entity(), + "chart-history-all-cores", + self.graphics_cfg(cx).history_all_cores, + ) + .render() + }); // The labels button beside the history one edits the ACTIVE TAB's chart captions. let labels_popup_open = self.popup_shows(ChartPopup::Labels); let labels_btn = labels_popup::labels_popup_host( @@ -485,6 +501,7 @@ impl Render for ChartTabs { .child(candle_btn) .child(graphics_btn) .child(history_btn) + .children(all_cores_btn) .child(labels_btn) .child(volumes_btn) .child(settings_btn), diff --git a/crates/moon-ui-gpui/src/chartdx/data_state/state.rs b/crates/moon-ui-gpui/src/chartdx/data_state/state.rs index 4b6ba6a7..a46a8c27 100644 --- a/crates/moon-ui-gpui/src/chartdx/data_state/state.rs +++ b/crates/moon-ui-gpui/src/chartdx/data_state/state.rs @@ -65,6 +65,7 @@ impl ChartDataState { news_marks: std::rc::Rc::new(Vec::new()), news_hovered: None, trade_history: std::rc::Rc::new(Vec::new()), + trade_history_cores: None, report_axis: moon_core::db::ReportAxis::identity_core_local(), trade_history_revision: 0, archived_lines: Rc::new(HashMap::new()), diff --git a/crates/moon-ui-gpui/src/chartdx/engine.rs b/crates/moon-ui-gpui/src/chartdx/engine.rs index 46893199..6414bef6 100644 --- a/crates/moon-ui-gpui/src/chartdx/engine.rs +++ b/crates/moon-ui-gpui/src/chartdx/engine.rs @@ -561,6 +561,20 @@ impl ChartEngine { self.data.borrow_mut().set_trade_history(records) } + /// Replace the admitted core set those markers may widen to. + /// + /// Args: + /// cores: The panel's admitted set, or `None` for own-core only. + /// + /// Returns: + /// Whether the set changed. + pub(crate) fn set_trade_history_cores( + &mut self, + cores: Option>, + ) -> bool { + self.data.borrow_mut().set_trade_history_cores(cores) + } + /// Hand this engine the archived lines of its closed trades, for the "Moonbot lines" style. /// /// Args: @@ -680,6 +694,18 @@ impl ChartEngine { read(&self.data.borrow().trade_history) } + /// The admitted core set published with the durable history, if the panel handed one. + /// + /// `None` means own-core only. The `Rc` is cloned; the set is not. + /// + /// Returns: + /// The published set, or `None` when the panel never handed one. + pub(crate) fn trade_history_cores( + &self, + ) -> Option> { + self.data.borrow().trade_history_cores.clone() + } + /// Read the report axis this engine's closed-trade stamps are currently corrected on. /// /// Handed to a closure rather than a returning clone, for the same reason diff --git a/crates/moon-ui-gpui/src/chartdx/mod.rs b/crates/moon-ui-gpui/src/chartdx/mod.rs index 5deb152a..71ff7d42 100644 --- a/crates/moon-ui-gpui/src/chartdx/mod.rs +++ b/crates/moon-ui-gpui/src/chartdx/mod.rs @@ -1593,6 +1593,11 @@ struct ChartDataState { news_hovered: Option, /// Durable closed trades for this exact Main chart target. trade_history: std::rc::Rc>, + /// Cores the panel admitted for `trade_history`, or `None` when no set was handed over. + /// + /// `None` is every panel that was never handed a set — every panel today, and the frozen + /// Trade window always — and `None` means own-core only. + trade_history_cores: Option>, /// The time axis this engine's replicated closed-trade stamps are corrected on. /// /// The replica stores `buydate`/`closedate` on the CORE's own wall clock, while the chart diff --git a/crates/moon-ui-gpui/src/chartdx/trade_history_sync.rs b/crates/moon-ui-gpui/src/chartdx/trade_history_sync.rs index bf9d4b3f..a7dae601 100644 --- a/crates/moon-ui-gpui/src/chartdx/trade_history_sync.rs +++ b/crates/moon-ui-gpui/src/chartdx/trade_history_sync.rs @@ -5,7 +5,8 @@ //! integration suite cannot import this binary crate's items (hence the static text contracts in //! `tests/theme_contract/`). A `src/**/tests.rs` sibling unit-test module, like this file's own, //! compiles and runs normally and is where a private free function belongs. What stays here is -//! only what needs the chart's own state: filtering to the pane's core, rebasing timestamps onto +//! only what needs the chart's own state: filtering each pane to its own core and, when that pane +//! owns the history request, to the admitted set, rebasing timestamps onto //! the chart epoch, resolving theme colours, publishing hover state, and retaining the exact //! cluster snapshot uploaded for hit-testing. @@ -56,6 +57,39 @@ pub(super) fn trade_kind_visible( } } +/// Whether one closed-trade record may draw on this pane. +/// +/// A panel's `trade_history` is ONE list loaded for ONE `(core, market)` target, and a multi-pane +/// panel (the Compare kind) draws that same list on every pane. Only the pane whose core OWNS the +/// request may widen to the admitted set; every other pane keeps today's own-core rule, or a +/// Compare pane would draw the anchor pane's foreign trades. That is why `owner == pane` is +/// load-bearing. +/// +/// Once the panel is handed a widened set, a non-owner Compare pane begins drawing ITS OWN core's +/// rows out of that shared list, where today it draws nothing — today the list holds only the +/// owner's rows. That is deliberate and strictly flag-gated: with the toggle off the admitted set +/// is just the owner, so a follower pane draws nothing exactly as it does now. `owner == pane` +/// still stops a follower from drawing OTHER cores' trades. +/// +/// `None` is own-core only. That is a panel which has not loaded a target yet, and the frozen +/// Trade window, which publishes records but never hands over a core set. A loaded panel stores +/// `Some` even when the flag is off and the set is just the owner; that set draws the same rows +/// `None` would. +/// +/// Args: +/// pane: The pane's own core. +/// record: The record's core. +/// cores: The set the panel was handed, or `None` when it never handed one. +/// +/// Returns: +/// Whether the record draws on this pane. +fn pane_admits_record(pane: CoreId, record: CoreId, cores: Option<&TradeHistoryCores>) -> bool { + if record == pane { + return true; + } + matches!(cores, Some(c) if c.owner == pane && c.admitted.contains(&record)) +} + /// Lift a record's core-local entry/exit stamps onto the chart's true-UTC millisecond axis. /// /// The conversion (correct the seconds part once, re-attach any sub-second remainder) lives @@ -98,6 +132,18 @@ fn trade_mark_with( } } +/// The cores one panel's trade-history list was loaded for. +/// +/// `owner` is the core whose `(core, market)` request loaded that list. `admitted` is the full +/// set the request was made for, the owner first. +#[derive(Debug, PartialEq)] +pub(crate) struct TradeHistoryCores { + /// Core whose request loaded the panel's record list. + pub(crate) owner: CoreId, + /// Every core that request covered, `owner` first. + pub(crate) admitted: Vec, +} + /// Everything a pane must retain about the trade arrows it currently has on the GPU. /// /// The two halves travel together because neither is usable alone: the clusters say WHERE the @@ -110,11 +156,14 @@ pub(crate) struct TradeGeometry { pub clusters: Vec, /// For each entry of this pane's FILTERED mark list, its index in the panel's record list. /// - /// A pane draws only the trades of its OWN core, so its mark indices — which is what - /// `TradeCluster::members` holds — are not the panel's indices. Two panes on two cores - /// therefore disagree about what "member 3" means, and the hover card would show the wrong - /// trades without this map. Carrying the map rather than a record id also sidesteps the legacy - /// rows whose id column collapses to `0`, which cannot tell two trades apart at all. + /// A pane draws its own core's trades and, when it owns the history request and the panel was + /// handed an admitted set, that set's trades. Its mark indices — which is what + /// `TradeCluster::members` holds — stay per pane, so they are not the panel's indices: two + /// panes filter the same list differently and disagree about what "member 3" means, and the + /// hover card would show the wrong trades without this map. `sources` still sends those + /// indices back to the panel's record list. Carrying the map rather than a record id also + /// sidesteps the legacy rows whose id column collapses to `0`, which cannot tell two trades + /// apart at all. pub sources: Vec, } @@ -122,8 +171,8 @@ impl ChartDataState { /// Mark every pane's trade-history geometry dirty and request a present. /// /// The shared body of every setter here that invalidates the userdata pass rather than a - /// single pane: `set_trade_history`, `set_trade_hover`, and `set_report_axis` all need - /// exactly this. + /// single pane: `set_trade_history`, `set_trade_history_cores`, `set_trade_hover`, and + /// `set_report_axis` all need exactly this. fn dirty_all_trade_panes(&mut self) { let mut render = self.render.borrow_mut(); for pane in &mut render.panes { @@ -154,6 +203,28 @@ impl ChartDataState { true } + /// Replace the admitted core set the trade-history filter may widen to. + /// + /// `None` is own-core only: every panel until one hands a set, and the frozen Trade window + /// always. A real change bumps `trade_history_revision` and dirties every pane, because + /// `trade_history_sig` is what decides whether a pane rebuilds its geometry — a set that + /// changed without moving that signature would leave the old arrows on screen. + /// + /// Args: + /// cores: The panel's admitted set, or `None` for own-core only. + /// + /// Returns: + /// Whether the set changed. + pub(super) fn set_trade_history_cores(&mut self, cores: Option>) -> bool { + if self.trade_history_cores == cores { + return false; + } + self.trade_history_cores = cores; + self.trade_history_revision = self.trade_history_revision.wrapping_add(1); + self.dirty_all_trade_panes(); + true + } + /// Replace the report axis this engine's closed-trade stamps are corrected on. /// /// Args: @@ -172,7 +243,8 @@ impl ChartDataState { /// Replace the hovered arrow and invalidate userdata only on a real change. /// - /// The hover is qualified by PANE, not merely by mark: each pane draws only its own core's + /// The hover is qualified by PANE, not merely by mark: a pane draws its own core's trades and, + /// when it owns the history request and the panel was handed an admitted set, that set's /// trades, so a bare index names a different trade on every pane and would grow an unrelated /// marker on all the others. It is also a MARK rather than a cluster index, so that the /// rebuild this very call triggers cannot move the highlight onto a neighbouring arrow — see @@ -234,11 +306,22 @@ impl ChartDataState { if sig == u64::MAX { 0 } else { sig } } - /// Append entry/exit arrows and their connectors for records owned by this exact pane core. + /// Append entry/exit arrows and their connectors for the trades this pane may draw. + /// + /// A pane draws its own core's trades and, when it owns the history request and the panel was + /// handed an admitted set, that set's trades. Mark indices stay per pane, and `sources` maps + /// them back to the panel's record list. + /// + /// In the Moonbot-lines style an admitted record the pane does not own keeps both arrows. + /// The archived exit line is built only for the pane's own core, while a lined end loses its + /// arrow, so treating a foreign trade as lined would hide its exit. Own-core trades stay + /// lines; foreign admitted trades stay arrows. Widening the archived-line layer is outside + /// this feature. /// /// Args: /// pane: Index of the pane being composed, which decides whether it owns the hovered arrow. - /// core: Exact pane core; records from other cores are ignored. + /// core: The pane's own core. A foreign record draws only when this pane owns the history + /// request and the record's core is in the admitted set. /// view: The pane's own view, supplying the epoch and the scale clustering works in. /// markers: Existing order/figure/news marker union to extend. /// lines_drawn: `Some` when this pane's order pass draws the archived lines, so the @@ -264,13 +347,15 @@ impl ChartDataState { return TradeGeometry::default(); } // When the order pass draws the closed trades as lines, an END drawn as a line loses its - // arrow, which would sit on top of it: one or the other, per end. The EXIT is always a - // line — from the archive or from the row itself — so its arrow never draws in that - // style; the ENTRY is a line only when the core archived its own entry line (it archives - // only a line its chart gave a point, so a market entry usually has none) and keeps its - // arrow otherwise. The CALLER says whether the lines are drawn — a pane without a core - // runs no order pass at all, and its arrows stay — and names the trades that closed this - // session, which the live store draws whole: no arrow for either of their ends. + // arrow, which would sit on top of it: one or the other, per end. For the pane's OWN core + // the EXIT is a line — from the archive or from the row itself — so its arrow never draws + // in that style; the ENTRY is a line only when the core archived its own entry line (it + // archives only a line its chart gave a point, so a market entry usually has none) and + // keeps its arrow otherwise. A record the pane admits but does not own is not lined: the + // archived exit line is never built for a foreign core, and dropping its arrow would hide + // the exit. The CALLER says whether the lines are drawn — a pane without a core runs no + // order pass at all, and its arrows stay — and names the trades that closed this session, + // which the live store draws whole: no arrow for either of their ends. let epoch_ms = view.epoch_ms; let mut sources = Vec::new(); // The replica stores seconds and, when the core supplied them, milliseconds; every other @@ -279,18 +364,28 @@ impl ChartDataState { .trade_history .iter() .enumerate() - .filter(|(_, record)| record.core_uid == core) + .filter(|(_, record)| { + pane_admits_record(core, record.core_uid, self.trade_history_cores.as_deref()) + }) .filter(|(_, record)| trade_kind_visible(&self.chart_graphics, record.emulator)) .filter_map(|(index, record)| { // On a live chart a twin of a live closed order draws both its lines from the // session store; on a frozen viewer the list names what the frozen store draws, - // and the ends are decided per trade — see `archived_line_ends`. - let (entry_lined, exit_lined) = match lines_drawn { - Some(twins) if self.draws_live_market() && self.is_live_twin(record, twins) => { - (true, true) + // and the ends are decided per trade — see `archived_line_ends`. A record this + // pane admits but does not own never reaches that: it keeps both arrows. + let owned = record.core_uid == core; + let (entry_lined, exit_lined) = if owned { + match lines_drawn { + Some(twins) + if self.draws_live_market() && self.is_live_twin(record, twins) => + { + (true, true) + } + Some(drawn) => self.archived_line_ends(record, drawn), + None => (false, false), } - Some(drawn) => self.archived_line_ends(record, drawn), - None => (false, false), + } else { + (false, false) }; if entry_lined && exit_lined { return None; diff --git a/crates/moon-ui-gpui/src/panels/chart/mod.rs b/crates/moon-ui-gpui/src/panels/chart/mod.rs index 2c873605..e50c8ff1 100644 --- a/crates/moon-ui-gpui/src/panels/chart/mod.rs +++ b/crates/moon-ui-gpui/src/panels/chart/mod.rs @@ -591,6 +591,19 @@ impl ChartPanel { this.sync_trace_lines(false, cx); }) .detach(); + let workspace_revision = backend.read(cx).workspace_revision(); + cx.observe(&workspace_revision, |this, _revision, cx| { + this.requery_trade_history_on_core_scope(cx); + }) + .detach(); + let market_data_revision = backend.read(cx).market_data_revision(); + cx.observe(&market_data_revision, |this, _revision, cx| { + // A sibling's catalog can become resolvable without a Backend notification or a + // workspace change. In PerCore that catalog is its own, so the admitted aliases have + // to be collected again or that sibling never enters the read. + this.requery_trade_history_on_core_scope(cx); + }) + .detach(); // Notify for setting changes and infrequent axis text. Frequent market data bypasses GPUI // notification because `gpu_canvas.frame()` reads MarketDataSource directly. A local // one-shot timer handles time-based pane TTL independently of backend observations. @@ -620,6 +633,7 @@ impl ChartPanel { // place such a panel hears about it, so the trade-kind re-query hangs here too; it // returns immediately unless that pair actually moved. this.requery_trade_history_on_trade_kinds(cx); + this.requery_trade_history_on_core_scope(cx); // ...and the trade style lives beside them: a default flipped to lines elsewhere // has to start resolving here too. this.request_trace_lines(true, cx); @@ -805,6 +819,19 @@ impl ChartPanel { this.sync_trace_lines(false, cx); }) .detach(); + let workspace_revision = backend.read(cx).workspace_revision(); + cx.observe(&workspace_revision, |this, _revision, cx| { + this.requery_trade_history_on_core_scope(cx); + }) + .detach(); + let market_data_revision = backend.read(cx).market_data_revision(); + cx.observe(&market_data_revision, |this, _revision, cx| { + // A sibling's catalog can become resolvable without a Backend notification or a + // workspace change. In PerCore that catalog is its own, so the admitted aliases have + // to be collected again or that sibling never enters the read. + this.requery_trade_history_on_core_scope(cx); + }) + .detach(); cx.observe(&backend, |this, backend, cx| { let now = Instant::now(); let (sig, settings_sig, panic_rev, fav_rev) = { @@ -829,6 +856,7 @@ impl ChartPanel { // own hears a ⧉ press from another group window only here, and the durable history // query was narrowed by the previous trade-kind pair. this.requery_trade_history_on_trade_kinds(cx); + this.requery_trade_history_on_core_scope(cx); // ...and the trade style lives beside them: a default flipped to lines elsewhere // has to start resolving here too. this.request_trace_lines(true, cx); @@ -1457,6 +1485,7 @@ impl ChartPanel { ) }; self.requery_trade_history_on_trade_kinds(cx); + self.requery_trade_history_on_core_scope(cx); // The style may have flipped to lines: resolve and hand over what is already resolved. // The engine's own graphics update happens on render, before its next order pass. self.request_trace_lines(true, cx); @@ -1788,8 +1817,12 @@ impl ChartPanel { self.settings_sig = settings_sig; self.view_dirty = true; // For the reason the backend observer does it: the durable trade-history query is narrowed - // by the drawn trade kinds, and those live in the graphics settings this just changed. + // by the drawn trade kinds, and the admitted core set follows the kind's stored + // `history_all_cores`. Both live in the graphics settings this just changed. This method + // stamps `settings_sig` itself, so the observer branch that would have re-read them does + // not run. self.requery_trade_history_on_trade_kinds(cx); + self.requery_trade_history_on_core_scope(cx); // The style may have flipped to lines: resolve and hand over what is already resolved. // The engine's own graphics update happens on render, before its next order pass. self.request_trace_lines(true, cx); diff --git a/crates/moon-ui-gpui/src/panels/chart/report_trades.rs b/crates/moon-ui-gpui/src/panels/chart/report_trades.rs index 3b4cbe03..bbc2c156 100644 --- a/crates/moon-ui-gpui/src/panels/chart/report_trades.rs +++ b/crates/moon-ui-gpui/src/panels/chart/report_trades.rs @@ -1,4 +1,8 @@ //! Runtime durable closed-trade loading for one exact Main-chart target. +//! +//! With `history_all_cores` on, and only while the panel's group is in Auto Overview, the read +//! widens to every core of that overview on the chart core's own exchange. The stored flag alone +//! never widens: the gate is applied here, where the request is built. use std::rc::Rc; use std::time::{Duration, Instant}; @@ -9,8 +13,12 @@ use moon_core::session::CoreId; use rust_i18n::t; use super::ChartPanel; +use crate::Backend; use crate::backend::ChartHistoryScope; +use crate::chartdx::trade_history_sync::TradeHistoryCores; +use crate::core_order::{ExchangeSection, section_of}; use crate::load_state::{db_read_failed_hint, db_read_failed_retryable}; +use crate::workspace::RetainedCoreScope; /// Maximum durable rows drawn for one Main chart. const HISTORY_LIMIT: usize = 1_000; @@ -169,6 +177,19 @@ pub(super) struct ReportTradesState { /// trip — and "something is drawn", which needs the set that was never fetched. Remembering the /// single boolean is what makes that re-read fire on that transition and on nothing else. last_admitted_any: Option, + /// Cores the current history request was made for, the chart's own core first. + /// + /// Remembered beside [`Self::last_admitted_any`] so a later wake can tell a changed admitted + /// set from the one already loaded. Empty means no request has settled. A different set is a + /// different request: the redundancy check compares it, or a wider read would be swallowed + /// as the same target. + cores: Vec, + /// Coin aliases the last history read was built from, in query order. + /// + /// A sibling can already sit in [`Self::cores`] while its catalog still spells the coin as a + /// name fallback. The catalog wake does not change the ids, only this spelling, and the read + /// is the only place the aliases are rebuilt — so the wake has to compare this too. + exact_coins: Vec, pub(super) status: ReportTradesStatus, } @@ -188,10 +209,62 @@ fn draws_any_trade_kind(graphics: &moon_core::config::ChartGraphicsCfg) -> bool graphics.show_real_trades || graphics.show_emulator_trades } +/// Cores whose closed trades this chart may draw, the chart's own core first. +/// +/// One answer for the query and the draw filter, so the two cannot drift. Every early exit is +/// `vec![core]`: the flag off, a panel with no window group, anything that is not Auto Overview, +/// and a core whose venue nothing can name. That last one does not join other cores — an unnamed +/// venue matches nobody — so the chart stays on its own core rather than widening into the +/// unidentified bucket. +/// +/// The owner is moved to element 0 even when the display order already contains it later. Keying +/// catalog readiness on `cores[0]` would otherwise treat a sibling's empty label as the chart's +/// own and, on a Default scope, clear the arrows. +/// +/// Args: +/// b: Backend holding the session venues and the workspace scope. +/// group: The panel's window group, or `None` for a diagnostics or historical panel. +/// core: The chart's own core. +/// all_cores: The stored `history_all_cores` flag. Off never widens. +/// +/// Returns: +/// Admitted cores, the owner at element 0. One element is today's picture. +pub(super) fn admitted_history_cores( + b: &Backend, + group: Option<&str>, + core: CoreId, + all_cores: bool, +) -> Vec { + if !all_cores { + return vec![core]; + } + let Some(group) = group else { + return vec![core]; + }; + if !b.is_auto_overview_scope(group) { + return vec![core]; + } + // Cloned so the scope call below can borrow `b` again. The map is one entry per core. + let venues = b.session.core_venues().clone(); + let own = section_of(venues.get(&core)); + if own == ExchangeSection::Unidentified { + return vec![core]; + } + let mut admitted: Vec = b + .effective_workspace_scope(group, RetainedCoreScope::All) + .ids() + .iter() + .copied() + .filter(|candidate| *candidate != core && section_of(venues.get(candidate)) == own) + .collect(); + admitted.insert(0, core); + admitted +} + /// Read one durable history snapshot without touching GPUI state. /// /// Args: -/// core: Exact runtime core that owns the chart. +/// cores: Explicit runtime cores, the chart's own core first. /// exact_coins: Case-insensitive exact database coin identities for the market. /// filter: Optional published Report filter refinement. /// @@ -201,15 +274,15 @@ fn draws_any_trade_kind(graphics: &moon_core::config::ChartGraphicsCfg) -> bool /// Errors: /// Propagates report-replica readiness, snapshot, schema, and SQL failures. fn load_history( - core: CoreId, + cores: Vec, exact_coins: Vec, filter: Option, ) -> db::ReadResult { let conn = db::open_reader_with(db::CHART_TRADE_HISTORY_ATTACH)?; let snapshot = db::read_snapshot(&conn)?; - db::query_chart_trade_history( + db::query_chart_trade_history_for_cores( &snapshot, - core, + &cores, &exact_coins, filter.as_ref(), HISTORY_LIMIT, @@ -236,6 +309,91 @@ fn history_result_is_current( } impl ChartPanel { + /// The admitted core set for one chart core under this panel's flag and window group. + /// + /// The wakes that compare a request share this call, so a later edit cannot change one of + /// them and leave the others on the old arguments. `load_history_scope` does not use it: that + /// path already holds the graphics value and must not read the settings a second time. + /// + /// Args: + /// core: The chart's own core. + /// cx: Application context used to read the backend and the effective graphics. + /// + /// Returns: + /// Admitted cores, the owner first. + fn admitted_cores_for(&self, core: CoreId, cx: &App) -> Vec { + admitted_history_cores( + self.backend.read(cx), + self.workspace_group.as_deref(), + core, + self.effective_chart_graphics(cx).history_all_cores, + ) + } + + /// Coin aliases one history read queries, in the order the SQL sees them. + /// + /// The market name comes first, then each admitted core's catalog label, then a Report + /// scope's exact coin. An empty label is skipped. The catalog wake rebuilds this same list + /// so a spelling change is visible even when the core ids are not. + /// + /// Args: + /// core: The chart's own core. Its label is the catalog-ready one, not `cores[0]`. + /// market: Canonical market the chart is showing. + /// cores: Admitted cores, the owner first. + /// scope: Default or published Report history scope. + /// cx: Application context used to read each core's market label. + /// + /// Returns: + /// Deduplicated aliases, case-insensitive. + fn history_exact_coins( + &self, + core: CoreId, + market: &str, + cores: &[CoreId], + scope: &ChartHistoryScope, + cx: &App, + ) -> Vec { + let owner_label = self + .backend + .read(cx) + .session + .market_source() + .market_label(core, market) + .coin; + let mut exact_coins = vec![market.to_string()]; + for admitted in cores { + let label = if *admitted == core { + owner_label.clone() + } else { + self.backend + .read(cx) + .session + .market_source() + .market_label(*admitted, market) + .coin + }; + if label.is_empty() + || exact_coins + .iter() + .any(|coin| coin.eq_ignore_ascii_case(&label)) + { + continue; + } + exact_coins.push(label); + } + if let ChartHistoryScope::Report { exact_coin, .. } = scope { + let report_coin = exact_coin.clone(); + if !report_coin.trim().is_empty() + && !exact_coins + .iter() + .any(|coin| coin.eq_ignore_ascii_case(&report_coin)) + { + exact_coins.push(report_coin); + } + } + exact_coins + } + /// Install durable markers and force the shared userdata union to rebuild while visible. /// /// Args: @@ -278,39 +436,9 @@ impl ChartPanel { replace_visible: bool, cx: &mut Context, ) { - let mut exact_coins = vec![market.clone()]; - let label_coin = self - .backend - .read(cx) - .session - .market_source() - .market_label(core, &market) - .coin; - let catalog_ready = !label_coin.is_empty(); - let default_needs_catalog = matches!(scope, ChartHistoryScope::Default); - if catalog_ready - && !exact_coins - .iter() - .any(|coin| coin.eq_ignore_ascii_case(&label_coin)) - { - exact_coins.push(label_coin); - } - let (filter, report_coin) = match &scope { - ChartHistoryScope::Default => (None, None), - ChartHistoryScope::Report { - filter, exact_coin, .. - } => (Some(filter.clone()), Some(exact_coin.clone())), - }; - if let Some(report_coin) = report_coin.filter(|coin| !coin.trim().is_empty()) - && !exact_coins - .iter() - .any(|coin| coin.eq_ignore_ascii_case(&report_coin)) - { - exact_coins.push(report_coin); - } - // THIS panel's effective settings: the popup is per tab, so two tabs on the same market can - // legitimately draw different sets. + // legitimately draw different sets. Hoisted above the alias block so the admitted set and + // the "draw anything" check share one read. // // The trade-kind checkboxes deliberately do NOT narrow this query — see // `ChartTradeRecord::emulator`: the row cap is applied after the predicate, so filtering @@ -321,17 +449,51 @@ impl ChartPanel { // in `chartdx/trade_history_sync.rs` is the one place that reads them. The Report scope's // own `filter.emulator` is a different thing and travels untouched: it says which rows the // user asked to see, not how they are drawn. - let draws_any_kind = { - let graphics = self.effective_chart_graphics(cx); - draws_any_trade_kind(&graphics) + let graphics = self.effective_chart_graphics(cx); + let draws_any_kind = draws_any_trade_kind(&graphics); + let backend = self.backend.read(cx); + let cores = admitted_history_cores( + backend, + self.workspace_group.as_deref(), + core, + graphics.history_all_cores, + ); + // The OWNER's label, never `cores[0]`. `admitted_history_cores` already moves the owner to + // the front; keying readiness on the owner by name is the second guard, so a sibling with + // an empty label cannot make this read NotReady and clear the chart's own arrows. + let owner_label = self + .backend + .read(cx) + .session + .market_source() + .market_label(core, &market) + .coin; + let catalog_ready = !owner_label.is_empty(); + let default_needs_catalog = matches!(scope, ChartHistoryScope::Default); + // One alias per admitted core. In PerCore each core has its own catalog, so two cores on + // one exchange can spell the coin differently; several distinct aliases are the right + // result, not a bug. An empty label contributes nothing and does not fail the read. + let exact_coins = self.history_exact_coins(core, &market, &cores, &scope, cx); + let filter = match &scope { + ChartHistoryScope::Default => None, + ChartHistoryScope::Report { filter, .. } => Some(filter.clone()), }; self.report_trades.sequence = self.report_trades.sequence.wrapping_add(1); let sequence = self.report_trades.sequence; self.report_trades.target = Some((core, market.clone())); self.report_trades.scope = scope.clone(); + self.report_trades.cores = cores.clone(); + self.report_trades.exact_coins = exact_coins.clone(); self.report_trades.last_admitted_any = Some(draws_any_kind); self.report_trades.last_refresh_start = Some(Instant::now()); + // Before the early return and before the re-read: toggling OFF narrows the drawn set + // immediately, and toggling ON widens a filter whose current records are still one core. + self.chart + .set_trade_history_cores(Some(Rc::new(TradeHistoryCores { + owner: core, + admitted: cores.clone(), + }))); if !draws_any_kind { // Both checkboxes are clear, so nothing would be drawn from this set: skip the round // trip entirely. The visible set is cleared whatever `replace_visible` says — the user @@ -368,8 +530,9 @@ impl ChartPanel { for attempt in 1..=BUSY_READ_ATTEMPTS { let exact_coins = exact_coins.clone(); let filter = filter.clone(); + let cores = cores.clone(); let outcome = executor - .spawn(async move { load_history(core, exact_coins, filter) }) + .spawn(async move { load_history(cores, exact_coins, filter) }) .await; match outcome { Ok(history) => { @@ -469,7 +632,8 @@ impl ChartPanel { scope: ChartHistoryScope, cx: &mut Context, ) { - if self.history_request_is_redundant(core, &market, &scope) { + let cores = self.admitted_cores_for(core, cx); + if self.history_request_is_redundant(core, &market, &scope, &cores) { return; } self.load_history_scope(core, market, scope, true, cx); @@ -497,7 +661,8 @@ impl ChartPanel { cx: &mut Context, ) { let scope = ChartHistoryScope::Default; - if self.history_request_is_redundant(core, &market, &scope) { + let cores = self.admitted_cores_for(core, cx); + if self.history_request_is_redundant(core, &market, &scope, &cores) { return; } self.load_history_scope(core, market, scope, true, cx); @@ -515,13 +680,14 @@ impl ChartPanel { core: CoreId, market: &str, scope: &ChartHistoryScope, + cores: &[CoreId], ) -> bool { let same_target = self .report_trades .target .as_ref() .is_some_and(|target| target.0 == core && target.1 == market); - if !same_target || &self.report_trades.scope != scope { + if !same_target || &self.report_trades.scope != scope || self.report_trades.cores != cores { return false; } match self.report_trades.status { @@ -656,6 +822,42 @@ impl ChartPanel { self.refresh_trade_history(cx); } + /// Re-read durable history when the admitted core set changes. + /// + /// The set is the chart's own core, plus — only in Auto Overview, and only while the flag is + /// on — every other core of that overview on the same exchange. A settings change, a workspace + /// revision, and a sibling catalog arriving are the wakes, and each is rare. This is not on + /// the coalesced Backend path: that one fires four times a second, and a scope walk there + /// would be three orders of magnitude off the background refresh budget. + /// + /// Args: + /// cx: Panel context used to start a non-clearing refresh. + /// + /// Returns: + /// Nothing; idle panels and an unchanged set do no work. + pub(super) fn requery_trade_history_on_core_scope(&mut self, cx: &mut Context) { + let Some((core, market)) = self.report_trades.target.clone() else { + return; + }; + let cores = self.admitted_cores_for(core, cx); + // A single core's catalog is already retried by `catalog_ready`. More than one core + // can be admitted by venue before its catalog spells the stored coin, and that + // spelling change does not move `cores`. + let aliases = if cores.len() > 1 { + Some(self.history_exact_coins(core, &market, &cores, &self.report_trades.scope, cx)) + } else { + None + }; + let same_aliases = match &aliases { + None => true, + Some(aliases) => aliases == &self.report_trades.exact_coins, + }; + if self.report_trades.cores == cores && same_aliases { + return; + } + self.refresh_trade_history(cx); + } + /// Drop the history target when this panel no longer shows the market it belongs to. /// /// A stale target is not inert: every refresh edge — a report generation, a trade-kind change — @@ -678,6 +880,9 @@ impl ChartPanel { self.report_trades.target = None; self.report_trades.scope = ChartHistoryScope::Default; self.report_trades.last_admitted_any = None; + self.report_trades.cores.clear(); + self.report_trades.exact_coins.clear(); + self.chart.set_trade_history_cores(None); self.report_trades.status = ReportTradesStatus::Idle; // Bump the sequence so a read still in flight for that market cannot land afterwards. self.report_trades.sequence = self.report_trades.sequence.wrapping_add(1); diff --git a/crates/moon-ui-gpui/src/panels/chart/trade_history_hover.rs b/crates/moon-ui-gpui/src/panels/chart/trade_history_hover.rs index 2266d9a0..ae80df3e 100644 --- a/crates/moon-ui-gpui/src/panels/chart/trade_history_hover.rs +++ b/crates/moon-ui-gpui/src/panels/chart/trade_history_hover.rs @@ -79,8 +79,9 @@ pub(super) struct TradeHoverState { /// snapshot that may already have been rebuilt under a different filter. #[derive(Clone, PartialEq, Eq, Debug)] struct TradeHover { - /// Pane the arrow belongs to. Every pane draws only its own core's trades, so the mark index - /// below is meaningless without it. + /// Pane the arrow belongs to. A pane draws its own core's trades and, when it owns the + /// history request, the admitted set, so the mark index below is still per pane and + /// meaningless without it. pane: usize, /// The hovered ACTION as `(mark index in this pane's filtered numbering, buy)`. /// @@ -284,6 +285,25 @@ impl ChartPanel { let card_w = f32::from(design::ui_px(cx, CARD_W)).min((slot_w - inset * 2.0).max(1.0)); let now_ms = now_unix_ms_i64(); + // The admitted set, not the records currently on screen: a caption that followed the + // visible rows would appear and disappear between reads. + let show_core = self + .chart + .trade_history_cores() + .is_some_and(|cores| cores.admitted.len() > 1); + // Copied out before the chart borrow: the name is the session's, and a missing session + // falls back to the uid's digits rather than a blank. + let session_names: Vec<(moon_core::session::CoreId, String)> = if show_core { + self.backend + .read(cx) + .session + .sessions() + .iter() + .map(|session| (session.id, session.name.clone())) + .collect() + } else { + Vec::new() + }; let rows = self.chart.with_report_axis(|axis| { self.chart.with_trade_records(|records| { let mut shown = hover @@ -291,25 +311,31 @@ impl ChartPanel { .iter() .filter_map(|&index| records.get(index)) .collect::>(); - // Chronological, so a cluster reads as the sequence it actually traded in. The - // record order is the query's (newest first) and the indices are sorted, so - // neither is it. - // - // NO axis correction here: `hover.trades` are indices into ONE pane's records, - // and a pane draws only its own core. Within one core, `Backend::report_axis` - // yields exactly one segment, so `to_utc` is the strictly monotonic map - // `x -> x - k` — order-preserving, so corrected order equals raw order. - // Correcting here would be pure cost. + // Chronological on the chart's true-UTC axis. Two admitted cores can sit on + // different clock offsets, so raw `buy_date` order is not the order the axis + // draws. The close stamp is the same tie-break as before, corrected the same way. shown.sort_by(|left, right| { - left.buy_date - .cmp(&right.buy_date) - .then(left.close_date.cmp(&right.close_date)) + axis.stamp_to_utc_ms(left.buy_stamp(), left.core_uid) + .cmp(&axis.stamp_to_utc_ms(right.buy_stamp(), right.core_uid)) + .then( + axis.stamp_to_utc_ms(left.close_stamp(), left.core_uid) + .cmp(&axis.stamp_to_utc_ms(right.close_stamp(), right.core_uid)), + ) }); let hidden = shown.len().saturating_sub(CARD_MAX_ITEMS); let rows = shown .into_iter() .take(CARD_MAX_ITEMS) - .map(|record| trade_row(record, axis, now_ms, palette, cx)) + .map(|record| { + let core_name = show_core.then(|| { + session_names + .iter() + .find(|(id, _)| *id == record.core_uid) + .map(|(_, name)| name.clone()) + .unwrap_or_else(|| record.core_uid.to_string()) + }); + trade_row(record, axis, now_ms, palette, core_name, cx) + }) .collect::>(); (rows, hidden) }) @@ -430,6 +456,8 @@ fn profit_text(record: &ChartTradeRecord) -> Option<(String, fmt::DeltaSign)> { /// true-UTC axis the clock formatter expects. /// now_ms: Current Unix time in milliseconds for dated clock formatting. /// p: Active theme palette. +/// core_name: Session name of the record's core, when more than one core can contribute. +/// `None` leaves the row as it was for a single-core card. /// cx: Application context used for scaled design tokens and translations. /// /// Returns: @@ -439,6 +467,7 @@ fn trade_row( axis: &moon_core::db::ReportAxis, now_ms: i64, p: MoonPalette, + core_name: Option, cx: &App, ) -> AnyElement { let side_color = if record.is_short { p.red } else { p.green }; @@ -489,7 +518,26 @@ fn trade_row( clock(record.buy_stamp()), clock(record.close_stamp()) )), - ); + ) + .when_some(core_name, |this, name| { + // Direct child of the head row: a flex wrapper around `.truncate()` collapses + // the whole line to one ellipsis. The remaining width is the bound, and the + // tooltip keeps the tail a narrow card would otherwise hide. + this.child( + div() + .id(SharedString::from(format!( + "chart-hover-core-{}-{}-{}", + record.core_uid, record.record_id, record.buy_date + ))) + .flex_1() + .min_w_0() + .truncate() + .text_size(design::t_caption(cx)) + .text_color(rgb(p.text_muted)) + .tooltip(crate::panels::common::text_tooltip(name.clone())) + .child(name), + ) + }); let prices = h_flex() .w_full() .items_center() diff --git a/locales/shell.yml b/locales/shell.yml index 4984885b..bcf682ff 100644 --- a/locales/shell.yml +++ b/locales/shell.yml @@ -1018,6 +1018,10 @@ chart.history.tip: ru: "Настройки истории сделок" en: "Trade history settings" es: "Ajustes del historial de operaciones" +chart.history.all_cores.tip: + ru: "Сделки всех ядер сводки на этой бирже" + en: "Full summary: trades of every core on this exchange" + es: "Operaciones de todos los núcleos del Resumen completo en este exchange" chart.history.frame_closed_orders: ru: "Закрытые ордера" en: "Closed orders" From 643f4d92f0dfc833c6560a711f1632b0800d5d75 Mon Sep 17 00:00:00 2001 From: kirillDevPro <113171057+kirillDevPro@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:46:12 +0200 Subject: [PATCH 2/3] fix(chart): lift the all-cores toggle out of the settings icons The toggle shipped inside the run of settings-popup icons, where it read as a sixth popup rather than as what it is: a mode switch that changes which trades the chart draws. It now stands in its own section between the coin field and the icon run, with the strip's own rule beside it - next to the other control that answers "what am I looking at". Section and divider share one Option, so a chart outside Auto Overview, where the toggle does not exist, gains neither an empty group nor a second rule. Both toolbars move together: the docked strip and the detached chart header. The new section is deliberately left out of the pair of groups that dismiss an open coin search on press - that contract stays at exactly two listeners. --- .../src/chart_tabs/detached_host/render.rs | 18 ++++++++-- crates/moon-ui-gpui/src/chart_tabs/strip.rs | 35 +++++++++++++------ 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/crates/moon-ui-gpui/src/chart_tabs/detached_host/render.rs b/crates/moon-ui-gpui/src/chart_tabs/detached_host/render.rs index d4ee3b4b..52e6da64 100644 --- a/crates/moon-ui-gpui/src/chart_tabs/detached_host/render.rs +++ b/crates/moon-ui-gpui/src/chart_tabs/detached_host/render.rs @@ -98,7 +98,8 @@ impl Render for DetachedChartHost { .size(design::INPUT_SIZE), ); // A press on either control group beside the field ends an open search, and only while - // there is one to end; see `common::coin_toolbar_press_handler`. Coverage here is the + // there is one to end; see `common::coin_toolbar_press_handler`. The all-cores section + // between the field and the icon run is not one of those two groups. Coverage here is the // groups themselves, so the row's own gaps are not in it — the dividers, the padding, and // the band above and below the centred sections — and this window's dismiss layer starts // BELOW the header, so a press there leaves the list up. The title cluster cannot be @@ -199,8 +200,19 @@ impl Render for DetachedChartHost { }, ) }; - // Only detached tab windows have this header; the main dock does not. Scale is on the left, + // Only detached tab windows have this header; the main dock does not. The all-cores + // mode stands left of the icon run when Auto Overview shows it, scale leads that run, // and "close all charts" is on the right. + // Section and divider share one Option, so a chart outside Auto Overview never gains an + // empty group or a second rule between the coin field and the icons. + let all_cores_group = all_cores_btn.map(|btn| { + h_flex() + .flex_none() + .items_center() + .gap(design::ui_px(cx, design::CHROME_GAP)) + .child(design::chrome_section(cx).child(btn)) + .child(design::chrome_divider(cx, p)) + }); v_flex() .size_full() .relative() @@ -247,6 +259,7 @@ impl Render for DetachedChartHost { .child(design::chrome_divider(cx, p)) .child(design::chrome_section(cx).child(coin_search_el)) .child(design::chrome_divider(cx, p)) + .children(all_cores_group) .child( design::chrome_section(cx) .when_some(ends_search.clone(), |this, end| { @@ -314,7 +327,6 @@ impl Render for DetachedChartHost { .render(), cx, )) - .children(all_cores_btn) // The labels button edits THIS window's chart captions. .child(labels_popup::labels_popup_host( self, diff --git a/crates/moon-ui-gpui/src/chart_tabs/strip.rs b/crates/moon-ui-gpui/src/chart_tabs/strip.rs index 1cdeafc2..11e17e10 100644 --- a/crates/moon-ui-gpui/src/chart_tabs/strip.rs +++ b/crates/moon-ui-gpui/src/chart_tabs/strip.rs @@ -463,21 +463,34 @@ impl Render for ChartTabs { // below reads `cx` again. let fig_tools = self.render_fig_tools(cx).into_any_element(); - // Right cluster, read left to right as three groups of one job each: what you DRAW on the - // chart, what you PUT on it, and how you VIEW it. The groups are `chrome_section`s with a - // `chrome_divider` standing between them, so the boundary comes from the rule rather than - // from wider spacing — the same block idiom the terminal header and the trading toolbar use. + // Right cluster, read left to right: what you DRAW on the chart, what you PUT on it, + // the all-cores history mode when Auto Overview shows it, and how you VIEW it. The + // groups are `chrome_section`s with a `chrome_divider` standing between them, so the + // boundary comes from the rule rather than from wider spacing — the same block idiom + // the terminal header and the trading toolbar use. The all-cores section and the + // divider after it are one element, present only when the button is. // Both settings buttons carry their own anchored popovers, so nothing here positions a popup. // - // Both groups AROUND the market field end an open search on press; see - // `common::coin_toolbar_press_handler`. Built only while there IS a search to end, because - // the listener is not free: it makes each `chrome_section` carry a hitbox that every - // mouse-down in the window then walks. A frame always lands between the two states and the - // next press — opening the list notifies, and taking the focus refreshes past the view - // cache — so nothing a user can do slips through the gate. + // The draw group and the view group end an open search on press; see + // `common::coin_toolbar_press_handler`. The all-cores section is left out of that pair: + // both toolbars stay at exactly two such listeners. Built only while there IS a search + // to end, because the listener is not free: it makes each `chrome_section` carry a + // hitbox that every mouse-down in the window then walks. A frame always lands between + // the two states and the next press — opening the list notifies, and taking the focus + // refreshes past the view cache — so nothing a user can do slips through the gate. let coin_search_live = self.popup_shows(ChartPopup::Coin) || self.coin_input.read(cx).focus_handle(cx).is_focused(window); let ends_search = coin_search_live.then(|| common::coin_toolbar_press_handler(cx)); + // Section and divider share one Option, so an ordinary chart never gets an empty + // group or a second rule between the coin field and the view icons. + let all_cores_group = all_cores_btn.map(|btn| { + h_flex() + .flex_none() + .items_center() + .gap(design::ui_px(cx, design::CHROME_GAP)) + .child(design::chrome_section(cx).child(btn)) + .child(design::chrome_divider(cx, p_strip)) + }); let right_cluster = h_flex() .flex_none() .items_center() @@ -493,6 +506,7 @@ impl Render for ChartTabs { .child(design::chrome_divider(cx, p_strip)) .child(design::chrome_section(cx).child(coin_search_el)) .child(design::chrome_divider(cx, p_strip)) + .children(all_cores_group) .child( design::chrome_section(cx) .when_some(ends_search, |this, end| this.capture_any_mouse_down(end)) @@ -501,7 +515,6 @@ impl Render for ChartTabs { .child(candle_btn) .child(graphics_btn) .child(history_btn) - .children(all_cores_btn) .child(labels_btn) .child(volumes_btn) .child(settings_btn), From 30d24c588a63c1575eb1c07d945db5f02ab393e6 Mon Sep 17 00:00:00 2001 From: kirillDevPro <113171057+kirillDevPro@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:53:10 +0200 Subject: [PATCH 3/3] fix(chart): name the exchange and core count when drawing all cores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the all-cores toggle on, the chart drew the closed trades of every Full summary core on the exchange, while its corner caption still named the one core the coin was opened on - so the picture read as that core's trades alone. The caption now reads " · all cores ()" whenever the pane draws more than its own core, decided by the same owner-and-size test the draw filter applies, so a compare pane that does not own the request keeps its own name. It is substituted at the one place the caption's inputs are assembled, beside the screenshot substitution, and wins over it: that one hides account labels and this string carries none. Installing or clearing the admitted set now forces the order sync that rebuilds the caption, so the name moves with the arrows instead of waiting for an unrelated order revision. --- .../src/chartdx/data_state/orders.rs | 20 ++++++++++- crates/moon-ui-gpui/src/chartdx/mod.rs | 7 ++++ .../moon-ui-gpui/src/chartdx/text/captions.rs | 35 ++++++++++++------- .../src/panels/chart/report_trades.rs | 17 +++++++-- locales/shell.yml | 4 +++ 5 files changed, 67 insertions(+), 16 deletions(-) diff --git a/crates/moon-ui-gpui/src/chartdx/data_state/orders.rs b/crates/moon-ui-gpui/src/chartdx/data_state/orders.rs index 57fc6d23..627170ab 100644 --- a/crates/moon-ui-gpui/src/chartdx/data_state/orders.rs +++ b/crates/moon-ui-gpui/src/chartdx/data_state/orders.rs @@ -54,6 +54,12 @@ impl ChartDataState { // here, beside the configuration it overrides, because both answers must hold for the whole // sync — a shot arming halfway through would otherwise caption some panes and not others. let shot = st.shot_caption_active(); + // A wide admitted set prints the exchange in the core-name caption, so that pane needs a + // venue whether or not the user asked for a venue caption — the same reason a shot does. + let history_wide = self + .trade_history_cores + .as_ref() + .is_some_and(|cores| cores.admitted.len() > 1); // Whether this ENGINE draws a frozen picture rather than a live market. Engine-level, not // per-pane: the answer belongs to the engine a trade window owns, so it is the same for // every pane it holds and is resolved once for the whole sync. One predicate, shared with @@ -62,7 +68,8 @@ impl ChartDataState { // Both caption gates are answered ONCE for the sync, not per pane: they read the // configuration, which cannot change inside a sync, and a walk over sixteen rows of eight // captions per pane per order revision is real work for an answer that never differs. - let wants_venue_cfg = shot || labels_cfg.any_drawn(|f| f == ChartLabelField::Venue); + let wants_venue_cfg = + shot || history_wide || labels_cfg.any_drawn(|f| f == ChartLabelField::Venue); // `!frozen` for the same reason the order lines below are emptied: these captions read the // core's CURRENT open position and the strategy behind it. Printed over a trade that closed // hours ago they are not stale, they are about a different thing entirely — and a caption @@ -117,6 +124,17 @@ impl ChartDataState { pr.core_name = core_name; pixels_changed = true; } + // Same foreign-admission test as `pane_admits_record`: this pane owns the history + // request and the admitted set holds more than the owner. A compare pane that does + // not own the request keeps its own name. + let all_cores_count = self.trade_history_cores.as_ref().and_then(|cores| { + (cores.owner == pane.core && cores.admitted.len() > 1) + .then_some(cores.admitted.len()) + }); + if pr.all_cores_count != all_cores_count { + pr.all_cores_count = all_cores_count; + pixels_changed = true; + } // Caption inputs that only the SESSION can answer: the venue behind the core, the open // orders on this market and the strategy that placed the newest of them. Collected here // for the same reason the core name is — this is where the session is in hand — and diff --git a/crates/moon-ui-gpui/src/chartdx/mod.rs b/crates/moon-ui-gpui/src/chartdx/mod.rs index 71ff7d42..5b548528 100644 --- a/crates/moon-ui-gpui/src/chartdx/mod.rs +++ b/crates/moon-ui-gpui/src/chartdx/mod.rs @@ -414,6 +414,12 @@ struct PaneRender { market: String, /// Core name for the chart corner label, resolved from `SessionManager` during order sync. core_name: String, + /// Admitted-core count for that same caption, or `None` when the pane draws only its own core. + /// + /// `Some(n)` means this pane owns the history request and `n` cores were admitted — the same + /// test `pane_admits_record` uses before it will draw a foreign trade. The caption string is + /// built from this in `refresh_pane_labels`, not here. + all_cores_count: Option, /// Ticker for that same caption (`BEAT-USDT`), resolved from the core's catalog in /// `sync_from_market_source` and cached here. /// @@ -870,6 +876,7 @@ impl PaneRender { core: None, market: String::new(), core_name: String::new(), + all_cores_count: None, ticker: String::new(), ticker_catalog_key: 0, ticker_resolved: false, diff --git a/crates/moon-ui-gpui/src/chartdx/text/captions.rs b/crates/moon-ui-gpui/src/chartdx/text/captions.rs index 4e87b062..42afa37d 100644 --- a/crates/moon-ui-gpui/src/chartdx/text/captions.rs +++ b/crates/moon-ui-gpui/src/chartdx/text/captions.rs @@ -36,6 +36,7 @@ use moon_core::config::{ ROW_NAME_PART, ROW_RUN_STRIDE, ResolvedLabelStyle, WRAP_PART_BASE, }; use moon_core::util::fmt::DeltaSign; +use rust_i18n::t; use super::caption::{CaptionBox, CaptionGeom, caption_geom}; use super::labels::{LabelAction, LabelText}; @@ -1598,22 +1599,32 @@ impl RenderState { .zip(pr.cached_last_price) .filter(|(r, l)| *r > 0.0 && *l > 0.0) .map(|(r, l)| (l - r) / r * 100.0); - // A shot is in flight: this pane's core-name caption names the EXCHANGE instead. The - // substitution happens HERE, at the one place the caption's inputs are assembled, so that - // nothing below — the caption resolution, the truncation, the measuring, the plate geometry — - // learns that a shot is happening. Only which string arrives changes. + // The core-name caption is substituted HERE, at the one place its inputs are assembled, + // so nothing below — resolution, truncation, measuring, plate geometry — learns why the + // string changed. Only which string arrives changes. // - // The core name is the user's own free text, an account label such as `SUB ACC No 38`, and - // these pictures get shared publicly. `venue` is never empty while a shot is armed: the - // order sync resolves it through the shared label helper, which answers with the "not - // identified" wording for a core that cannot be named. + // A pane drawing more than its own core's trades names the exchange and the count. That + // wins over a shot: the shot exists to keep an account label out of a shared picture, and + // this string has none. Otherwise a shot in flight names the exchange alone. The core name + // is the user's own free text, an account label such as `SUB ACC No 38`. `venue` is never + // empty while a shot is armed: the order sync resolves it through the shared label helper, + // which answers with the "not identified" wording for a core that cannot be named. let shot = self.shot_caption_active(); + let core_name = if let Some(n) = pr.all_cores_count { + t!( + "chart.history.all_cores.caption", + venue = pr.venue.as_str(), + n = n + ) + .to_string() + } else if shot { + pr.venue.clone() + } else { + pr.core_name.clone() + }; let inputs = super::LabelInputs { ticker: pr.ticker.clone(), - core_name: match shot { - true => pr.venue.clone(), - false => pr.core_name.clone(), - }, + core_name, venue: pr.venue.clone(), quote: pr.quote.clone(), strategy: pr.label_strategy.clone(), diff --git a/crates/moon-ui-gpui/src/panels/chart/report_trades.rs b/crates/moon-ui-gpui/src/panels/chart/report_trades.rs index bbc2c156..f5cb81bf 100644 --- a/crates/moon-ui-gpui/src/panels/chart/report_trades.rs +++ b/crates/moon-ui-gpui/src/panels/chart/report_trades.rs @@ -489,11 +489,18 @@ impl ChartPanel { self.report_trades.last_refresh_start = Some(Instant::now()); // Before the early return and before the re-read: toggling OFF narrows the drawn set // immediately, and toggling ON widens a filter whose current records are still one core. - self.chart + // The corner name is rebuilt by the order sync, and this set is not an order revision. + // Force that sync the way a changed record list does, so the name moves with the arrows + // instead of waiting for an unrelated order. + if self + .chart .set_trade_history_cores(Some(Rc::new(TradeHistoryCores { owner: core, admitted: cores.clone(), - }))); + }))) + { + self.sync_orders_if_visible(cx, true); + } if !draws_any_kind { // Both checkboxes are clear, so nothing would be drawn from this set: skip the round // trip entirely. The visible set is cleared whatever `replace_visible` says — the user @@ -882,7 +889,11 @@ impl ChartPanel { self.report_trades.last_admitted_any = None; self.report_trades.cores.clear(); self.report_trades.exact_coins.clear(); - self.chart.set_trade_history_cores(None); + // Same wake as a set installed by a load: clearing the set is not an order revision, + // and the corner name would otherwise keep naming every core until one moved. + if self.chart.set_trade_history_cores(None) { + self.sync_orders_if_visible(cx, true); + } self.report_trades.status = ReportTradesStatus::Idle; // Bump the sequence so a read still in flight for that market cannot land afterwards. self.report_trades.sequence = self.report_trades.sequence.wrapping_add(1); diff --git a/locales/shell.yml b/locales/shell.yml index bcf682ff..de346661 100644 --- a/locales/shell.yml +++ b/locales/shell.yml @@ -1022,6 +1022,10 @@ chart.history.all_cores.tip: ru: "Сделки всех ядер сводки на этой бирже" en: "Full summary: trades of every core on this exchange" es: "Operaciones de todos los núcleos del Resumen completo en este exchange" +chart.history.all_cores.caption: + ru: "%{venue} · все ядра (%{n})" + en: "%{venue} · all cores (%{n})" + es: "%{venue} · todos los núcleos (%{n})" chart.history.frame_closed_orders: ru: "Закрытые ордера" en: "Closed orders"