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
59 changes: 59 additions & 0 deletions crates/moon-core/src/market/trade_replay/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,10 @@ pub struct TradeReplaySeries {
/// a [`TradeReplaySource::Ticks`] series carries these TOO — the EXCHANGE's own one-minute
/// klines, not bars aggregated from [`Self::ticks`], so the bar layer covers the WHOLE window
/// even where the points, per [`Self::partial`], do not.
///
/// Stored whole; DRAWN only where the points are not. [`Self::read_into`] withholds every bar
/// lying wholly inside the span [`Self::ticks`] covers, so the two layers never overlay each
/// other and the bars are left holding exactly the edges the points never reached.
pub candles: Vec<ChartCandle>,
/// Trade points in ascending time. Empty when [`Self::source`] is
/// [`TradeReplaySource::Klines1m`]; carried alongside [`Self::candles`] for
Expand All @@ -730,6 +734,19 @@ pub struct TradeReplaySeries {
/// Whether [`Self::ticks`] covers only PART of [`Self::window`] — the bars always cover all
/// of it. Always `false` on a [`TradeReplaySource::Klines1m`] series.
pub partial: bool,
/// The inclusive span [`Self::ticks`] is guaranteed EXHAUSTIVE over, or `None` when there was
/// no tick walk at all ([`TradeReplaySource::Klines1m`]).
///
/// Carried straight from `worker::TickHarvest::covered`, the walk's own answer, and NOT
/// re-derived from the rows: clipping proves every row is inside the span, never that the
/// first and last rows ARE its edges. A completed boundary slice whose opening minute simply
/// saw no trade is exhaustively covered while carrying no point there, and only this field
/// knows it — which is what lets [`Self::read_into`] withhold that minute's bar instead of
/// leaving one stray candle floating inside the tick trace.
///
/// [`Self::partial`] is the BOOLEAN read of this same span against [`Self::window`]; this is
/// the span itself.
pub covered: Option<(i64, i64)>,
}

impl TradeReplaySeries {
Expand Down Expand Up @@ -859,6 +876,26 @@ impl TradeReplaySeries {
true => crate::market::candles::resample(&clipped, tf_ms, &mut out.candles),
false => out.candles.extend(clipped),
}
// WHERE THE POINTS ARE, THE BARS STEP ASIDE. A tick series carries the exchange's
// own one-minute klines for the WHOLE window (see `Self::candles`), which is what
// keeps the edges the points never reached drawn — but inside the covered span
// the two layers are the same trades told twice, drawn on top of each other.
//
// Decided from the WALK's own interval, never from pixels and never from the rows:
// `Self::covered` is what the tick stage proved exhaustive, while the extrema of
// the points are merely a subset of it — a covered minute the venue happened to
// publish no trade in would keep its bar under a row-derived rule and read as a
// stray candle floating inside the trace. `None` there is a `Klines1m` series,
// which is what keeps a still-loading window whole: the bar-only stage walked no
// ticks, so nothing is hidden until the upgrade lands.
//
// Applied AFTER the aggregation above so one rule covers both paths, and to the
// OUTPUT timeframe, which is the width the caller actually draws. `candle_tf_ms`
// is never filled on this path, so there is no parallel array to desync.
if let Some(covered) = self.covered {
out.candles
.retain(|c| !bar_inside(c.t_open_ms, tf_ms, covered));
}
read.candles_changed = true;
}
}
Expand All @@ -880,6 +917,28 @@ impl TradeReplaySeries {
}
}

/// Whether one bar lies WHOLLY inside a covered span.
///
/// A bar that STRADDLES an edge stays drawn: half of it is over ground the points never reached,
/// so it is context rather than an overlay, and dropping it would leave a gap the user reads as
/// missing data. That is also what makes the window's own caption honest — the edges really are
/// the part still closed by candles.
///
/// Args:
/// t_open_ms: The bar's opening stamp; a non-finite one is never inside anything.
/// tf_ms: The bar's width, at the timeframe it is DRAWN at.
/// covered: Inclusive span from [`TradeReplaySeries::covered`].
///
/// Returns:
/// `true` when the whole bar sits inside the span.
fn bar_inside(t_open_ms: f64, tf_ms: i64, covered: (i64, i64)) -> bool {
if !t_open_ms.is_finite() {
return false;
}
let open = t_open_ms as i64;
open >= covered.0 && open.saturating_add(tf_ms.max(1)) - 1 <= covered.1
}

/// Lowest and highest finite positive price across a run of trade points.
///
/// Args:
Expand Down
53 changes: 53 additions & 0 deletions crates/moon-core/src/market/trade_replay/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ fn bars_only_series() -> TradeReplaySeries {
tick_status: TickStatus::Pending,
bucket_ms: 0,
partial: false,
covered: None,
}
}

Expand Down Expand Up @@ -156,6 +157,58 @@ fn replay_repeat_keeps_candle_range_after_bars_are_already_shipped() {
);
}

/// `market/trade_replay/mod.rs:bar_inside` must reject only wholly contained bars; relaxing it
/// to an overlap drops the right edge candle and leaves a blank gutter beside the tick trace.
#[test]
fn replay_ticks_keep_both_straddling_edge_candles() {
let mut series = bars_only_series();
series.source = TradeReplaySource::Ticks;
series.covered = Some((MINUTE_MS / 2, 5 * MINUTE_MS / 2));
let mut out = ChartHistoryBuffers::default();

series.read_into(
0.0,
0.0,
(2 * MINUTE_MS) as f32,
Some(&candle_params(0)),
&mut out,
);

assert_eq!(
out.candles
.iter()
.map(|candle| candle.t_open_ms as i64)
.collect::<Vec<_>>(),
vec![0, 2 * MINUTE_MS],
"only the wholly covered middle candle may step aside; both straddling edge candles close the tick trace"
);
}

/// `market/trade_replay/mod.rs:TradeReplaySeries::read_into` must leave a `covered: None`
/// Klines1m series whole; applying the hide with its window span blanks the fallback chart while ticks load.
#[test]
fn replay_bars_only_series_keeps_every_candle_without_tick_coverage() {
let series = bars_only_series();
let mut out = ChartHistoryBuffers::default();

series.read_into(
0.0,
0.0,
(2 * MINUTE_MS) as f32,
Some(&candle_params(0)),
&mut out,
);

assert_eq!(
out.candles
.iter()
.map(|candle| candle.t_open_ms as i64)
.collect::<Vec<_>>(),
vec![0, MINUTE_MS, 2 * MINUTE_MS],
"a bars-only fallback must keep every one-minute candle instead of rendering an empty chart"
);
}

/// `market/trade_replay/mod.rs:cache_covers` must enforce both edges and its one-bar allowance;
/// widening the allowance or dropping the right-edge check silently reuses incomplete exit bars.
#[test]
Expand Down
8 changes: 8 additions & 0 deletions crates/moon-core/src/market/trade_replay/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,7 @@ fn serve_ticks(
ticks,
bucket_ms,
partial,
covered,
stage.candles.clone(),
),
venue_refused,
Expand Down Expand Up @@ -1148,6 +1149,9 @@ where
/// ticks: Trade points, ascending, already clipped to the harvest's covered range.
/// bucket_ms: The bucket [`fit_ticks`] thinned the points to; `0` means raw.
/// partial: Whether `ticks` covers only part of `request.window`.
/// covered: The walk's own exhaustive span, carried onto the series verbatim — the chart
/// withholds the bars lying inside it, and only this range knows that a covered minute
/// with no trade in it is still covered.
/// candles: The exchange klines to carry as the bar layer.
///
/// Returns:
Expand All @@ -1158,6 +1162,7 @@ fn compose_ticks(
ticks: Vec<Tick>,
bucket_ms: i64,
partial: bool,
covered: (i64, i64),
candles: Vec<ChartCandle>,
) -> TradeReplaySeries {
TradeReplaySeries {
Expand All @@ -1171,6 +1176,7 @@ fn compose_ticks(
tick_status: TickStatus::Served,
bucket_ms,
partial,
covered: Some(covered),
}
}

Expand Down Expand Up @@ -1223,6 +1229,8 @@ fn compose(
tick_status: TickStatus::Pending,
bucket_ms: 0,
partial: false,
// No tick walk ran, so there is no covered span and the chart keeps every bar.
covered: None,
}
}

Expand Down
48 changes: 30 additions & 18 deletions crates/moon-ui-gpui/src/panels/detects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -696,32 +696,44 @@ impl Render for DetectsPanel {
shown += 1;
}

// An empty feed states WHY it is empty instead of painting a blank pane under the gear.
// Same element the Log panel uses for `log.empty_filtered` (`panels/log/view.rs`): one
// centred, muted line filling the space the card grid would have taken. It replaces the
// scroll box rather than sitting inside it — an empty scroll container would still own the
// `flex_1` slot and leave the sentence pinned to the top-left corner.
// An empty feed states WHY it is empty instead of painting a blank pane under the gear. It
// replaces the scroll box rather than sitting inside it — an empty scroll container would
// still own the `flex_1` slot and leave the sentence pinned to the top-left corner.
//
// A COLUMN, not the Log panel's centred row, and the text carries its own definite width.
// These sentences are whole clauses where `log.empty_filtered` is two words, so at a ~290px
// side dock they have to WRAP — and a centred flex ROW cannot wrap them. GPUI measures text
// with `wrap_width = known_dimensions.width.or(Definite(available))` (moon-gpui
// `elements/text.rs:650`), so the min-content probe taffy runs to fix a row item's automatic
// minimum width comes back with the whole one-line sentence: the item refuses to shrink,
// overflows a narrow panel symmetrically under `justify_center`, and is clipped on BOTH
// sides. `.text_center()` cannot save it — it centres lines inside a box already wider than
// the panel. Giving the text `w_full` makes its width DEFINITE, which is the one input the
// measure above needs; `max_w` then keeps a wide, undocked panel from stretching one clause
// across the whole pane. Same shape as `analytics/render.rs`'s `quote_split_note`, and the
// row-axis mirror of the rule pinned in `tests/theme_contract/shell.rs`.
let body: AnyElement = if shown == 0 {
div()
v_flex()
.flex_1()
.w_full()
.min_h(px(0.0))
.flex()
.items_center()
.justify_center()
// `items_center` + `justify_center` centre the text BOX, not the lines inside it.
// The Log panel needs no more than that because its empty copy is two words; these
// sentences wrap at any realistic dock width, and without this the centred empty
// state reads left-aligned exactly where it is most cramped.
.text_center()
.p_2()
.px_3()
.py_2()
.text_size(crate::design::t_body(cx))
.text_color(rgb(p.text_soft))
.child(empty_feed_text(
&marker,
retained_reachable,
available_cores,
))
.child(
div()
.w_full()
.max_w(crate::design::font_w_px(cx, 560.0))
.text_center()
.child(empty_feed_text(
&marker,
retained_reachable,
available_cores,
)),
)
.into_any_element()
} else {
div()
Expand Down
9 changes: 8 additions & 1 deletion crates/moon-ui-gpui/src/trade_window/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,12 +261,19 @@ impl TradeWindowView {
.child(self.panel.clone())
// The caption is a requirement, not decoration: a one-minute picture of a
// forty-second scalp is an honest answer only while it says which it is.
//
// `t_body`, NOT `t_caption`: this is the one line saying WHAT is on screen —
// ticks, bucketed ticks, or candles and the reason for them — so it must not be
// the smallest text in the window. It now matches the figures rail's VALUES
// (`figures.rs`, `t_body`) rather than its field labels, which is the right
// company for it. A design step, never a hard-coded size, so the Font slider and
// the UI scale keep carrying it.
.child(
div()
.absolute()
.left(design::ui_px(cx, 8.0))
.bottom(design::ui_px(cx, 6.0))
.text_size(design::t_caption(cx))
.text_size(design::t_body(cx))
.text_color(moon(p.text_muted))
.child(caption),
)
Expand Down
57 changes: 57 additions & 0 deletions crates/moon-ui-gpui/tests/theme_contract/detects.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//! Static contracts for the Detects panel's narrow-dock empty state.

use super::support::*;

/// `panels/detects/mod.rs:DetectsPanel::render` must keep the empty feed in a column and give its
/// sentence a definite width. Replacing `v_flex()` with a centred flex row or hanging
/// `empty_feed_text(...)` directly on that row makes every empty-state sentence render as one line
/// clipped on both sides in the reported roughly 290-pixel side-dock screenshot.
#[test]
fn detects_empty_state_keeps_a_column_and_a_definite_text_width() {
let source = read_src("panels/detects/mod.rs");
let render = code_only(braced_body(
&source,
"fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement",
));
let empty_state = render
.split_once("let body: AnyElement = if shown == 0 {")
.unwrap_or_else(|| {
panic!("the Detects render must retain its shown == 0 empty-state branch")
})
.1
.split_once("} else {")
.unwrap_or_else(|| {
panic!("the Detects empty state must remain separate from its scroll box")
})
.0
.trim_start();

assert!(
empty_state.starts_with("v_flex()"),
"the Detects empty state must stay a column so its sentence can wrap instead of clipping on both sides in a narrow side dock"
);
assert!(
!empty_state.starts_with("div().flex().items_center()"),
"the Detects empty state must not restore the centred flex row that produced the clipped narrow-dock screenshot"
);

let sentence_box = empty_state
.split_once("div()")
.unwrap_or_else(|| {
panic!("the Detects empty sentence needs its own box to avoid the clipped narrow-dock screenshot")
})
.1
.split_once(".child(empty_feed_text(")
.unwrap_or_else(|| {
panic!("the Detects empty sentence must stay inside its own box instead of clipping in a narrow side dock")
})
.0;
assert!(
sentence_box.contains(".w_full()"),
"the Detects empty sentence box must have a definite width so every empty state wraps instead of clipping on both sides"
);
assert!(
sentence_box.contains(".text_center()"),
"wrapped Detects empty-state lines must remain centred in the narrow-dock screenshot"
);
}
1 change: 1 addition & 0 deletions crates/moon-ui-gpui/tests/theme_contract/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ mod analytics;
mod chart;
mod core_pick;
mod core_status;
mod detects;
mod naming;
mod shared_config;
mod shell;
Expand Down