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
15 changes: 9 additions & 6 deletions crates/moon-core/src/config/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,16 @@ pub const DEFAULT_TRADES_MAX_MB: u32 = 256;
/// (`trade_replay::MODEL_PAD_MS`): one setting sizes the chart's window, the close-time capture,
/// the tuner's fetch and the cleanup alike, and none of them pads it behind the tab's back (the
/// developer's call, 2026-09-23; the steps started at 5 s before that, and the tuner lifted
/// them to a minute on its own). The ceiling is two hours: the bar context after an exit is two
/// hours at least, and prints past the bars would have nowhere to draw.
pub const TRADE_MARGIN_STEPS_S: &[u32] = &[30, 60, 180, 300, 600, 900, 1800, 3600, 7200];
/// them to a minute on its own). 65 s is a step so the default survives the snap; it is not
/// the floor. The ceiling is two hours: the bar context after an exit is two hours at least,
/// and prints past the bars would have nowhere to draw.
pub const TRADE_MARGIN_STEPS_S: &[u32] = &[30, 60, 65, 180, 300, 600, 900, 1800, 3600, 7200];

/// Default seconds of prints around a trade, per end — the floor of [`TRADE_MARGIN_STEPS_S`],
/// 30 s (the developer's call, 2026-09-23; 5 s from 2026-09-21, 15 minutes before that).
pub const DEFAULT_TRADE_MARGIN_S: u32 = 30;
/// Default seconds of prints around a trade, per end. 65 s (the user's call, 2026-09-26;
/// 30 s from 2026-09-23, 5 s from 2026-09-21, 15 minutes before that). Not the floor of
/// [`TRADE_MARGIN_STEPS_S`]: 30 s stays the tuner's pad and a step, so a file that already
/// stores 30 keeps 30. A file with no margin key at all takes this default.
pub const DEFAULT_TRADE_MARGIN_S: u32 = 65;

/// Ceiling on [`TradeReplayStoreCfg::margin_s`] — the last of [`TRADE_MARGIN_STEPS_S`].
pub const MAX_TRADE_MARGIN_S: u32 = 7200;
Expand Down
6 changes: 6 additions & 0 deletions crates/moon-core/src/config/storage/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ fn snap_and_step_walk_the_step_list() {
assert_eq!(snap_trade_margin_s(44), 30);
assert_eq!(snap_trade_margin_s(45), 30, "tie goes to the lower step");
assert_eq!(snap_trade_margin_s(46), 60);
assert_eq!(snap_trade_margin_s(65), 65);
assert_eq!(snap_trade_margin_s(62), 60);
assert_eq!(snap_trade_margin_s(63), 65);
assert_eq!(snap_trade_margin_s(u32::MAX), MAX_TRADE_MARGIN_S);

assert_eq!(step_trade_margin_s(900, 1), 1800);
Expand All @@ -150,6 +153,9 @@ fn snap_and_step_walk_the_step_list() {
"the top absorbs the rest"
);
assert_eq!(step_trade_margin_s(30, -1), 30, "so does the bottom");
assert_eq!(step_trade_margin_s(60, 1), 65);
assert_eq!(step_trade_margin_s(65, 1), 180);
assert_eq!(step_trade_margin_s(65, -1), 60);
assert_eq!(
step_trade_margin_s(2700, 1),
3600,
Expand Down
128 changes: 104 additions & 24 deletions crates/moon-core/src/market/trade_replay/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -885,9 +885,10 @@ pub struct TradeReplaySeries {
/// 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.
/// Stored whole; DRAWN only where the points are not. [`Self::read_into`] cuts every bar
/// to the pieces that sit outside [`Self::covered`], so a body or wick never crosses the
/// prints. A minute the walk only partly covers keeps its uncovered side; a gap the walk
/// did not cover keeps its bar. The bars still hold the ground 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 Down Expand Up @@ -1129,24 +1130,37 @@ impl TradeReplaySeries {
}
// 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.
// keeps the ground the points never reached drawn — but inside a covered span the
// two layers are the same trades told twice.
//
// A bar that only partly overlaps a span is cut to the uncovered pieces. Keeping
// that minute whole paints its body and wick across the prints (a short trade is
// two full minute candles sitting on the ticks). Dropping the minute drops the
// gap inside it too, and a missing tile or a budget stop must still show a bar.
// The piece keeps the minute's OHLC — the uncovered seconds have no prints of
// their own to rebuild it — and its turnover is only that piece's share of the
// minute, so the volume band does not squeeze the whole minute into the stub.
// A piece the full bar wide stays on the series timeframe (`candle_tf_ms` 0); a
// stub carries its own width.
//
// 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. Empty 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. Per SPAN, never over the
// ticks, so nothing is cut until the upgrade lands. Per SPAN, never over the
// hull: a long position's two stretches keep the middle's bars drawn.
//
// 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.
// OUTPUT timeframe, which is the width the caller actually draws.
if !self.covered.is_empty() {
out.candles
.retain(|c| !bar_inside(c.t_open_ms, tf_ms, &self.covered));
withhold_covered_bars(
&mut out.candles,
&mut out.candle_tf_ms,
tf_ms,
&self.covered,
);
}
read.candles_changed = true;
}
Expand All @@ -1170,26 +1184,92 @@ impl TradeReplaySeries {
}
}

/// Whether one bar lies WHOLLY inside one covered span.
/// Replace `candles` with the pieces that sit outside `covered`, and write each piece's width.
///
/// 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.
/// Coverage is inclusive milliseconds. A bar is the half-open interval `[open, open + tf)`.
/// A piece that is the whole bar is stored at `tf_out` 0 so the chart uses the series
/// timeframe; a stub stores its own width in milliseconds. Turnover is scaled by the piece's
/// share of the bar. OHLC is the bar's own: the uncovered seconds have no prints to split it.
///
/// 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: [`TradeReplaySeries::covered`].
/// candles: Bars at the timeframe the caller draws, replaced in place.
/// tf_out: Parallel widths, cleared and filled one entry per surviving piece.
/// tf_ms: That timeframe, in milliseconds.
/// covered: The tick walk's exhaustive spans.
fn withhold_covered_bars(
candles: &mut Vec<ChartCandle>,
tf_out: &mut Vec<f32>,
tf_ms: i64,
covered: &Coverage,
) {
let src = std::mem::take(candles);
tf_out.clear();
let width = tf_ms.max(1);
for candle in src {
if !candle.t_open_ms.is_finite() {
continue;
}
let open = candle.t_open_ms as i64;
for (start, piece_w) in uncovered_pieces(open, width, covered) {
let whole = start == open && piece_w == width;
let mut piece = candle;
if !whole {
piece.t_open_ms = start as f64;
let share = piece_w as f64 / width as f64;
piece.volume = (f64::from(piece.volume) * share) as f32;
piece.quote_volume = (f64::from(piece.quote_volume) * share) as f32;
}
candles.push(piece);
tf_out.push(if whole { 0.0 } else { piece_w as f32 });
}
}
}

/// The parts of the half-open bar `[open, open + width)` that no covered span contains.
///
/// Args:
/// open: The bar's opening millisecond.
/// width: The bar's width in milliseconds. Zero yields nothing.
/// covered: Inclusive spans, disjoint and ascending.
///
/// Returns:
/// `true` when the whole bar sits inside one span.
fn bar_inside(t_open_ms: f64, tf_ms: i64, covered: &Coverage) -> bool {
if !t_open_ms.is_finite() {
return false;
/// `(start_ms, width_ms)` pieces, ascending, each of positive width.
fn uncovered_pieces(open: i64, width: i64, covered: &Coverage) -> Vec<(i64, i64)> {
if width <= 0 {
return Vec::new();
}
let mut pieces = vec![(open, open.saturating_add(width))];
for &(from, to) in covered.spans() {
let cut_lo = from;
let cut_hi = to.saturating_add(1);
if cut_lo >= cut_hi {
continue;
}
let mut next = Vec::with_capacity(pieces.len() + 1);
for (lo, hi) in pieces {
if cut_hi <= lo || cut_lo >= hi {
next.push((lo, hi));
continue;
}
if cut_lo > lo {
next.push((lo, cut_lo));
}
if cut_hi < hi {
next.push((cut_hi, hi));
}
}
pieces = next;
if pieces.is_empty() {
break;
}
}
let open = t_open_ms as i64;
covered.contains((open, open.saturating_add(tf_ms.max(1)) - 1))
pieces
.into_iter()
.filter_map(|(lo, hi)| {
let piece_w = hi.saturating_sub(lo);
(piece_w > 0).then_some((lo, piece_w))
})
.collect()
}

/// Lowest and highest finite positive price across a run of trade points.
Expand Down
85 changes: 76 additions & 9 deletions crates/moon-core/src/market/trade_replay/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -591,33 +591,95 @@ 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.
/// `read_into` must keep candle geometry out of the tick span.
///
/// Breakage: a bar that only partly overlaps the prints used to stay whole, so a short trade
/// drew two one-minute candles across the ticks. Cutting the bar to its uncovered side removes
/// that overlay. Dropping the bar instead would also drop a gap the walk never covered.
#[test]
fn replay_ticks_keep_both_straddling_edge_candles() {
fn replay_candles_stay_off_the_tick_span_and_a_gap_keeps_its_bar() {
let mut series = bars_only_series();
series.source = TradeReplaySource::Ticks;
series.covered = Coverage::one((MINUTE_MS / 2, 5 * MINUTE_MS / 2));
// Five minutes. Ticks cover [30s, 90s] and [150s, 210s]. The open interval between
// those spans is a gap (a missing tile, a budget stop) and must still draw.
series.candles = (0..5)
.map(|minute| {
let mut bar = candle(minute * MINUTE_MS, 90.0, 110.0, 100.0);
bar.quote_volume = 60_000.0;
bar.volume = 60_000.0;
bar
})
.collect();
let spans = [(30_000, 90_000), (150_000, 210_000)];
series.covered = Coverage::from_spans(spans);
let mut out = ChartHistoryBuffers::default();

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

let drawn = drawn_half_open(&out);
for &(open, end) in &drawn {
for &(from, to) in &spans {
assert!(
end <= from || open > to,
"candle [{open}, {end}) overlaps covered [{from}, {to}]"
);
}
}
// Minute 0 loses its covered tail. The gap (90_001..=149_999) is two stubs, one from
// each minute the spans bite. Minute 4 never meets a span and stays a full bar.
assert_eq!(
drawn,
vec![
(0, 30_000),
(90_001, 120_000),
(120_000, 150_000),
(210_001, 240_000),
(240_000, 300_000),
]
);
assert_eq!(
out.candle_tf_ms,
vec![30_000.0, 29_999.0, 30_000.0, 29_999.0, 0.0],
"a full bar keeps the series timeframe; a stub carries its own width"
);
let quote = |open: i64| {
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"
.find(|candle| candle.t_open_ms as i64 == open)
.map(|candle| candle.quote_volume)
.unwrap_or(f32::NAN)
};
assert!(
(quote(0) - 30_000.0).abs() < 1.0,
"the stub takes its share of the minute, not the whole minute"
);
assert!(
(quote(240_000) - 60_000.0).abs() < 1.0,
"a bar outside the ticks keeps its turnover"
);
}

/// Half-open `[open, end)` of each candle on the draw list, using the series minute where
/// the parallel width is 0.
fn drawn_half_open(out: &ChartHistoryBuffers) -> Vec<(i64, i64)> {
out.candles
.iter()
.enumerate()
.map(|(index, candle)| {
let own = out.candle_tf_ms.get(index).copied().unwrap_or(0.0);
let width = if own > 0.0 { own as i64 } else { MINUTE_MS };
let open = candle.t_open_ms as i64;
(open, open + width)
})
.collect()
}

/// `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]
Expand Down Expand Up @@ -1204,4 +1266,9 @@ fn replay_with_split_coverage_keeps_the_middle_bars() {
vec![MINUTE_MS],
"the first and third minutes are covered by ticks; the middle minute stays a bar"
);
assert_eq!(
out.candle_tf_ms,
vec![0.0],
"a minute that sits wholly in the gap keeps the series width"
);
}
7 changes: 4 additions & 3 deletions crates/moon-ui-gpui/src/chartdx/shaders/candles.hlsl
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,10 @@ CandleOut candles_vertex(uint vid : SV_VertexID, uint iid : SV_InstanceID) {
if (cd.t_open >= cs_hide_start) {
return cull_out(); // Omit the candle in the "trades only" zone.
}
// The history tail is completed with higher timeframes: those candles carry their own
// tf_rel (width) and muted colors to distinguish them from the selected timeframe.
bool foreign_tf = cd.tf_rel > 0.0 && abs(cd.tf_rel - cs_tf_rel) > 0.5;
// A wider own timeframe is the coarse history tail and is muted. A narrower own width
// is a trade-window candle cut to the uncovered side of a tick span; it keeps the
// series color. Width still follows tf_rel whenever the candle carries one.
bool foreign_tf = cd.tf_rel > cs_tf_rel + 0.5;
float tf_rel = (cd.tf_rel > 0.0) ? cd.tf_rel : cs_tf_rel;
float x0 = cv_bounds.x + (cd.t_open - cv_view_time0) * cv_time_to_px;
float x1 = x0 + tf_rel * cv_time_to_px;
Expand Down
4 changes: 3 additions & 1 deletion crates/moon-ui-gpui/src/chartdx/shaders/chart_native.metal
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,9 @@ vertex CandleOut candles_vertex(uint vid [[vertex_id]], uint iid [[instance_id]]
if (cd.t_open >= cs.hide_start) {
return candle_cull_out(); // Omit the candle in the "trades only" zone.
}
bool foreign_tf = cd.tf_rel > 0.0 && fabs(cd.tf_rel - cs.tf_rel) > 0.5;
// Wider own timeframe: coarse history tail, muted. Narrower: a clipped trade-window
// candle, series color. Width still follows tf_rel when the candle carries one.
bool foreign_tf = cd.tf_rel > cs.tf_rel + 0.5;
float tf_rel = (cd.tf_rel > 0.0) ? cd.tf_rel : cs.tf_rel;
float x0 = cv.bounds.x + (cd.t_open - cv.view_time0) * cv.time_to_px;
float x1 = x0 + tf_rel * cv.time_to_px;
Expand Down
4 changes: 3 additions & 1 deletion crates/moon-ui-gpui/src/chartdx/shaders/native_candles.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ fn candles_vertex(@builtin(vertex_index) vid: u32, @builtin(instance_index) iid:
if cd.t_open >= cs.hide_start {
return cull_out(); // Omit the candle in the "trades only" zone.
}
let foreign_tf = cd.tf_rel > 0.0 && abs(cd.tf_rel - cs.tf_rel) > 0.5;
// Wider own timeframe: coarse history tail, muted. Narrower: a clipped trade-window
// candle, series color. Width still follows tf_rel when the candle carries one.
let foreign_tf = cd.tf_rel > cs.tf_rel + 0.5;
var tf_rel = cs.tf_rel;
if cd.tf_rel > 0.0 {
tf_rel = cd.tf_rel;
Expand Down
15 changes: 8 additions & 7 deletions crates/moon-ui-gpui/src/settings/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,8 @@ impl SettingsView {
}

/// Moves the prints kept around a trade, per end, `delta` steps along
/// `TRADE_MARGIN_STEPS_S` (30 s … 120 min, not a fixed amount), and updates live state and
/// storage.toml.
/// `TRADE_MARGIN_STEPS_S` (30 s … 120 min, including 65 s, not a fixed amount), and updates
/// live state and storage.toml.
fn adjust_trades_margin_step(&mut self, delta: i32, cx: &mut Context<Self>) {
let v = storage_cfg::step_trade_margin_s(self.storage.cfg.trade_replay.margin_s, delta);
if self.storage.cfg.trade_replay.margin_s != v {
Expand Down Expand Up @@ -240,13 +240,14 @@ impl SettingsView {
}
}

/// The stepper's label for a margin: whole seconds under a minute, whole minutes from
/// there — every step of `TRADE_MARGIN_STEPS_S` is one or the other.
/// The stepper's label for a margin: a whole number of minutes when the step divides by
/// 60, seconds otherwise. 65 s is a step and must not read as "1 min", which is what 60 s
/// already says.
fn trades_margin_label(secs: u32) -> String {
if secs < 60 {
t!("storage.trades_sec", s = secs).to_string()
} else {
if secs % 60 == 0 {
t!("storage.trades_min", min = secs / 60).to_string()
} else {
t!("storage.trades_sec", s = secs).to_string()
}
}

Expand Down
Loading