Skip to main content

egui_charts/studies/builtin/
footprint_studies.rs

1//! Levels and zones derived only from executed volume.
2
3use crate::data::{Footprint, FootprintBar, meets_threshold};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7/// Meaning of a footprint annotation. Sells trade on the bid and buys on the ask.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum FootprintStudyKind {
10    /// Consecutive sell imbalances.
11    StackedBid,
12    /// Consecutive buy imbalances.
13    StackedAsk,
14    /// Sell volume traded at the bar's highest price, so the auction there did not finish.
15    UnfinishedHigh,
16    /// Buy volume traded at the bar's lowest price, so the auction there did not finish.
17    UnfinishedLow,
18    /// Highest-volume level, extended until a later bar trades through it.
19    NakedPoc,
20    /// Price with the largest absolute delta, ties resolved toward the lower price.
21    DeltaPoc,
22    /// Row volume exceeds the configured share of this bar's volume.
23    VolumeCluster,
24    /// Small positive executed volume at a bar extreme, below the host's size threshold.
25    SinglePrint,
26    /// The bar's positive delta reaches the configured share of its volume.
27    DeltaAsk,
28    /// The bar's negative delta reaches the configured share of its volume.
29    DeltaBid,
30    /// The bar's buy volume reaches the configured multiple of its sell volume.
31    RatioAsk,
32    /// The bar's sell volume reaches the configured multiple of its buy volume.
33    RatioBid,
34}
35
36/// Where a level or zone stands against later trading.
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum FootprintStudyState {
39    /// A marker that stays inside its own bar.
40    Local,
41    /// No later bar has traded in the zone, and it has not expired.
42    Unvisited,
43    /// A later bar traded in the zone, ending it.
44    Revisited,
45    /// No later bar traded in the zone within the configured number of bars, ending it.
46    Expired,
47}
48
49/// A level or zone beginning at one bar and optionally ending at its first revisit.
50#[derive(Clone, Debug, PartialEq)]
51pub struct FootprintStudy {
52    /// Bar that created the level or zone.
53    pub start: DateTime<Utc>,
54    /// Last bar of the level or zone: the first later bar whose traded range overlaps it, or the
55    /// last of the configured number of bars when none did; `None` while untouched and
56    /// unexpired. Local markers use the originating bar as their end.
57    pub end: Option<DateTime<Utc>>,
58    /// Inclusive lower grouped price key.
59    pub low: i64,
60    /// Inclusive upper grouped price key.
61    pub high: i64,
62    /// Interpretation of the annotation.
63    pub kind: FootprintStudyKind,
64    /// Whether later trading ended it, and how.
65    pub state: FootprintStudyState,
66}
67
68/// What [`footprint_studies`] marks, and how. The default marks stacks of three diagonal
69/// imbalances at 300%, unfinished highs and lows, naked and delta POCs, and leaves the rest off.
70#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
71#[serde(default)]
72pub struct FootprintStudyConfig {
73    /// Mark consecutive imbalance zones.
74    pub stacked_imbalances: bool,
75    /// Extend the highest-volume row to its first revisit.
76    pub naked_poc: bool,
77    /// Mark the row with the largest absolute delta.
78    pub delta_poc: bool,
79    /// Extend unfinished highs and lows to their first revisit.
80    pub unfinished_auctions: bool,
81    /// Dominance multiple of an imbalance, normally 3 (300%).
82    pub imbalance_ratio: f64,
83    /// Inclusive minimum dominant-side volume of each imbalance in a stack, in instrument
84    /// volume units after row grouping. Zero preserves all ratio signals; negative or
85    /// non-finite, none. The chart uses its footprint config's minimum for displayed zones.
86    pub imbalance_min_volume: f64,
87    /// At least this many (and one) adjacent imbalances make a zone.
88    pub stack: usize,
89    /// Build imbalances from a side against the opposite side one price away, as the footprint
90    /// shows them, rather than at the same price.
91    pub diagonal: bool,
92    /// Bars a level or zone extends past its own: it ends at its first revisit, or expires
93    /// after this many later bars. `None` extends until a revisit or the last bar.
94    pub extend_bars: Option<usize>,
95    /// Row volume above this share of the bar volume, in 0..=1, marks a cluster. Out of range,
96    /// not a number, or `None`, none.
97    pub cluster_share: Option<f64>,
98    /// Extreme rows whose positive total volume is no larger than this, in instrument volume
99    /// units, are single prints. This is an execution-size marker, independent of
100    /// single-period occupancy in time profiles. `None`, none.
101    pub single_print_max: Option<f64>,
102    /// Marks a bar whose absolute delta, as a percentage of its volume with unknown side
103    /// included, reaches this value, at equality too. A bar without volume or with zero delta
104    /// has none. Not a number or negative, or `None`, none.
105    pub delta_percent: Option<f64>,
106    /// Marks a bar whose buy volume reaches this multiple of its sell volume, or the reverse,
107    /// at equality too. A side with volume against none meets any multiple, two empty sides
108    /// none, and unknown side counts in neither. Below 1 or not a number, or `None`, none.
109    pub ratio: Option<f64>,
110    /// The delta POC needs at least this absolute net delta; below it the bar has none.
111    pub min_delta: f64,
112}
113
114impl Default for FootprintStudyConfig {
115    fn default() -> Self {
116        Self {
117            stacked_imbalances: true,
118            naked_poc: true,
119            delta_poc: true,
120            unfinished_auctions: true,
121            imbalance_ratio: 3.0,
122            imbalance_min_volume: 0.0,
123            stack: 3,
124            diagonal: true,
125            extend_bars: None,
126            cluster_share: None,
127            single_print_max: None,
128            delta_percent: None,
129            ratio: None,
130            min_delta: 0.0,
131        }
132    }
133}
134
135/// Finds stacked imbalances, unfinished highs and lows, naked POCs, delta POCs, clusters, single
136/// prints and bars whose delta percentage or bid/ask ratio reaches a threshold, in the bars from
137/// [`Footprint::complete_from`] on.
138///
139/// Missing price rows break a stack. Stacks, POCs and unfinished levels end at the first
140/// subsequent bar whose executed range revisits them, or expire as `config.extend_bars` says;
141/// their `state` tells which. Call after trades change, not every paint: extending old zones
142/// scans subsequent bars.
143pub fn footprint_studies(flow: &Footprint, config: &FootprintStudyConfig) -> Vec<FootprintStudy> {
144    let bars: Vec<_> = flow.complete_bars(..).map(|bar| (bar, 1)).collect();
145    footprint_studies_on_rows(&bars, config)
146}
147
148/// Each bar's row keys are in multiples of its display grouping; output keys use input rows.
149pub(crate) fn footprint_studies_on_rows(
150    bars: &[(&FootprintBar, i64)],
151    config: &FootprintStudyConfig,
152) -> Vec<FootprintStudy> {
153    let mut result = Vec::new();
154    for (index, &(bar, group)) in bars.iter().enumerate() {
155        let mut add = |low: i64, high: i64, kind, extended| {
156            let enabled = match kind {
157                FootprintStudyKind::StackedBid | FootprintStudyKind::StackedAsk => {
158                    config.stacked_imbalances
159                }
160                FootprintStudyKind::NakedPoc => config.naked_poc,
161                FootprintStudyKind::DeltaPoc => config.delta_poc,
162                FootprintStudyKind::UnfinishedHigh | FootprintStudyKind::UnfinishedLow => {
163                    config.unfinished_auctions
164                }
165                _ => true,
166            };
167            if !enabled {
168                return;
169            }
170            let low = low.saturating_mul(group);
171            let high = high.saturating_mul(group).saturating_add(group - 1);
172            let (end, state) = if extended {
173                let later = bars[index + 1..]
174                    .iter()
175                    .take(config.extend_bars.unwrap_or(usize::MAX));
176                let revisit = later.clone().find(|(next, next_group)| {
177                    next.levels
178                        .first_key_value()
179                        .zip(next.levels.last_key_value())
180                        .is_some_and(|((first, _), (last, _))| {
181                            first.saturating_mul(*next_group) <= high
182                                && last
183                                    .saturating_mul(*next_group)
184                                    .saturating_add(*next_group - 1)
185                                    >= low
186                        })
187                });
188                match (revisit, config.extend_bars) {
189                    (Some((next, _)), _) => (Some(next.time), FootprintStudyState::Revisited),
190                    (None, Some(n)) if later.count() == n => (
191                        bars.get(index + n).map(|(last, _)| last.time),
192                        FootprintStudyState::Expired,
193                    ),
194                    _ => (None, FootprintStudyState::Unvisited),
195                }
196            } else {
197                (Some(bar.time), FootprintStudyState::Local)
198            };
199            result.push(FootprintStudy {
200                start: bar.time,
201                end,
202                low,
203                high,
204                kind,
205                state,
206            });
207        };
208        let (Some((&first, first_row)), Some((&last, last_row))) =
209            (bar.levels.first_key_value(), bar.levels.last_key_value())
210        else {
211            continue;
212        };
213        if let Some(poc) = bar.poc() {
214            add(poc, poc, FootprintStudyKind::NakedPoc, true);
215        }
216        if let Some(row) = bar
217            .levels
218            .values()
219            .max_by(|a, b| {
220                a.delta()
221                    .abs()
222                    .total_cmp(&b.delta().abs())
223                    .then_with(|| b.level.cmp(&a.level))
224            })
225            .filter(|row| row.delta().abs() >= config.min_delta)
226        {
227            add(row.level, row.level, FootprintStudyKind::DeltaPoc, false);
228        }
229        if last_row.bid > 0.0 {
230            add(last, last, FootprintStudyKind::UnfinishedHigh, true);
231        }
232        if first_row.ask > 0.0 {
233            add(first, first, FootprintStudyKind::UnfinishedLow, true);
234        }
235        if let Some(max) = config.single_print_max.filter(|max| max.is_finite()) {
236            for (i, (key, row)) in [(first, first_row), (last, last_row)]
237                .into_iter()
238                .enumerate()
239            {
240                if (i == 0 || first != last) && row.volume() > 0.0 && row.volume() <= max {
241                    add(key, key, FootprintStudyKind::SinglePrint, false);
242                }
243            }
244        }
245        if let Some(share) = config
246            .cluster_share
247            .filter(|share| (0.0..=1.0).contains(share))
248            && bar.volume() > 0.0
249        {
250            for row in bar.levels.values() {
251                if row.volume() / bar.volume() > share {
252                    add(
253                        row.level,
254                        row.level,
255                        FootprintStudyKind::VolumeCluster,
256                        false,
257                    );
258                }
259            }
260        }
261        if let Some((percent, threshold)) = bar
262            .delta_percent()
263            .zip(config.delta_percent.filter(|t| *t >= 0.0))
264            && percent != 0.0
265            && meets_threshold(percent.abs(), threshold)
266        {
267            let kind = if percent > 0.0 {
268                FootprintStudyKind::DeltaAsk
269            } else {
270                FootprintStudyKind::DeltaBid
271            };
272            add(first, last, kind, false);
273        }
274        if let Some(ratio) = config.ratio.filter(|r| *r >= 1.0) {
275            let dominant = |a: f64, b: f64| a > b && meets_threshold(a, b * ratio);
276            if dominant(bar.ask, bar.bid) {
277                add(first, last, FootprintStudyKind::RatioAsk, false);
278            }
279            if dominant(bar.bid, bar.ask) {
280                add(first, last, FootprintStudyKind::RatioBid, false);
281            }
282        }
283        for (side, kind) in [
284            (0, FootprintStudyKind::StackedBid),
285            (1, FootprintStudyKind::StackedAsk),
286        ] {
287            let mut run: Option<(i64, i64, usize)> = None;
288            for row in bar.levels.values() {
289                let imbalance = bar.imbalance(
290                    row.level,
291                    config.imbalance_ratio,
292                    config.diagonal,
293                    config.imbalance_min_volume,
294                );
295                let active = if side == 0 { imbalance.0 } else { imbalance.1 };
296                if let Some((low, high, count)) = run
297                    && (!active || high.checked_add(1) != Some(row.level))
298                {
299                    if count >= config.stack.max(1) {
300                        add(low, high, kind, true);
301                    }
302                    run = None;
303                }
304                if active {
305                    run = Some(match run {
306                        Some((low, _, count)) => (low, row.level, count + 1),
307                        None => (row.level, row.level, 1),
308                    });
309                }
310            }
311            if let Some((low, high, count)) = run
312                && count >= config.stack.max(1)
313            {
314                add(low, high, kind, true);
315            }
316        }
317    }
318    result
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::model::{BarClock, OrderSide, Tick, Timeframe};
325    use chrono::TimeDelta;
326
327    #[test]
328    fn adjacent_diagonals_extend_only_until_a_later_revisit() {
329        let start: DateTime<Utc> = "2026-01-05T10:00:00Z".parse().unwrap();
330        let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 1.0, 1).unwrap();
331        for (second, price, size, side) in [
332            (0, 100.0, 1.0, OrderSide::Sell),
333            (1, 101.0, 1.0, OrderSide::Sell),
334            (2, 101.0, 4.0, OrderSide::Buy),
335            (3, 102.0, 1.0, OrderSide::Sell),
336            (4, 102.0, 4.0, OrderSide::Buy),
337            (5, 103.0, 4.0, OrderSide::Buy),
338            (60, 110.0, 1.0, OrderSide::Buy),
339            (120, 102.0, 1.0, OrderSide::Sell),
340        ] {
341            flow.push(&Tick::new(
342                start + TimeDelta::seconds(second),
343                price,
344                size,
345                Some(side),
346            ))
347            .unwrap();
348        }
349        // Exactly one third is not above the requested share.
350        let config = |cluster_share| FootprintStudyConfig {
351            cluster_share: Some(cluster_share),
352            single_print_max: Some(1.0),
353            ..Default::default()
354        };
355        assert!(
356            !footprint_studies(&flow, &config(1.0 / 3.0))
357                .iter()
358                .any(|s| s.start == start && s.kind == FootprintStudyKind::VolumeCluster)
359        );
360        let studies = footprint_studies(&flow, &config(0.3));
361        let first: Vec<_> = studies.iter().filter(|s| s.start == start).collect();
362        let single = first
363            .iter()
364            .find(|s| s.kind == FootprintStudyKind::SinglePrint)
365            .unwrap();
366        assert_eq!(
367            (single.low, single.high, single.end),
368            (100, 100, Some(start))
369        );
370        let stack = first
371            .iter()
372            .find(|s| s.kind == FootprintStudyKind::StackedAsk)
373            .unwrap();
374        assert_eq!(
375            (stack.low, stack.high, stack.end),
376            (101, 103, Some(start + TimeDelta::minutes(2)))
377        );
378        let poc = first
379            .iter()
380            .find(|s| s.kind == FootprintStudyKind::NakedPoc)
381            .unwrap();
382        assert_eq!((poc.low, poc.end), (101, None)); // Tie at 101/102 takes 101; 102 does not touch it.
383        assert_eq!(poc.state, FootprintStudyState::Unvisited);
384        assert_eq!(stack.state, FootprintStudyState::Revisited);
385        assert!(!first.iter().any(|s| matches!(
386            s.kind,
387            FootprintStudyKind::UnfinishedHigh | FootprintStudyKind::UnfinishedLow
388        )));
389        assert_eq!(
390            first
391                .iter()
392                .filter(|s| s.kind == FootprintStudyKind::VolumeCluster)
393                .count(),
394            2
395        ); // 5/15 at 101 and 102.
396    }
397
398    /// A footprint of the given executions, `(second, price, size, side)`, in 1-minute bars of
399    /// 1.0 ticks, the first bar opening at 10:00. A bar is told apart by its second.
400    fn flow(trades: &[(i64, f64, f64, Option<OrderSide>)]) -> Footprint {
401        let start: DateTime<Utc> = "2026-01-05T10:00:00Z".parse().unwrap();
402        let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 1.0, 1).unwrap();
403        for &(second, price, size, side) in trades {
404            flow.push(&Tick::new(
405                start + TimeDelta::seconds(second),
406                price,
407                size,
408                side,
409            ))
410            .unwrap();
411        }
412        flow
413    }
414
415    /// The studies of one kind as `(low, high, minutes to the end bar, state)`.
416    fn found(
417        flow: &Footprint,
418        config: &FootprintStudyConfig,
419        kind: FootprintStudyKind,
420    ) -> Vec<(i64, i64, Option<i64>, FootprintStudyState)> {
421        let start: DateTime<Utc> = "2026-01-05T10:00:00Z".parse().unwrap();
422        footprint_studies(flow, config)
423            .iter()
424            .filter(|s| s.kind == kind && s.start == start)
425            .map(|s| {
426                let end = s.end.map(|end| (end - start).num_minutes());
427                (s.low, s.high, end, s.state)
428            })
429            .collect()
430    }
431
432    /// Guards the level rules against hand-worked bars: the thresholds include equality and count
433    /// unknown volume only in the delta percentage's denominator, a side against an empty one
434    /// meets any ratio, the delta POC needs its minimum and breaks ties toward the lower price,
435    /// an unfinished high needs sell volume at the high and a low buy volume at the low even with
436    /// the other side empty, unfinished levels end at their revisit or expiry with the state
437    /// saying which, and horizontal and diagonal imbalances build different zones.
438    #[test]
439    fn thresholds_unfinished_levels_and_zones_follow_their_rules() {
440        use FootprintStudyKind::*;
441        use FootprintStudyState::*;
442        let (buy, sell) = (Some(OrderSide::Buy), Some(OrderSide::Sell));
443        let marked = |trades: &[_], config: &FootprintStudyConfig, kind| {
444            !found(&flow(trades), config, kind).is_empty()
445        };
446        // Delta percentage: 3 buys and 1 sell make 50%; 4 unknown more make it 25%.
447        let half = [(0, 100.0, 3.0, buy), (1, 100.0, 1.0, sell)];
448        let diluted = [
449            (0, 100.0, 3.0, buy),
450            (1, 100.0, 1.0, sell),
451            (2, 100.0, 4.0, None),
452        ];
453        let flat = [(0, 100.0, 2.0, buy), (1, 100.0, 2.0, sell)];
454        let delta = |t: f64| FootprintStudyConfig {
455            delta_percent: Some(t),
456            ..Default::default()
457        };
458        assert!(marked(&half, &delta(50.0), DeltaAsk));
459        assert!(!marked(&half, &delta(50.1), DeltaAsk));
460        assert!(marked(&diluted, &delta(25.0), DeltaAsk));
461        assert!(!marked(&diluted, &delta(26.0), DeltaAsk));
462        assert!(marked(
463            &[(0, 100.0, 3.0, sell), (1, 100.0, 1.0, buy)],
464            &delta(50.0),
465            DeltaBid
466        ));
467        assert!(!marked(&flat, &delta(0.0), DeltaAsk) && !marked(&flat, &delta(0.0), DeltaBid));
468        // Ratio: 6 buys against 2 sells is exactly 3.
469        let ratio = |r: f64| FootprintStudyConfig {
470            ratio: Some(r),
471            ..Default::default()
472        };
473        let six = [(0, 100.0, 6.0, buy), (1, 100.0, 2.0, sell)];
474        assert!(marked(&six, &ratio(3.0), RatioAsk));
475        assert!(!marked(&six, &ratio(3.5), RatioAsk));
476        assert!(marked(&[(0, 100.0, 5.0, buy)], &ratio(1000.0), RatioAsk));
477        assert!(marked(
478            &[(0, 100.0, 2.0, buy), (1, 100.0, 6.0, sell)],
479            &ratio(3.0),
480            RatioBid
481        ));
482        assert!(!marked(&[(0, 100.0, 5.0, None)], &ratio(1.0), RatioAsk));
483        assert!(!marked(&flat, &ratio(1.0), RatioAsk) && !marked(&flat, &ratio(1.0), RatioBid));
484        // 29 of 100 is 29%, 33 against 30 and 55 against 50 are 1.1, also with fractional sizes.
485        for scale in [1.0, 0.1] {
486            for (side, other, delta_kind, ratio_kind) in [
487                (buy, sell, DeltaAsk, RatioAsk),
488                (sell, buy, DeltaBid, RatioBid),
489            ] {
490                let pair =
491                    |a: f64, b: f64| [(0, 100.0, a * scale, side), (1, 100.0, b * scale, other)];
492                let imbalance = |a, b| {
493                    let level = flow(&pair(a, b));
494                    level
495                        .bars()
496                        .values()
497                        .next()
498                        .unwrap()
499                        .imbalance(100, 1.1, false, 0.0)
500                };
501                let share = [
502                    (0, 100.0, 29.0 * scale, side),
503                    (1, 100.0, 71.0 * scale, None),
504                ];
505                assert!(marked(&share, &delta(29.0), delta_kind), "scale {scale}");
506                assert_eq!(imbalance(33.0, 30.0), (side == sell, side == buy));
507                assert_eq!(imbalance(32.0, 30.0), (false, false));
508                assert!(marked(&pair(55.0, 50.0), &ratio(1.1), ratio_kind));
509                assert!(!marked(&pair(54.0, 50.0), &ratio(1.1), ratio_kind));
510            }
511        }
512        // Sizes summed trade by trade: 209 buys against 190 sells of 0.01 are 1.1, and 129 buys
513        // against 71 sells of 0.1 are a delta of 29%.
514        let lots = |buys: usize, sells: usize, size: f64| {
515            let mut trades = vec![(0, 100.0, size, buy); buys];
516            trades.extend(vec![(0, 100.0, size, sell); sells]);
517            trades
518        };
519        let accumulated = lots(209, 190, 0.01);
520        assert_eq!(
521            flow(&accumulated)
522                .bars()
523                .values()
524                .next()
525                .unwrap()
526                .imbalance(100, 1.1, false, 0.0),
527            (false, true)
528        );
529        assert!(marked(&accumulated, &ratio(1.1), RatioAsk));
530        for (minimum, expected) in [(2.09, true), (2.1, false), (-1.0, false)] {
531            let config = FootprintStudyConfig {
532                diagonal: false,
533                stack: 1,
534                imbalance_ratio: 1.1,
535                imbalance_min_volume: minimum,
536                ..Default::default()
537            };
538            assert_eq!(
539                marked(&accumulated, &config, StackedAsk),
540                expected,
541                "minimum {minimum}"
542            );
543        }
544        assert!(!marked(&lots(208, 190, 0.01), &ratio(1.1), RatioAsk));
545        assert!(marked(&lots(129, 71, 0.1), &delta(29.0), DeltaAsk));
546        // Delta POC: 100 nets +2 and 101 nets -3; at a tie of 3 the lower price wins.
547        let rows = [(0, 100.0, 2.0, buy), (1, 101.0, 3.0, sell)];
548        let tied = [(0, 100.0, 3.0, buy), (1, 101.0, 3.0, sell)];
549        let poc = |min_delta: f64| FootprintStudyConfig {
550            min_delta,
551            ..Default::default()
552        };
553        assert_eq!(
554            found(&flow(&rows), &poc(3.0), DeltaPoc),
555            [(101, 101, Some(0), Local)]
556        );
557        assert!(found(&flow(&rows), &poc(3.5), DeltaPoc).is_empty());
558        assert_eq!(
559            found(&flow(&tied), &poc(0.0), DeltaPoc),
560            [(100, 100, Some(0), Local)]
561        );
562        // Unfinished extremes: a one-sided extreme counts, an unknown-only one does not.
563        for (trades, high, low) in [
564            (vec![(0, 100.0, 1.0, buy), (1, 100.0, 1.0, sell)], 1, 1),
565            (vec![(0, 100.0, 1.0, buy)], 0, 1),
566            (vec![(0, 100.0, 1.0, sell), (1, 100.0, 1.0, None)], 1, 0),
567            (vec![(0, 100.0, 1.0, None)], 0, 0),
568            (vec![(0, 100.0, 1.0, sell), (1, 101.0, 1.0, buy)], 0, 0),
569        ] {
570            let flow = flow(&trades);
571            let config = FootprintStudyConfig::default();
572            assert_eq!(
573                found(&flow, &config, UnfinishedHigh).len(),
574                high,
575                "{trades:?}"
576            );
577            assert_eq!(
578                found(&flow, &config, UnfinishedLow).len(),
579                low,
580                "{trades:?}"
581            );
582        }
583        // Bar 0 trades 100 (buys) and 101 (both): an unfinished low at 100 and high at 101.
584        // Bars 1 and 2 stay at 103; bar 3 revisits 101 only.
585        let tape = [
586            (0, 100.0, 1.0, buy),
587            (1, 101.0, 1.0, sell),
588            (2, 101.0, 1.0, buy),
589            (60, 103.0, 1.0, buy),
590            (120, 103.0, 1.0, buy),
591            (180, 101.0, 1.0, buy),
592        ];
593        for (extend_bars, high, low) in [
594            (None, (Some(3), Revisited), (None, Unvisited)),
595            (Some(0), (Some(0), Expired), (Some(0), Expired)),
596            (Some(2), (Some(2), Expired), (Some(2), Expired)),
597            (Some(3), (Some(3), Revisited), (Some(3), Expired)),
598            (Some(5), (Some(3), Revisited), (None, Unvisited)),
599        ] {
600            let flow = flow(&tape);
601            let config = FootprintStudyConfig {
602                extend_bars,
603                ..Default::default()
604            };
605            assert_eq!(
606                found(&flow, &config, UnfinishedHigh),
607                [(101, 101, high.0, high.1)],
608                "high, {extend_bars:?}"
609            );
610            assert_eq!(
611                found(&flow, &config, UnfinishedLow),
612                [(100, 100, low.0, low.1)],
613                "low, {extend_bars:?}"
614            );
615        }
616        // Three rows each with 6 sells against 1 buy: a stack when compared at one price, but
617        // diagonally the top row has no row above it, so only two imbalances run.
618        let wall: Vec<_> = (0..3)
619            .flat_map(|i| {
620                let price = 100.0 + i as f64;
621                [(2 * i, price, 6.0, sell), (2 * i + 1, price, 1.0, buy)]
622            })
623            .collect();
624        let diagonal = |diagonal| FootprintStudyConfig {
625            diagonal,
626            ..Default::default()
627        };
628        assert_eq!(
629            found(&flow(&wall), &diagonal(false), StackedBid),
630            [(100, 102, None, Unvisited)]
631        );
632        assert!(found(&flow(&wall), &diagonal(true), StackedBid).is_empty());
633        // Dominant-side minimum: 9/1 passes the ratio but not a floor of 10; 12/4 passes both.
634        // Unknown volume and the opposite side cannot make up the minimum.
635        for (side, other, kind) in [(buy, sell, StackedAsk), (sell, buy, StackedBid)] {
636            for diagonal in [false, true] {
637                let opposite_price = if !diagonal {
638                    101.0
639                } else if side == buy {
640                    100.0
641                } else {
642                    102.0
643                };
644                for (dominant, opposite, minimum, expected) in [
645                    (9.0, 1.0, 0.0, true),
646                    (12.0, 4.0, 0.0, true),
647                    (9.0, 1.0, 10.0, false),
648                    (12.0, 4.0, 10.0, true),
649                    (10.0, 1.0, 10.0, true),
650                    (10.0, 4.0, 10.0, false),
651                ] {
652                    let trades = [
653                        (0, opposite_price, opposite, other),
654                        (0, opposite_price, 100.0, None),
655                        (1, 101.0, dominant, side),
656                        (2, 101.0, 100.0, None),
657                    ];
658                    let config = FootprintStudyConfig {
659                        diagonal,
660                        stack: 1,
661                        imbalance_min_volume: minimum,
662                        ..Default::default()
663                    };
664                    assert_eq!(
665                        marked(&trades, &config, kind),
666                        expected,
667                        "{side:?}, diagonal {diagonal}, {dominant}/{opposite}, minimum {minimum}"
668                    );
669                }
670            }
671        }
672        // Asks 12, 9, 15 against bids of 1 below form a stack at 101..103 until the middle
673        // row falls below the minimum.
674        let thin = [
675            (0, 100.0, 1.0, sell),
676            (1, 101.0, 1.0, sell),
677            (2, 102.0, 1.0, sell),
678            (3, 101.0, 12.0, buy),
679            (4, 102.0, 9.0, buy),
680            (5, 103.0, 15.0, buy),
681        ];
682        for (minimum, expected) in [(0.0, true), (10.0, false)] {
683            let config = FootprintStudyConfig {
684                imbalance_min_volume: minimum,
685                ..Default::default()
686            };
687            let zones = found(&flow(&thin), &config, StackedAsk);
688            assert_eq!(zones.len(), usize::from(expected), "minimum {minimum}");
689            if expected {
690                assert_eq!(zones, [(101, 103, None, Unvisited)]);
691            }
692        }
693    }
694}