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..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 @@ -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; @@ -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 @@ -107,6 +108,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 @@ -184,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() @@ -232,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| { 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..11e17e10 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( @@ -447,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() @@ -477,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)) 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/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..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, @@ -1593,6 +1600,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/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/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..f5cb81bf 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,58 @@ 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. + // 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 @@ -368,8 +537,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 +639,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 +668,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 +687,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 +829,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 +887,13 @@ 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(); + // 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/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..de346661 100644 --- a/locales/shell.yml +++ b/locales/shell.yml @@ -1018,6 +1018,14 @@ 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.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"