Skip to main content

egui_charts/data/
footprint.rs

1//! Trade volume at integer price levels. No volume or side is inferred from OHLC bars.
2
3use super::FeedError;
4use crate::model::{BarClock, OrderSide, Tick};
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8use std::ops::RangeBounds;
9
10/// Inclusive thresholds allow a relative error of 1e-9, which covers the rounding of sizes
11/// summed trade by trade, and is far below one lot at any realistic volume.
12pub(crate) fn meets_threshold(value: f64, threshold: f64) -> bool {
13    value >= threshold * (1.0 - 1e-9)
14}
15
16/// Executed volume at one grouped price, with sells on the bid and buys on the ask.
17#[derive(Clone, Debug, Default, PartialEq)]
18pub struct FootprintLevel {
19    /// Integer price level, in multiples of [`Footprint::price_step`].
20    pub level: i64,
21    /// Sell aggressor volume.
22    pub bid: f64,
23    /// Buy aggressor volume.
24    pub ask: f64,
25    /// Volume whose aggressor is unknown. Never assigned to either side.
26    pub unknown: f64,
27}
28
29impl FootprintLevel {
30    /// Buy volume minus sell volume.
31    pub fn delta(&self) -> f64 {
32        self.ask - self.bid
33    }
34
35    /// Total executed volume, including unclassified trades.
36    pub fn volume(&self) -> f64 {
37        self.bid + self.ask + self.unknown
38    }
39
40    /// Ask divided by bid. Undefined when the bid is zero.
41    pub fn ratio(&self) -> Option<f64> {
42        (self.bid > 0.0).then(|| self.ask / self.bid)
43    }
44}
45
46/// How `studies::TradeProfile::nodes` (requires `studies`) chooses volume nodes.
47///
48/// The default compares each price row with its immediate neighbours, which is the most
49/// sensitive setting. Raising `smoothing` or `prominence` leaves only the broader nodes.
50#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
51pub struct NodeSensitivity {
52    /// Rows on each side of a row that are averaged with it before comparing; zero compares
53    /// the rows as traded. Untraded rows count as zero volume.
54    pub smoothing: u32,
55    /// Smallest rise of a peak above, or drop of a valley below, the higher respectively
56    /// lower of its neighbouring plateaus, as a share of the largest smoothed volume. NaN
57    /// counts as zero and values are clamped to 0..=1.
58    pub prominence: f64,
59}
60
61/// One bar's executed volume and intrabar delta path, starting at zero.
62#[derive(Clone, Debug, PartialEq)]
63pub struct FootprintBar {
64    /// Bar open, obtained from the supplied [`BarClock`].
65    pub time: DateTime<Utc>,
66    /// Traded levels in ascending price order; empty prices allocate nothing.
67    pub levels: BTreeMap<i64, FootprintLevel>,
68    /// Total sell aggressor volume.
69    pub bid: f64,
70    /// Total buy aggressor volume.
71    pub ask: f64,
72    /// Total unclassified volume.
73    pub unknown: f64,
74    /// Minimum running delta, including the initial zero.
75    pub min_delta: f64,
76    /// Maximum running delta, including the initial zero.
77    pub max_delta: f64,
78}
79
80impl FootprintBar {
81    /// A bar with no executions, opening at `time`.
82    pub(crate) fn empty(time: DateTime<Utc>) -> Self {
83        Self {
84            time,
85            levels: BTreeMap::new(),
86            bid: 0.0,
87            ask: 0.0,
88            unknown: 0.0,
89            min_delta: 0.0,
90            max_delta: 0.0,
91        }
92    }
93
94    /// Adds the executions of the next bar in time, extending the running delta path.
95    pub(crate) fn absorb(&mut self, next: &FootprintBar) {
96        let open = self.delta();
97        self.min_delta = self.min_delta.min(open + next.min_delta);
98        self.max_delta = self.max_delta.max(open + next.max_delta);
99        self.bid += next.bid;
100        self.ask += next.ask;
101        self.unknown += next.unknown;
102        for (&level, row) in &next.levels {
103            let target = self.levels.entry(level).or_insert_with(|| FootprintLevel {
104                level,
105                ..Default::default()
106            });
107            target.bid += row.bid;
108            target.ask += row.ask;
109            target.unknown += row.unknown;
110        }
111    }
112
113    /// Buy volume minus sell volume.
114    pub fn delta(&self) -> f64 {
115        self.ask - self.bid
116    }
117
118    /// Total executed volume.
119    pub fn volume(&self) -> f64 {
120        self.bid + self.ask + self.unknown
121    }
122
123    /// Delta as a percentage of all executed volume; undefined for zero volume.
124    pub fn delta_percent(&self) -> Option<f64> {
125        (self.volume() > 0.0).then(|| self.delta() / self.volume() * 100.0)
126    }
127
128    /// Highest-volume level. A tie takes the lower price.
129    pub fn poc(&self) -> Option<i64> {
130        self.levels
131            .values()
132            .max_by(|a, b| {
133                a.volume()
134                    .total_cmp(&b.volume())
135                    .then_with(|| b.level.cmp(&a.level))
136            })
137            .map(|level| level.level)
138    }
139
140    /// Contiguous value area grown from the POC toward the larger adjacent volume.
141    /// Ties add one row on each side. `share` is clamped to 0..=1, NaN to zero.
142    /// Gaps hold zero volume.
143    /// Returns inclusive lower and upper level keys; a zero-volume bar has no area.
144    pub fn value_area(&self, share: f64) -> Option<(i64, i64)> {
145        if self.volume() <= 0.0 {
146            return None;
147        }
148        let rows: Vec<_> = self.levels.values().collect();
149        let poc = self.poc()?;
150        let mut low = rows.partition_point(|row| row.level < poc);
151        let mut high = low;
152        let mut volume = rows[low].volume();
153        let target = self.volume()
154            * if share.is_nan() {
155                0.0
156            } else {
157                share.clamp(0.0, 1.0)
158            };
159        let mut low_key = rows[low].level;
160        let mut high_key = low_key;
161        while volume < target && (low > 0 || high + 1 < rows.len()) {
162            let below = low.checked_sub(1).map(|i| rows[i]);
163            let above = rows.get(high + 1).copied();
164            let below_gap = below.map(|row| low_key as i128 - row.level as i128);
165            let above_gap = above.map(|row| row.level as i128 - high_key as i128);
166            // Skip empty spans in one step, without comparing across untraded prices.
167            let gap = below_gap.into_iter().chain(above_gap).min().unwrap_or(1);
168            if gap > 1 {
169                if below.is_some() {
170                    low_key = (low_key as i128 - gap + 1) as i64;
171                }
172                if above.is_some() {
173                    high_key = (high_key as i128 + gap - 1) as i64;
174                }
175            }
176            let bid_volume = below.map(|row| {
177                if row.level as i128 + 1 == low_key as i128 {
178                    row.volume()
179                } else {
180                    0.0
181                }
182            });
183            let ask_volume = above.map(|row| {
184                if row.level as i128 - 1 == high_key as i128 {
185                    row.volume()
186                } else {
187                    0.0
188                }
189            });
190            if below.is_some() && (above.is_none() || bid_volume >= ask_volume) {
191                low_key -= 1;
192                if below.is_some_and(|row| row.level == low_key) {
193                    low -= 1;
194                    volume += rows[low].volume();
195                }
196            }
197            if above.is_some() && (below.is_none() || ask_volume >= bid_volume) {
198                high_key += 1;
199                if above.is_some_and(|row| row.level == high_key) {
200                    high += 1;
201                    volume += rows[high].volume();
202                }
203            }
204        }
205        Some((low_key, high_key))
206    }
207
208    /// (bid, ask) dominance at `level`, comparing horizontally or to the adjacent price.
209    /// Missing adjacent prices and the outer diagonals do not signal an imbalance.
210    /// A positive side against a zero opposite side is imbalanced. The dominant side needs at
211    /// least `min_volume`, inclusive and with the ratio's rounding tolerance; zero keeps every
212    /// ratio signal. Ratios below 1, negative minima or non-finite values produce no signals.
213    pub fn imbalance(
214        &self,
215        level: i64,
216        ratio: f64,
217        diagonal: bool,
218        min_volume: f64,
219    ) -> (bool, bool) {
220        let Some(row) = self.levels.get(&level) else {
221            return (false, false);
222        };
223        if !ratio.is_finite() || ratio < 1.0 || !min_volume.is_finite() || min_volume < 0.0 {
224            return (false, false);
225        }
226        let dominant = |a: f64, b: f64| {
227            a > b && meets_threshold(a, min_volume) && meets_threshold(a / ratio, b)
228        };
229        if !diagonal {
230            return (dominant(row.bid, row.ask), dominant(row.ask, row.bid));
231        }
232        let bid = level
233            .checked_add(1)
234            .and_then(|key| self.levels.get(&key))
235            .is_some_and(|above| dominant(row.bid, above.ask));
236        let ask = level
237            .checked_sub(1)
238            .and_then(|key| self.levels.get(&key))
239            .is_some_and(|below| dominant(row.ask, below.bid));
240        (bid, ask)
241    }
242}
243
244/// Trade aggregation for one instrument, shared by footprints and delta studies.
245///
246/// Build once for historical trades and call [`push`](Self::push) for live executions.
247/// Trades must be chronological, with exchange order preserved for equal timestamps, so
248/// delta highs and lows are exact. Replay corrections by rebuilding, rather than silently
249/// approximating their intrabar path. The host deduplicates ids (the chart's feed already does).
250#[derive(Clone, Debug)]
251pub struct Footprint {
252    clock: BarClock,
253    step: f64,
254    tick_size: f64,
255    ticks_per_row: u32,
256    bars: BTreeMap<DateTime<Utc>, FootprintBar>,
257    /// Price rows held across all bars.
258    rows: usize,
259    last: Option<DateTime<Utc>>,
260    min_size: f64,
261    max_size: f64,
262    max_bars: usize,
263    max_rows: usize,
264    /// Bars opening up to this time lack executions that were dropped or never received.
265    incomplete_through: Option<DateTime<Utc>>,
266}
267
268impl Footprint {
269    /// Creates an empty aggregation. `tick_size` must be finite and positive, and
270    /// `ticks_per_row` nonzero. Prices round to the nearest integer tick, then group
271    /// downwards, including negative prices. Grouping never changes trade-side totals.
272    pub fn new(clock: BarClock, tick_size: f64, ticks_per_row: u32) -> Result<Self, FeedError> {
273        let step = tick_size * ticks_per_row as f64;
274        if !tick_size.is_finite() || tick_size <= 0.0 {
275            return Err(FeedError::InvalidData(format!(
276                "footprint tick_size must be finite and above zero, got {tick_size:?}"
277            )));
278        }
279        if ticks_per_row == 0 {
280            return Err(FeedError::InvalidData(
281                "footprint ticks_per_row must be at least 1, got 0".into(),
282            ));
283        }
284        if !step.is_finite() {
285            return Err(FeedError::InvalidData(format!(
286                "footprint price step of tick_size {tick_size:?} times ticks_per_row {ticks_per_row} is not finite"
287            )));
288        }
289        Ok(Self {
290            clock,
291            step,
292            tick_size,
293            ticks_per_row,
294            bars: BTreeMap::new(),
295            rows: 0,
296            last: None,
297            min_size: 0.0,
298            max_size: f64::MAX,
299            max_bars: usize::MAX,
300            max_rows: usize::MAX,
301            incomplete_through: None,
302        })
303    }
304
305    /// Keeps at most `bars` bars and `rows` price rows in all, dropping the oldest bars first.
306    /// The newest bar is always kept, whole. Dropped bars are counted as incomplete.
307    pub(crate) fn set_budget(&mut self, bars: usize, rows: usize) {
308        self.max_bars = bars;
309        self.max_rows = rows;
310        self.enforce_budget();
311    }
312
313    /// Open of the oldest bar whose executions are all held: earlier bars were cut by the
314    /// budget or by trades dropped from the host's store, or may lack trades that never
315    /// arrived, and must not be shown as complete.
316    pub fn complete_from(&self) -> Option<DateTime<Utc>> {
317        self.complete_bars(..).next().map(|bar| bar.time)
318    }
319
320    /// The bars in `range` from [`complete_from`](Self::complete_from) on. Panics as
321    /// [`BTreeMap::range`] does.
322    pub(crate) fn complete_bars(
323        &self,
324        range: impl RangeBounds<DateTime<Utc>>,
325    ) -> impl Iterator<Item = &FootprintBar> {
326        let cut = self.incomplete_through;
327        self.bars
328            .range(range)
329            .filter(move |(time, _)| cut.is_none_or(|cut| **time > cut))
330            .map(|(_, bar)| bar)
331    }
332
333    /// Takes out the executions dropped from the host's store, the newest at `through`, and
334    /// rebuilds the bar containing it from `held`, the executions still held in order. Earlier
335    /// bars go. Delta extrema cannot be recovered by subtraction, so the bar is summed again.
336    #[cfg_attr(
337        all(not(feature = "widget"), not(test)),
338        expect(dead_code, reason = "only the chart widget evicts from its footprint")
339    )]
340    pub(crate) fn drop_through<'a>(
341        &mut self,
342        through: DateTime<Utc>,
343        held: impl IntoIterator<Item = &'a Tick>,
344    ) {
345        let open = self.clock.bar_open(through);
346        self.incomplete_through = self.incomplete_through.max(Some(open));
347        self.bars = self.bars.split_off(&open);
348        if self.bars.contains_key(&open) {
349            let mut part = Self {
350                clock: self.clock.clone(),
351                bars: BTreeMap::new(),
352                rows: 0,
353                last: None,
354                ..*self
355            };
356            for trade in held
357                .into_iter()
358                .take_while(|trade| self.clock.bar_open(trade.ts) <= open)
359            {
360                let _ = part.push(trade);
361            }
362            match part.bars.remove(&open) {
363                Some(bar) => self.bars.insert(open, bar),
364                None => self.bars.remove(&open),
365            };
366        }
367        self.rows = self.bars.values().map(|bar| bar.levels.len()).sum();
368    }
369
370    /// Counts the bar holding `first`, the oldest execution held, and earlier bars as
371    /// incomplete: executions before it may exist that never arrived.
372    #[cfg_attr(
373        not(feature = "widget"),
374        expect(
375            dead_code,
376            reason = "only the chart widget knows where its trades begin"
377        )
378    )]
379    pub(crate) fn received_from(&mut self, first: DateTime<Utc>) {
380        let open = self.clock.bar_open(first);
381        self.incomplete_through = self.incomplete_through.max(Some(open));
382    }
383
384    fn enforce_budget(&mut self) {
385        while self.bars.len() > 1 && (self.bars.len() > self.max_bars || self.rows > self.max_rows)
386        {
387            if let Some((time, bar)) = self.bars.pop_first() {
388                self.rows -= bar.levels.len();
389                self.incomplete_through = self.incomplete_through.max(Some(time));
390            }
391        }
392    }
393
394    /// Restricts executions to this inclusive size range, before aggregation.
395    /// Must be called before adding trades. Invalid ranges return an error without changing it.
396    pub fn set_size_filter(&mut self, min: f64, max: f64) -> Result<(), FeedError> {
397        if self.last.is_some() {
398            return Err(FeedError::InvalidData(
399                "footprint size filter must be set before the first trade".into(),
400            ));
401        }
402        if !min.is_finite() || min < 0.0 {
403            return Err(FeedError::InvalidData(format!(
404                "footprint size filter min must be finite and at least 0, got {min:?}"
405            )));
406        }
407        if !max.is_finite() {
408            return Err(FeedError::InvalidData(format!(
409                "footprint size filter max must be finite, got {max:?}"
410            )));
411        }
412        if max < min {
413            return Err(FeedError::InvalidData(format!(
414                "footprint size filter max {max:?} is below min {min:?}"
415            )));
416        }
417        self.min_size = min;
418        self.max_size = max;
419        Ok(())
420    }
421
422    /// Instrument tick size before price-row grouping.
423    pub fn tick_size(&self) -> f64 {
424        self.tick_size
425    }
426
427    /// Height of one grouped price row.
428    pub fn price_step(&self) -> f64 {
429        self.step
430    }
431
432    /// The calendar the bars open by.
433    #[cfg(feature = "studies")]
434    pub(crate) fn clock(&self) -> &BarClock {
435        &self.clock
436    }
437
438    /// The level a trade at `price` falls in: the nearest tick, grouped downwards. `None` when
439    /// the price is not finite or the level overflows.
440    pub fn level_of(&self, price: f64) -> Option<i64> {
441        let level = ((price / self.tick_size).round() / self.ticks_per_row as f64).floor();
442        (level.is_finite() && level >= i64::MIN as f64 && level < i64::MAX as f64)
443            .then_some(level as i64)
444    }
445
446    /// Aggregated bars in chronological order.
447    pub fn bars(&self) -> &BTreeMap<DateTime<Utc>, FootprintBar> {
448        &self.bars
449    }
450
451    /// The aggregated bar containing `time`, using the same UTC slots as trade aggregation.
452    /// Accepts a bar stamped anywhere inside its slot, as the feed history contract does.
453    pub fn bar_at(&self, time: DateTime<Utc>) -> Option<&FootprintBar> {
454        self.bars.get(&self.clock.bar_open(time))
455    }
456
457    /// The executions of the bars that contain `first` through `last`, inclusive, as one bar
458    /// opening with the first of them: the exact profile of a span of the chart. Bars before
459    /// [`complete_from`](Self::complete_from) are left out, as their executions are not all
460    /// held. Empty when no complete bar traded there or `last` precedes `first`.
461    pub fn profile(&self, first: DateTime<Utc>, last: DateTime<Utc>) -> FootprintBar {
462        let (first, last) = (self.clock.bar_open(first), self.clock.bar_open(last));
463        let mut profile = FootprintBar::empty(first);
464        if first <= last {
465            for bar in self.complete_bars(first..=last) {
466                profile.absorb(bar);
467            }
468        }
469        profile
470    }
471
472    /// Adds an execution, returning whether it passed the size filter. Invalid, out-of-order
473    /// or overflowing input returns an error and changes nothing. Unknown side stays unknown.
474    /// Zero-sized executions are ignored.
475    pub fn push(&mut self, trade: &Tick) -> Result<bool, FeedError> {
476        if !trade.price.is_finite() {
477            return Err(FeedError::InvalidData(format!(
478                "footprint trade price must be finite, got {:?}",
479                trade.price
480            )));
481        }
482        if !trade.size.is_finite() || trade.size < 0.0 {
483            return Err(FeedError::InvalidData(format!(
484                "footprint trade size must be finite and at least 0, got {:?}",
485                trade.size
486            )));
487        }
488        let Some(level) = self.level_of(trade.price) else {
489            return Err(FeedError::InvalidData(format!(
490                "footprint trade price {:?} is outside the grid of step {:?}",
491                trade.price, self.step
492            )));
493        };
494        if let Some(last) = self.last.filter(|last| trade.ts < *last) {
495            return Err(FeedError::InvalidData(format!(
496                "footprint trade at {} precedes the last trade at {last}",
497                trade.ts
498            )));
499        }
500        if trade.size < self.min_size || trade.size > self.max_size || trade.size == 0.0 {
501            return Ok(false);
502        }
503        let time = self.clock.bar_open(trade.ts);
504        if self
505            .bars
506            .get(&time)
507            .is_some_and(|bar| !(bar.volume() + trade.size).is_finite())
508        {
509            return Err(FeedError::InvalidData("footprint volume overflow".into()));
510        }
511        let bar = self.bars.entry(time).or_insert_with(|| FootprintBar {
512            time,
513            levels: BTreeMap::new(),
514            bid: 0.0,
515            ask: 0.0,
516            unknown: 0.0,
517            min_delta: 0.0,
518            max_delta: 0.0,
519        });
520        let row = bar.levels.entry(level).or_insert_with(|| {
521            self.rows += 1;
522            FootprintLevel {
523                level,
524                ..Default::default()
525            }
526        });
527        match trade.side {
528            Some(OrderSide::Buy) => {
529                row.ask += trade.size;
530                bar.ask += trade.size;
531            }
532            Some(OrderSide::Sell) => {
533                row.bid += trade.size;
534                bar.bid += trade.size;
535            }
536            _ => {
537                row.unknown += trade.size;
538                bar.unknown += trade.size;
539            }
540        }
541        bar.min_delta = bar.min_delta.min(bar.delta());
542        bar.max_delta = bar.max_delta.max(bar.delta());
543        self.last = Some(trade.ts);
544        self.enforce_budget();
545        Ok(true)
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use crate::model::Timeframe;
553    use chrono::TimeDelta;
554
555    #[test]
556    fn argument_errors_name_the_argument_and_its_value() {
557        use crate::data::{
558            DepthHistory, DepthHistoryConfig, MarketEvent, ReplayClock, ReplayEvent,
559            aggregate_trades,
560        };
561        let t0: DateTime<Utc> = "2026-01-05T10:00:00Z".parse().unwrap();
562        let t1 = t0 + TimeDelta::seconds(1);
563        let clock = || BarClock::new(Timeframe::Min1);
564        let trade = |ts, price, size| Tick::new(ts, price, size, None);
565        let base = DepthHistoryConfig::default();
566        let depth = |config| DepthHistory::new(config).map(drop);
567        let fresh = || Footprint::new(clock(), 1.0, 1).unwrap();
568        let mut started = fresh();
569        started.push(&trade(t1, 100.0, 1.0)).unwrap();
570        let event = |ts| ReplayEvent::new("A", t0, 0, MarketEvent::Trade(trade(ts, 100.0, 1.0)));
571        #[rustfmt::skip]
572        let cases = [
573            (depth(DepthHistoryConfig { cadence: TimeDelta::zero(), ..base.clone() }), "depth history cadence must be at least one millisecond, got P0D"),
574            (depth(DepthHistoryConfig { rows: 0, ..base.clone() }), "depth history rows must be at least 1, got 0"),
575            (depth(DepthHistoryConfig { ticks_per_row: 0, ..base.clone() }), "depth history ticks_per_row must be at least 1, got 0"),
576            (depth(DepthHistoryConfig { columns: 1, ..base.clone() }), "depth history columns must be at least 2, got 1"),
577            (Footprint::new(clock(), f64::NAN, 1).map(drop), "footprint tick_size must be finite and above zero, got NaN"),
578            (Footprint::new(clock(), 1.0, 0).map(drop), "footprint ticks_per_row must be at least 1, got 0"),
579            (Footprint::new(clock(), 1e300, u32::MAX).map(drop), "footprint price step of tick_size 1e300 times ticks_per_row 4294967295 is not finite"),
580            (started.clone().set_size_filter(0.0, 1.0), "footprint size filter must be set before the first trade"),
581            (fresh().set_size_filter(-1.0, 1.0), "footprint size filter min must be finite and at least 0, got -1.0"),
582            (fresh().set_size_filter(0.0, f64::NAN), "footprint size filter max must be finite, got NaN"),
583            (fresh().set_size_filter(5.0, 1.0), "footprint size filter max 1.0 is below min 5.0"),
584            (started.clone().push(&trade(t1, f64::NAN, 1.0)).map(drop), "footprint trade price must be finite, got NaN"),
585            (started.clone().push(&trade(t1, 100.0, -1.0)).map(drop), "footprint trade size must be finite and at least 0, got -1.0"),
586            (Footprint::new(clock(), 1e-300, u32::MAX).unwrap().push(&trade(t1, 1e6, 1.0)).map(drop), "footprint trade price 1000000.0 is outside the grid of step 4.294967295e-291"),
587            (started.clone().push(&trade(t0, 100.0, 1.0)).map(drop), "footprint trade at 2026-01-05 10:00:00 UTC precedes the last trade at 2026-01-05 10:00:01 UTC"),
588            (ReplayClock::new(vec![event(t1)]).map(drop), "replay event of \"A\" at 2026-01-05 10:00:00 UTC with sequence 0 is invalid"),
589            (ReplayClock::new(vec![event(t0), event(t0)]).map(drop), "replay events of \"A\" at 2026-01-05 10:00:00 UTC share sequence 0"),
590            (ReplayClock::new(Vec::new()).unwrap().set_speed(-1.0), "replay speed must be finite and at least 0, got -1.0"),
591            (aggregate_trades(&[trade(t0, 100.0, 1.0), trade(t1, f64::NAN, 1.0)], Timeframe::Min1).map(drop), "trade 1 price must be finite, got NaN"),
592            (aggregate_trades(&[trade(t0, 100.0, 1.0), trade(t1, 100.0, -1.0)], Timeframe::Min1).map(drop), "trade 1 size must be finite and at least 0, got -1.0"),
593            (aggregate_trades(&[trade(t1, 100.0, 1.0), trade(t0, 100.0, 1.0)], Timeframe::Min1).map(drop), "trade 1 at 2026-01-05 10:00:00 UTC is out of time order"),
594        ];
595        for (result, expected) in cases {
596            assert_eq!(
597                result.unwrap_err().to_string(),
598                format!("invalid data: {expected}")
599            );
600        }
601    }
602
603    #[test]
604    fn trades_keep_side_path_grouping_and_value_area() {
605        let start: DateTime<Utc> = "2026-01-05T10:00:00Z".parse().unwrap();
606        let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
607        // Running delta: 0, -3, 5, 3, 3, 7. Total volume 24, bid 5, ask 12, unknown 7.
608        for (i, price, size, side) in [
609            (0, 100.0, 3.0, Some(OrderSide::Sell)),
610            (1, 100.25, 8.0, Some(OrderSide::Buy)),
611            (2, 100.0, 2.0, Some(OrderSide::Sell)),
612            (3, 100.5, 7.0, None),
613            (4, 100.75, 4.0, Some(OrderSide::Buy)),
614        ] {
615            flow.push(&Tick::new(start + TimeDelta::seconds(i), price, size, side))
616                .unwrap();
617        }
618        let bar = &flow.bars()[&start];
619        assert_eq!(
620            (
621                bar.bid,
622                bar.ask,
623                bar.volume(),
624                bar.delta(),
625                bar.min_delta,
626                bar.max_delta
627            ),
628            (5.0, 12.0, 24.0, 7.0, -3.0, 7.0)
629        );
630        assert_eq!(flow.bar_at(start + TimeDelta::seconds(59)), Some(bar));
631        assert_eq!(bar.poc(), Some(401));
632        // POC 8, choose upper neighbour 7 over lower 5, then lower 5: 20 >= 16.8.
633        assert_eq!(bar.value_area(0.7), Some((400, 402)));
634        assert_eq!(bar.imbalance(401, 1.5, true, 0.0), (false, true));
635        assert_eq!(bar.imbalance(403, 1.5, true, 0.0), (false, true));
636        assert_eq!(bar.delta_percent(), Some(7.0 / 24.0 * 100.0));
637        let snapshot = flow.bars().clone();
638        for trade in [
639            Tick::new(start, 100.0, 1.0, None),
640            Tick::new(start + TimeDelta::seconds(5), f64::NAN, 1.0, None),
641        ] {
642            assert!(flow.push(&trade).is_err());
643            assert_eq!(flow.bars(), &snapshot);
644        }
645        // A negative price groups down, and decimal ticks do not fall into the row below.
646        let mut grouped = Footprint::new(BarClock::new(Timeframe::Min1), 0.1, 3).unwrap();
647        for price in [-0.1, 0.3, 0.6] {
648            grouped.push(&Tick::new(start, price, 1.0, None)).unwrap();
649        }
650        assert_eq!(
651            grouped.bars()[&start]
652                .levels
653                .keys()
654                .copied()
655                .collect::<Vec<_>>(),
656            [-1, 1, 2]
657        );
658        // The row at 110 is not adjacent to the POC at 100. First take the 4 below it.
659        let mut sparse = Footprint::new(BarClock::new(Timeframe::Min1), 1.0, 1).unwrap();
660        for (price, size) in [(99.0, 4.0), (100.0, 10.0), (110.0, 9.0)] {
661            sparse.push(&Tick::new(start, price, size, None)).unwrap();
662        }
663        assert_eq!(sparse.bars()[&start].value_area(0.6), Some((99, 100)));
664    }
665
666    /// Dropped executions leave the bar summed again from those held, extrema included, and
667    /// the budget drops whole old bars; both leave earlier bars incomplete.
668    #[test]
669    fn dropped_executions_match_the_held_ones_summed_again() {
670        let start: DateTime<Utc> = "2026-01-05T10:00:00Z".parse().unwrap();
671        let sells = [
672            Some(OrderSide::Sell),
673            Some(OrderSide::Buy),
674            Some(OrderSide::Buy),
675        ];
676        // Running delta of the whole bar: 0, -1, 0, 1, 0, 1, 2; without the first two trades, it
677        // starts at zero again: 0, 1, 0, 1, 2.
678        let trades: Vec<_> = (0..7)
679            .map(|i| {
680                Tick::new(
681                    start + TimeDelta::seconds(i),
682                    100.0 + i as f64,
683                    1.0,
684                    sells[i as usize % 3],
685                )
686            })
687            .collect();
688        let new = || Footprint::new(BarClock::new(Timeframe::Min1), 1.0, 1).unwrap();
689        let mut flow = new();
690        trades.iter().for_each(|t| _ = flow.push(t));
691        flow.drop_through(trades[1].ts, &trades[2..]);
692        let mut held = new();
693        trades[2..].iter().for_each(|t| _ = held.push(t));
694        assert_eq!(flow.bars(), held.bars());
695        assert_eq!(
696            (flow.bars()[&start].min_delta, flow.bars()[&start].max_delta),
697            (0.0, 2.0)
698        );
699        assert_eq!(flow.complete_from(), None);
700
701        let mut budget = new();
702        budget.set_budget(2, usize::MAX);
703        for minute in 0..3 {
704            let at = start + TimeDelta::minutes(minute);
705            budget.push(&Tick::new(at, 100.0, 1.0, None)).unwrap();
706        }
707        assert_eq!(budget.bars().len(), 2);
708        assert_eq!(budget.complete_from(), Some(start + TimeDelta::minutes(1)));
709    }
710}