diff --git a/crates/moon-core/src/config/storage.rs b/crates/moon-core/src/config/storage.rs index ed013bc2..336bbebb 100644 --- a/crates/moon-core/src/config/storage.rs +++ b/crates/moon-core/src/config/storage.rs @@ -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; diff --git a/crates/moon-core/src/config/storage/tests.rs b/crates/moon-core/src/config/storage/tests.rs index c53fc810..c268518d 100644 --- a/crates/moon-core/src/config/storage/tests.rs +++ b/crates/moon-core/src/config/storage/tests.rs @@ -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); @@ -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, diff --git a/crates/moon-core/src/market/trade_replay/mod.rs b/crates/moon-core/src/market/trade_replay/mod.rs index 0236fbc0..2b2ce7f6 100644 --- a/crates/moon-core/src/market/trade_replay/mod.rs +++ b/crates/moon-core/src/market/trade_replay/mod.rs @@ -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, /// Trade points in ascending time. Empty when [`Self::source`] is /// [`TradeReplaySource::Klines1m`]; carried alongside [`Self::candles`] for @@ -1129,8 +1130,18 @@ 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 @@ -1138,15 +1149,18 @@ impl TradeReplaySeries { // 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; } @@ -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, + tf_out: &mut Vec, + 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. diff --git a/crates/moon-core/src/market/trade_replay/tests.rs b/crates/moon-core/src/market/trade_replay/tests.rs index bf475e2f..c234d079 100644 --- a/crates/moon-core/src/market/trade_replay/tests.rs +++ b/crates/moon-core/src/market/trade_replay/tests.rs @@ -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![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] @@ -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" + ); } diff --git a/crates/moon-ui-gpui/src/chartdx/shaders/candles.hlsl b/crates/moon-ui-gpui/src/chartdx/shaders/candles.hlsl index 1ddcb829..66da37f1 100644 --- a/crates/moon-ui-gpui/src/chartdx/shaders/candles.hlsl +++ b/crates/moon-ui-gpui/src/chartdx/shaders/candles.hlsl @@ -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; diff --git a/crates/moon-ui-gpui/src/chartdx/shaders/chart_native.metal b/crates/moon-ui-gpui/src/chartdx/shaders/chart_native.metal index 6cf505c1..33b8c5ee 100644 --- a/crates/moon-ui-gpui/src/chartdx/shaders/chart_native.metal +++ b/crates/moon-ui-gpui/src/chartdx/shaders/chart_native.metal @@ -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; diff --git a/crates/moon-ui-gpui/src/chartdx/shaders/native_candles.wgsl b/crates/moon-ui-gpui/src/chartdx/shaders/native_candles.wgsl index a9ee2021..bd269632 100644 --- a/crates/moon-ui-gpui/src/chartdx/shaders/native_candles.wgsl +++ b/crates/moon-ui-gpui/src/chartdx/shaders/native_candles.wgsl @@ -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; diff --git a/crates/moon-ui-gpui/src/settings/storage.rs b/crates/moon-ui-gpui/src/settings/storage.rs index 609b6ace..59908714 100644 --- a/crates/moon-ui-gpui/src/settings/storage.rs +++ b/crates/moon-ui-gpui/src/settings/storage.rs @@ -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) { 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 { @@ -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() } }