Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions crates/moon-core/src/config/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion crates/moon-core/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down
44 changes: 39 additions & 5 deletions crates/moon-core/src/db/report_read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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<ChartTradeHistory> {
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:
Expand Down
169 changes: 168 additions & 1 deletion crates/moon-core/src/db/report_read/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Vec<String>> =
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::<Vec<_>>()
);

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<_>>(),
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::<Vec<_>>()
.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}"
);
}
34 changes: 31 additions & 3 deletions crates/moon-ui-gpui/src/chart_tabs/detached_host/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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| {
Expand Down
Loading
Loading