Skip to main content

egui_charts/studies/builtin/
order_flow.rs

1//! Fixed aggregations of executions, quotes and synchronized depth, never estimates from bars.
2
3use super::anchor_period::AnchorPeriod;
4use super::trade_delta::CvdMarkers;
5use crate::data::{OrderFilter, OrderFlowInput, OrderFlowKind};
6use crate::model::{Bar, BarClock};
7use crate::studies::{
8    Indicator, IndicatorValues, InputKind, InputValue, PlotStyle, PlotType, StudyCandle, StudyInput,
9};
10use chrono::TimeDelta;
11use egui::Color32;
12
13const ANCHORS: [Option<AnchorPeriod>; 8] = [
14    None,
15    Some(AnchorPeriod::Session),
16    Some(AnchorPeriod::Week),
17    Some(AnchorPeriod::Month),
18    Some(AnchorPeriod::Quarter),
19    Some(AnchorPeriod::Year),
20    Some(AnchorPeriod::Decade),
21    Some(AnchorPeriod::Century),
22];
23
24/// An order-flow study in a separate pane. Use the factory titles or [`Self::new`].
25///
26/// Changing an aggregation input starts a fresh source: the host must replay historical events
27/// if it needs values before that change. Reset period, representation and marker settings reuse
28/// the held source. Book size is the last synchronized snapshot in the bar; spread
29/// averages every accepted quote equally. Bars without the relevant stream remain gaps.
30#[derive(Clone)]
31pub struct OrderFlowStudy {
32    kind: OrderFlowKind,
33    filter: OrderFilter,
34    input: OrderFlowInput,
35    clock: BarClock,
36    values: IndicatorValues,
37    reset_revision: u64,
38    colors: Vec<Color32>,
39    visible: bool,
40    /// Draw the rolling sums as a histogram rather than lines.
41    histogram: bool,
42    /// `Some(false)` is per-bar execution delta; `Some(true)` is cumulative execution delta.
43    delta: Option<bool>,
44    candle_style: bool,
45    candles: Vec<StudyCandle>,
46    anchor: Option<AnchorPeriod>,
47    volume_precision: Option<usize>,
48    markers: CvdMarkers,
49}
50
51impl OrderFlowStudy {
52    /// Constructs one of the fixed stream aggregations with an empty source.
53    pub fn new(kind: OrderFlowKind) -> Self {
54        let kind = kind.normalized();
55        Self {
56            kind,
57            filter: OrderFilter::default(),
58            input: OrderFlowInput::new(kind),
59            clock: BarClock::new(crate::model::Timeframe::Min1),
60            values: IndicatorValues::new(),
61            reset_revision: 0,
62            colors: vec![
63                Color32::from_rgb(38, 166, 154),
64                Color32::from_rgb(239, 83, 80),
65                Color32::from_rgb(120, 100, 220),
66            ],
67            visible: true,
68            histogram: true,
69            delta: None,
70            candle_style: false,
71            candles: Vec::new(),
72            anchor: None,
73            volume_precision: None,
74            markers: CvdMarkers::default(),
75        }
76    }
77
78    /// Exact execution delta in a separate pane, as candles by default. With `cumulative`,
79    /// candles carry the total until the selected anchor period changes (each exchange trading
80    /// day by default); otherwise every candle opens at zero. Inputs select an inclusive size
81    /// range and candles or a cumulative line / per-bar histogram. No volume is inferred from
82    /// price bars. The chart connects the same source as Size-Filtered CVD, including its
83    /// historical coverage, retention and replay cutoff.
84    ///
85    /// Cumulative mode offers `divergence` and `exhaustion`, both off by default. `swing_bars`
86    /// (3) requires strictly lower highs or higher lows on both sides of a price pivot.
87    /// `exhaustion_bars` (3) requires consecutive new extremes with shrinking same-side bar
88    /// delta. Labels sit at price extremes; a live bar never confirms a marker. When enabled,
89    /// outputs 3..=6 are bullish/bearish divergence and bullish/bearish exhaustion: 1 on the
90    /// closed confirmation bar, 0 on other closed bars. Use bar-close study alerts on these
91    /// outputs. Resets and missing executions between the swings prevent comparison; a
92    /// boundary after the second swing still permits its price confirmation. Invalid prices
93    /// break price windows and comparisons. Exhaustion follows per-bar delta across resets.
94    pub fn execution_delta(cumulative: bool) -> Self {
95        Self {
96            delta: Some(cumulative),
97            candle_style: true,
98            anchor: cumulative.then_some(AnchorPeriod::Session),
99            colors: vec![
100                crate::studies::palette::BLUE,
101                crate::studies::palette::UP,
102                crate::studies::palette::DOWN,
103            ],
104            ..Self::new(OrderFlowKind::FilteredCvd {
105                min: 0.0,
106                max: f64::MAX,
107            })
108        }
109    }
110
111    /// Raw per-bar rows of the input for `data[start..]`, `None` for a bar without any.
112    fn rows(&self, data: &[Bar], start: usize) -> Vec<Option<[f64; 3]>> {
113        let Some(first) = data.get(start) else {
114            return Vec::new();
115        };
116        let source = self.input.values_from(self.clock.bar_open(first.time));
117        data[start..]
118            .iter()
119            .map(|bar| source.get(&self.clock.bar_open(bar.time)).copied())
120            .collect()
121    }
122
123    /// Recomputes the outputs of `data[from..]`; the bars before `from` are unchanged.
124    /// A rolling sum reads the bars it spans from before `from` and a cumulative sum resumes
125    /// from the last value before it. A bar without input leaves a gap, and a rolling sum
126    /// over one is a gap, as is the warm-up.
127    fn refresh(&mut self, data: &[Bar], from: usize) {
128        let reset_revision = self.input.reset_revision();
129        let from = if reset_revision != self.reset_revision {
130            0
131        } else {
132            from.min(self.values.len()).min(data.len())
133        };
134        self.reset_revision = reset_revision;
135        self.candles
136            .truncate(self.candles.partition_point(|c| c.bars.start < from));
137        self.values.truncate(from);
138        self.values
139            .resize(data.len(), crate::studies::IndicatorValue::None);
140        match self.kind {
141            OrderFlowKind::FlowSum { bars } => {
142                let start = from.saturating_sub(bars - 1);
143                let rows = self.rows(data, start);
144                for at in from..data.len() {
145                    let Some(first) = (at + 1).checked_sub(bars) else {
146                        continue;
147                    };
148                    let window = &rows[first - start..=at - start];
149                    if window.iter().all(Option::is_some) {
150                        for line in 0..2 {
151                            let sum = window.iter().flatten().map(|row| row[line]).sum();
152                            self.values.set_value(line, at, sum);
153                        }
154                    }
155                }
156            }
157            OrderFlowKind::FilteredCvd { .. } => {
158                let sessions = self.clock.sessions();
159                let period =
160                    |bar: &Bar| self.anchor.map(|a| a.key(sessions.trading_date(bar.time)));
161                let mut last = self
162                    .values
163                    .line(0)
164                    .get(..from)
165                    .unwrap_or_default()
166                    .iter()
167                    .enumerate()
168                    .rev()
169                    .find(|(_, v)| v.is_finite())
170                    .map(|(i, v)| (period(&data[i]), *v));
171                for (at, row) in self.rows(data, from).into_iter().enumerate() {
172                    if let Some(row) = row {
173                        let at = from + at;
174                        let key = period(&data[at]);
175                        let open = last
176                            .filter(|(p, _)| self.delta != Some(false) && *p == key)
177                            .map_or(0.0, |(_, total)| total);
178                        let (high, low, close) = (open + row[1], open + row[2], open + row[0]);
179                        if ![open, high, low, close].iter().all(|v| v.is_finite()) {
180                            continue;
181                        }
182                        self.values.set_value(0, at, close);
183                        if self.delta.is_some() {
184                            self.values.set_value(1, at, f64::NAN);
185                            self.values.set_value(2, at, f64::NAN);
186                        }
187                        if self.delta.is_some() && self.candle_style {
188                            let plot = 1 + usize::from(row[0] < 0.0);
189                            self.values.set_value(plot, at, close);
190                            self.candles.push(StudyCandle {
191                                bars: at..at + 1,
192                                bar: Bar::new(data[at].time, open, high, low, close, 0.0),
193                                plot,
194                            });
195                        }
196                        last = Some((key, close));
197                    }
198                }
199            }
200            _ => {
201                for (at, row) in self.rows(data, from).into_iter().enumerate() {
202                    for (line, value) in row.iter().flatten().enumerate() {
203                        self.values.set_value(line, from + at, *value);
204                    }
205                }
206            }
207        }
208        if self.delta == Some(true) && self.markers.enabled() {
209            let start = self.markers.start(data.len(), from);
210            let rows = self.rows(data, start);
211            let sessions = self.clock.sessions();
212            let period = |at: usize| {
213                self.anchor
214                    .map(|a| a.key(sessions.trading_date(data[at].time)))
215            };
216            self.markers
217                .refresh(data, start, &rows, &mut self.values, |at| {
218                    at > 0 && period(at) != period(at - 1)
219                });
220        }
221    }
222
223    /// Book size over the individual orders `filter` accepts: a minimum size, a minimum and a
224    /// maximum resting age and excluded participants, all but the last also set by the study's
225    /// inputs. Needs a feed that sends order events, see [`OrderFlowInput::with_order_filter`].
226    /// Starts a fresh source.
227    pub fn with_order_filter(mut self, filter: OrderFilter) -> Self {
228        self.filter = filter;
229        self.reset();
230        self
231    }
232
233    /// A fresh source for the current kind and filter, with no values.
234    fn reset(&mut self) {
235        self.input = OrderFlowInput::new(self.kind).with_order_filter(self.filter.clone());
236        self.values = IndicatorValues::new();
237        self.candles.clear();
238        self.markers.labels.clear();
239        self.reset_revision = 0;
240    }
241}
242
243impl Indicator for OrderFlowStudy {
244    fn name(&self) -> &str {
245        if let Some(cumulative) = self.delta {
246            return if cumulative {
247                "Cumulative Execution Delta"
248            } else {
249                "Execution Delta"
250            };
251        }
252        match self.kind {
253            OrderFlowKind::Spread => "Quoted Spread",
254            OrderFlowKind::BookSize { .. } => "Book Size",
255            OrderFlowKind::TradeRate { .. } => "Trade Rate",
256            OrderFlowKind::AggressorRate { .. } => "Aggressor Rate",
257            OrderFlowKind::LevelsExecuted { .. } => "Levels Executed",
258            OrderFlowKind::SizeDelta { .. } => "Multi-Range Delta",
259            OrderFlowKind::FlowSum { .. } => "Rolling Volume and Delta",
260            OrderFlowKind::FilteredCvd { .. } => "Size-Filtered CVD",
261            OrderFlowKind::LevelsTradedThrough { .. } => "Levels Traded Through",
262        }
263    }
264    fn desc(&self) -> &str {
265        "Exact stream aggregation; missing market data leaves gaps"
266    }
267    fn order_flow_inputs(&self) -> &[OrderFlowInput] {
268        std::slice::from_ref(&self.input)
269    }
270    fn set_bar_clock(&mut self, clock: &BarClock) -> bool {
271        let changed = self.clock != *clock;
272        self.clock = clock.clone();
273        changed
274    }
275    fn set_instrument(&mut self, instrument: Option<&crate::model::Instrument>) -> bool {
276        self.volume_precision = instrument.and_then(|i| i.volume_decimals.map(usize::from));
277        false
278    }
279    fn calculate(&mut self, data: &[Bar]) {
280        self.refresh(data, 0);
281    }
282    fn is_incremental(&self) -> bool {
283        true
284    }
285    fn update(&mut self, data: &[Bar], from: usize) {
286        self.refresh(data, from);
287    }
288    fn values(&self) -> &IndicatorValues {
289        &self.values
290    }
291    fn candles(&self) -> &[StudyCandle] {
292        &self.candles
293    }
294    fn labels(&self) -> &[crate::studies::StudyLabel] {
295        &self.markers.labels
296    }
297    fn labels_on_price(&self) -> bool {
298        self.delta == Some(true)
299    }
300    fn value_format(&self) -> crate::scales::StudyFormat {
301        use crate::scales::StudyFormat;
302        match self.kind {
303            OrderFlowKind::Spread
304            | OrderFlowKind::TradeRate { .. }
305            | OrderFlowKind::AggressorRate { .. } => StudyFormat::Fixed(2),
306            OrderFlowKind::LevelsExecuted { .. } | OrderFlowKind::LevelsTradedThrough { .. } => {
307                StudyFormat::Fixed(0)
308            }
309            _ => StudyFormat::Volume,
310        }
311    }
312    fn precision(&self) -> Option<usize> {
313        if matches!(
314            self.kind,
315            OrderFlowKind::TradeRate { volume: false, .. } | OrderFlowKind::AggressorRate { .. }
316        ) {
317            return None;
318        }
319        if matches!(self.kind, OrderFlowKind::TradeRate { volume: true, .. }) {
320            return self.volume_precision.map(|decimals| decimals.max(2));
321        }
322        match self.value_format() {
323            crate::scales::StudyFormat::Volume => self.volume_precision,
324            crate::scales::StudyFormat::Fixed(decimals) => Some(decimals),
325            _ => None,
326        }
327    }
328    fn format_value(&self, value: f64) -> String {
329        let format = self.value_format();
330        let precision = self
331            .precision()
332            .or((format == crate::scales::StudyFormat::Volume && self.delta.is_none()).then_some(2))
333            .filter(|_| format != crate::scales::StudyFormat::Volume || value.abs() < 1_000.0);
334        format.format(value, precision)
335    }
336    fn format_plot_value(&self, line: usize, value: f64) -> String {
337        use crate::scales::StudyFormat;
338        if line == 0 {
339            match self.kind {
340                OrderFlowKind::TradeRate { volume: true, .. } => {
341                    return StudyFormat::Volume.format(
342                        value,
343                        (value.abs() < 1_000.0).then_some(self.volume_precision.unwrap_or(2)),
344                    );
345                }
346                OrderFlowKind::TradeRate { volume: false, .. }
347                | OrderFlowKind::AggressorRate { .. } => {
348                    return StudyFormat::Fixed(0).format(value, None);
349                }
350                _ => {}
351            }
352        }
353        self.format_value(value)
354    }
355    fn legend_visible(&self, line: usize) -> bool {
356        line < self.line_count() && (self.plot_style(line).visible || line == 0)
357    }
358    fn colors(&self) -> Vec<Color32> {
359        self.colors.clone()
360    }
361    fn set_colors(&mut self, colors: Vec<Color32>) {
362        for (old, new) in self.colors.iter_mut().zip(colors) {
363            *old = new;
364        }
365    }
366    fn is_overlay(&self) -> bool {
367        false
368    }
369    fn is_visible(&self) -> bool {
370        self.visible
371    }
372    fn set_visible(&mut self, visible: bool) {
373        self.visible = visible;
374    }
375    fn clone_box(&self) -> Box<dyn Indicator> {
376        Box::new(self.clone())
377    }
378    fn line_count(&self) -> usize {
379        if self.delta.is_some() {
380            return if self.markers.enabled() { 7 } else { 3 };
381        }
382        match self.kind {
383            OrderFlowKind::FlowSum { .. } => 2,
384            OrderFlowKind::FilteredCvd { .. } => 1,
385            _ => 3,
386        }
387    }
388    fn line_names(&self) -> Vec<String> {
389        if self.delta.is_some() {
390            let mut names = ["Delta", "Up", "Down"].map(String::from).to_vec();
391            if self.markers.enabled() {
392                names.extend(
393                    [
394                        "Bull divergence",
395                        "Bear divergence",
396                        "Bull exhaustion",
397                        "Bear exhaustion",
398                    ]
399                    .map(String::from),
400                );
401            }
402            return names;
403        }
404        let names: &[&str] = match self.kind {
405            OrderFlowKind::TradeRate {
406                volume,
407                seconds,
408                instant,
409            } => {
410                let unit = if volume { "lots" } else { "executions" };
411                return vec![
412                    format!("Bar {unit}"),
413                    format!("Rolling {seconds}s ({unit}/s)"),
414                    format!("Instant {instant}s ({unit}/s)"),
415                ];
416            }
417            OrderFlowKind::AggressorRate { seconds, instant } => {
418                return vec![
419                    "Bar orders".into(),
420                    format!("Rolling {seconds}s (orders/s)"),
421                    format!("Instant {instant}s (orders/s)"),
422                ];
423            }
424            OrderFlowKind::SizeDelta { ranges } => {
425                return ranges
426                    .iter()
427                    .map(|[min, max]| {
428                        if *max == f64::MAX {
429                            format!("Delta [{min}, ∞] lots")
430                        } else {
431                            format!("Delta [{min}, {max}] lots")
432                        }
433                    })
434                    .collect();
435            }
436            OrderFlowKind::FlowSum { .. } => &["Volume sum (lots)", "Delta sum (lots)"],
437            OrderFlowKind::FilteredCvd { .. } => &["CVD (lots)"],
438            OrderFlowKind::Spread => &["Last (ticks)", "Mean (ticks)", "Maximum (ticks)"],
439            OrderFlowKind::BookSize { .. } => {
440                &["Bid size (lots)", "Ask size (lots)", "Difference (lots)"]
441            }
442            OrderFlowKind::LevelsExecuted { .. } => &["Ask levels", "Bid levels", "Total levels"],
443            OrderFlowKind::LevelsTradedThrough { .. } => {
444                &["Bid levels", "Ask levels", "Total levels"]
445            }
446        };
447        names.iter().map(|name| (*name).to_owned()).collect()
448    }
449    fn plot_tagged(&self, line: usize) -> bool {
450        line < 3
451    }
452    fn plot_style(&self, line: usize) -> PlotStyle {
453        PlotStyle {
454            color: self.colors[line.min(2)],
455            plot_type: if self.delta == Some(false)
456                || (line == 2 && matches!(self.kind, OrderFlowKind::BookSize { .. }))
457                || (self.histogram && matches!(self.kind, OrderFlowKind::FlowSum { .. }))
458            {
459                PlotType::Histogram
460            } else {
461                PlotType::Line
462            },
463            width: 1.0,
464            pattern: crate::model::LineStyle::Solid,
465            visible: (self.delta != Some(true) || line < 3)
466                && !(line == 0
467                    && matches!(
468                        self.kind,
469                        OrderFlowKind::TradeRate { .. } | OrderFlowKind::AggressorRate { .. }
470                    )),
471        }
472    }
473    fn inputs(&self) -> Vec<StudyInput> {
474        let length = |id, label, value, min, max| {
475            StudyInput::new(
476                id,
477                label,
478                InputValue::Length(value),
479                InputKind::Length(min, max),
480            )
481        };
482        match self.kind {
483            OrderFlowKind::Spread => vec![],
484            OrderFlowKind::BookSize { levels, distance } => vec![
485                length("levels", "Nearest levels", levels, 1, 10000),
486                StudyInput::new(
487                    "distance",
488                    "Price distance (0 uses levels)",
489                    InputValue::Number(distance),
490                    InputKind::Number(0.0, f64::MAX),
491                ),
492                StudyInput::new(
493                    "min_size",
494                    "Minimum order size (needs order events)",
495                    InputValue::Number(self.filter.min_size),
496                    InputKind::Number(0.0, f64::MAX),
497                ),
498                StudyInput::new(
499                    "min_age",
500                    "Minimum resting seconds (0 for any, needs order events)",
501                    InputValue::Number(self.filter.min_age.map_or(0.0, |age| age.as_seconds_f64())),
502                    InputKind::Number(0.0, 86400.0),
503                ),
504                StudyInput::new(
505                    "max_age",
506                    "Maximum resting seconds (0 for any, needs order events)",
507                    InputValue::Number(self.filter.max_age.map_or(0.0, |age| age.as_seconds_f64())),
508                    InputKind::Number(0.0, 86400.0),
509                ),
510            ],
511            OrderFlowKind::TradeRate {
512                volume,
513                seconds,
514                instant,
515            } => vec![
516                StudyInput::new(
517                    "volume",
518                    "Count volume",
519                    InputValue::Toggle(volume),
520                    InputKind::Toggle,
521                ),
522                length("seconds", "Rolling seconds", seconds, 1, 86400),
523                length("instant", "Instant seconds", instant, 1, 86400),
524            ],
525            OrderFlowKind::SizeDelta { ranges } => {
526                let size = |id: &str, label: String, value| {
527                    StudyInput::new(
528                        id,
529                        label,
530                        InputValue::Number(value),
531                        InputKind::Number(0.0, f64::MAX),
532                    )
533                };
534                ranges
535                    .iter()
536                    .enumerate()
537                    .flat_map(|(i, [min, max])| {
538                        [
539                            size(
540                                &format!("min{i}"),
541                                format!("Range {} smallest size", i + 1),
542                                *min,
543                            ),
544                            size(
545                                &format!("max{i}"),
546                                format!("Range {} largest size", i + 1),
547                                *max,
548                            ),
549                        ]
550                    })
551                    .collect()
552            }
553            OrderFlowKind::FlowSum { bars } => vec![
554                length("bars", "Bars", bars, 1, 10_000),
555                StudyInput::new(
556                    "histogram",
557                    "Histogram",
558                    InputValue::Toggle(self.histogram),
559                    InputKind::Toggle,
560                ),
561            ],
562            OrderFlowKind::FilteredCvd { min, max } => {
563                let mut inputs = [("min", "Smallest size", min), ("max", "Largest size", max)]
564                    .map(|(id, label, value)| {
565                        StudyInput::new(
566                            id,
567                            label,
568                            InputValue::Number(value),
569                            InputKind::Number(0.0, f64::MAX),
570                        )
571                    })
572                    .to_vec();
573                if self.delta != Some(false) {
574                    inputs.push(
575                        StudyInput::new(
576                            "anchor",
577                            "Reset period",
578                            InputValue::Choice(
579                                ANCHORS.iter().position(|a| *a == self.anchor).unwrap_or(0),
580                            ),
581                            InputKind::Choice(
582                                ANCHORS
583                                    .iter()
584                                    .map(|a| {
585                                        a.map_or_else(
586                                            || "Continuous".to_owned(),
587                                            |a| format!("{a:?}"),
588                                        )
589                                    })
590                                    .collect(),
591                            ),
592                        )
593                        .with_default(InputValue::Choice(usize::from(self.delta == Some(true)))),
594                    );
595                }
596                if self.delta.is_some() {
597                    inputs.push(
598                        StudyInput::new(
599                            "representation",
600                            "Representation",
601                            InputValue::Choice(usize::from(!self.candle_style)),
602                            InputKind::Choice(vec![
603                                "Candles".to_owned(),
604                                if self.delta == Some(false) {
605                                    "Histogram"
606                                } else {
607                                    "Line"
608                                }
609                                .to_owned(),
610                            ]),
611                        )
612                        .with_default(InputValue::Choice(0)),
613                    );
614                }
615                if self.delta == Some(true) {
616                    inputs.extend([
617                        StudyInput::new(
618                            "divergence",
619                            "Divergence",
620                            InputValue::Toggle(self.markers.divergence),
621                            InputKind::Toggle,
622                        )
623                        .with_default(InputValue::Toggle(false)),
624                        length(
625                            "swing_bars",
626                            "Swing bars on each side",
627                            self.markers.swing_bars,
628                            1,
629                            10_000,
630                        )
631                        .with_default(InputValue::Length(3)),
632                        StudyInput::new(
633                            "exhaustion",
634                            "Exhaustion",
635                            InputValue::Toggle(self.markers.exhaustion),
636                            InputKind::Toggle,
637                        )
638                        .with_default(InputValue::Toggle(false)),
639                        length(
640                            "exhaustion_bars",
641                            "Exhaustion consecutive bars",
642                            self.markers.exhaustion_bars,
643                            2,
644                            10_000,
645                        )
646                        .with_default(InputValue::Length(3)),
647                    ]);
648                }
649                inputs
650            }
651            OrderFlowKind::AggressorRate { seconds, instant } => vec![
652                length("seconds", "Rolling seconds", seconds, 1, 86400),
653                length("instant", "Instant seconds", instant, 1, 86400),
654            ],
655            OrderFlowKind::LevelsExecuted { seconds, reversal } => vec![
656                length("seconds", "Window seconds (0 uses bar)", seconds, 0, 86400),
657                StudyInput::new(
658                    "reversal",
659                    "Restart on price reversal",
660                    InputValue::Toggle(reversal),
661                    InputKind::Toggle,
662                ),
663            ],
664            OrderFlowKind::LevelsTradedThrough { seconds } => vec![length(
665                "seconds",
666                "Window seconds (0 uses bar)",
667                seconds,
668                0,
669                86400,
670            )],
671        }
672    }
673    fn set_input(&mut self, id: &str, value: InputValue) -> Result<(), crate::studies::StudyError> {
674        if !self
675            .inputs()
676            .iter()
677            .any(|i| i.id == id && i.accepts(&value))
678        {
679            return Err(crate::studies::StudyError::rejected(&self.inputs(), id));
680        }
681        match (id, &value) {
682            ("divergence", InputValue::Toggle(on)) => self.markers.divergence = *on,
683            ("exhaustion", InputValue::Toggle(on)) => self.markers.exhaustion = *on,
684            ("swing_bars", InputValue::Length(n)) => self.markers.swing_bars = *n,
685            ("exhaustion_bars", InputValue::Length(n)) => self.markers.exhaustion_bars = *n,
686            _ => {}
687        }
688        if matches!(
689            id,
690            "divergence" | "exhaustion" | "swing_bars" | "exhaustion_bars"
691        ) {
692            self.values = IndicatorValues::new();
693            self.candles.clear();
694            self.markers.labels.clear();
695            return Ok(());
696        }
697        let before = self.kind;
698        if let InputValue::Choice(at) = &value {
699            match id {
700                "anchor" => self.anchor = ANCHORS[*at],
701                "representation" => self.candle_style = *at == 0,
702                _ => return Err(crate::studies::StudyError::invalid_input(id)),
703            }
704            self.values = IndicatorValues::new();
705            self.candles.clear();
706            self.markers.labels.clear();
707            return Ok(());
708        }
709        if let ("histogram", InputValue::Toggle(on)) = (id, &value) {
710            self.histogram = *on;
711            return Ok(());
712        }
713        match (&mut self.kind, id, value) {
714            (OrderFlowKind::SizeDelta { ranges }, id, InputValue::Number(v)) => {
715                let index = id[3..]
716                    .parse::<usize>()
717                    .map_err(|_| crate::studies::StudyError::invalid_input(id))?;
718                ranges[index][usize::from(id.starts_with("max"))] = v;
719            }
720            (OrderFlowKind::FlowSum { bars }, "bars", InputValue::Length(v)) => *bars = v,
721            (OrderFlowKind::FilteredCvd { min, .. }, "min", InputValue::Number(v)) => *min = v,
722            (OrderFlowKind::FilteredCvd { max, .. }, "max", InputValue::Number(v)) => *max = v,
723            (OrderFlowKind::BookSize { levels, .. }, "levels", InputValue::Length(v)) => {
724                *levels = v
725            }
726            (OrderFlowKind::BookSize { distance, .. }, "distance", InputValue::Number(v)) => {
727                *distance = v
728            }
729            (OrderFlowKind::BookSize { .. }, "min_size", InputValue::Number(v)) => {
730                self.filter.min_size = v;
731                self.reset();
732            }
733            (OrderFlowKind::BookSize { .. }, "min_age", InputValue::Number(v)) => {
734                self.filter.min_age = (v > 0.0).then(|| TimeDelta::milliseconds((v * 1e3) as i64));
735                self.reset();
736            }
737            (OrderFlowKind::BookSize { .. }, "max_age", InputValue::Number(v)) => {
738                self.filter.max_age = (v > 0.0).then(|| TimeDelta::milliseconds((v * 1e3) as i64));
739                self.reset();
740            }
741            (OrderFlowKind::TradeRate { volume, .. }, "volume", InputValue::Toggle(v)) => {
742                *volume = v
743            }
744            (
745                OrderFlowKind::TradeRate { seconds, .. }
746                | OrderFlowKind::AggressorRate { seconds, .. }
747                | OrderFlowKind::LevelsExecuted { seconds, .. }
748                | OrderFlowKind::LevelsTradedThrough { seconds },
749                "seconds",
750                InputValue::Length(v),
751            ) => *seconds = v,
752            (
753                OrderFlowKind::TradeRate { instant, .. }
754                | OrderFlowKind::AggressorRate { instant, .. },
755                "instant",
756                InputValue::Length(v),
757            ) => *instant = v,
758            (OrderFlowKind::LevelsExecuted { reversal, .. }, "reversal", InputValue::Toggle(v)) => {
759                *reversal = v
760            }
761            _ => return Err(crate::studies::StudyError::invalid_input(id)),
762        }
763        if self.kind != before {
764            self.reset();
765        }
766        Ok(())
767    }
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773    use crate::model::{OrderSide, Tick};
774    use chrono::{DateTime, TimeDelta, Utc};
775
776    const NAN: f64 = f64::NAN;
777
778    fn start() -> DateTime<Utc> {
779        "2026-01-05T10:00:00Z".parse().unwrap()
780    }
781
782    fn bars() -> Vec<Bar> {
783        (0..6)
784            .map(|i| Bar::new(start() + TimeDelta::minutes(i), 1.0, 1.0, 1.0, 1.0, 0.0))
785            .collect()
786    }
787
788    fn push(study: &OrderFlowStudy, minute: i64, trades: &[(f64, Option<OrderSide>)]) {
789        let input = &study.order_flow_inputs()[0];
790        for (second, &(size, side)) in trades.iter().enumerate() {
791            let at = start() + TimeDelta::minutes(minute) + TimeDelta::seconds(second as i64);
792            input.push_trade(&study.clock, &Tick::new(at, 100.0, size, side));
793        }
794    }
795
796    /// The same trades per bar (minute 2 has none): 7 and +3, 20 and +20, then 153 and -147,
797    /// 104 and +100 with 4 of unknown side, and 12 and -12.
798    fn fill(study: &OrderFlowStudy) {
799        use OrderSide::{Buy, Sell};
800        push(study, 0, &[(5.0, Some(Buy)), (2.0, Some(Sell))]);
801        push(study, 1, &[(20.0, Some(Buy))]);
802        push(study, 3, &[(150.0, Some(Sell)), (3.0, Some(Buy))]);
803        push(study, 4, &[(100.0, Some(Buy)), (4.0, None)]);
804        push(study, 5, &[(12.0, Some(Sell))]);
805    }
806
807    fn lines(study: &OrderFlowStudy) -> Vec<Vec<f64>> {
808        study.values().columns().to_vec()
809    }
810
811    /// Counts are integral; quantities use the instrument's lot precision, while rates and
812    /// mean tick spreads retain fractions. The configured chart presentation preserves this.
813    #[test]
814    fn count_and_quantity_formats_follow_their_units() {
815        use crate::model::Instrument;
816        use crate::studies::ConfiguredStudy;
817        let unconfigured = OrderFlowStudy::new(OrderFlowKind::FilteredCvd {
818            min: 0.0,
819            max: f64::MAX,
820        });
821        assert_eq!(unconfigured.format_value(18.125), "18.12");
822        for (kind, value, whole, fractional) in [
823            (
824                OrderFlowKind::LevelsExecuted {
825                    seconds: 0,
826                    reversal: false,
827                },
828                18.0,
829                "18",
830                "18",
831            ),
832            (
833                OrderFlowKind::LevelsTradedThrough { seconds: 60 },
834                18.0,
835                "18",
836                "18",
837            ),
838            (
839                OrderFlowKind::FilteredCvd {
840                    min: 0.0,
841                    max: f64::MAX,
842                },
843                18.125,
844                "18",
845                "18.125",
846            ),
847            (
848                OrderFlowKind::SizeDelta {
849                    ranges: [[0.0, 10.0]; 3],
850                },
851                18.125,
852                "18",
853                "18.125",
854            ),
855            (
856                OrderFlowKind::BookSize {
857                    levels: 10,
858                    distance: 0.0,
859                },
860                18.125,
861                "18",
862                "18.125",
863            ),
864            (OrderFlowKind::FlowSum { bars: 2 }, 18.125, "18", "18.125"),
865            (OrderFlowKind::FlowSum { bars: 2 }, 4554.0, "4.55K", "4.55K"),
866            (OrderFlowKind::Spread, 1.25, "1.25", "1.25"),
867            (
868                OrderFlowKind::TradeRate {
869                    volume: false,
870                    seconds: 60,
871                    instant: 5,
872                },
873                1.25,
874                "1.25",
875                "1.25",
876            ),
877            (
878                OrderFlowKind::AggressorRate {
879                    seconds: 60,
880                    instant: 5,
881                },
882                1.25,
883                "1.25",
884                "1.25",
885            ),
886            (
887                OrderFlowKind::TradeRate {
888                    volume: true,
889                    seconds: 60,
890                    instant: 5,
891                },
892                1.125,
893                "1.12",
894                "1.125",
895            ),
896        ] {
897            let mut study = ConfiguredStudy::new(Box::new(OrderFlowStudy::new(kind)));
898            for (decimals, want) in [(0, whole), (3, fractional)] {
899                study.set_instrument(Some(
900                    &Instrument::new("SAMPLE").with_volume_decimals(decimals),
901                ));
902                assert_eq!(study.format_value(value), want, "{kind:?}, {decimals}");
903            }
904            if matches!(kind, OrderFlowKind::TradeRate { volume: false, .. }) {
905                study.set_input("volume", InputValue::Toggle(true)).unwrap();
906                assert_eq!(study.format_value(1.125), "1.125");
907            }
908        }
909    }
910
911    fn assert_lines(study: &OrderFlowStudy, want: &[[f64; 6]]) {
912        let got = lines(study);
913        assert_eq!(got.len(), want.len());
914        for (got, want) in got.iter().zip(want) {
915            for (got, want) in got.iter().zip(want) {
916                assert!(
917                    got == want || (got.is_nan() && want.is_nan()),
918                    "{got:?} against {want:?}"
919                );
920            }
921        }
922    }
923
924    /// Hand sums over the bars filled above. Size ranges include both bounds (the 100 buy
925    /// is in the last), a window over the empty bar is a gap as is the warm-up, and a
926    /// cumulative sum carries over the gap.
927    #[test]
928    fn size_ranges_rolling_sums_and_filtered_cvd_follow_the_same_trades() {
929        let mut data = bars();
930        let mut study = OrderFlowStudy::new(OrderFlowKind::SizeDelta {
931            ranges: [[0.0, 9.0], [10.0, 99.0], [100.0, f64::MAX]],
932        });
933        assert_eq!(
934            study.line_names(),
935            [
936                "Delta [0, 9] lots",
937                "Delta [10, 99] lots",
938                "Delta [100, ∞] lots"
939            ]
940        );
941        fill(&study);
942        study.calculate(&data);
943        assert_lines(
944            &study,
945            &[
946                [3.0, 0.0, NAN, 3.0, 0.0, 0.0],
947                [0.0, 20.0, NAN, 0.0, 0.0, -12.0],
948                [0.0, 0.0, NAN, -150.0, 100.0, 0.0],
949            ],
950        );
951
952        let mut sums = OrderFlowStudy::new(OrderFlowKind::FlowSum { bars: 2 });
953        fill(&sums);
954        sums.calculate(&data);
955        assert_lines(
956            &sums,
957            &[
958                [NAN, 27.0, NAN, NAN, 257.0, 116.0],
959                [NAN, 23.0, NAN, NAN, -47.0, 88.0],
960            ],
961        );
962
963        let mut cvd = OrderFlowStudy::new(OrderFlowKind::FilteredCvd {
964            min: 100.0,
965            max: f64::MAX,
966        });
967        fill(&cvd);
968        cvd.calculate(&data);
969        assert_lines(&cvd, &[[0.0, 0.0, NAN, -150.0, -50.0, -50.0]]);
970
971        // A trade in the last bar changes only its sums, whether recalculated from the start
972        // or from that bar.
973        for study in [&mut sums, &mut cvd] {
974            push(study, 5, &[(5.0, Some(OrderSide::Sell))]);
975            push(study, 6, &[(7.0, Some(OrderSide::Buy))]);
976            let before = lines(study);
977            data.push(Bar::new(
978                start() + TimeDelta::minutes(6),
979                1.0,
980                1.0,
981                1.0,
982                1.0,
983                0.0,
984            ));
985            let mut fresh = study.clone();
986            fresh.calculate(&data);
987            study.update(&data, 5);
988            assert_eq!(
989                format!("{:?}", lines(study)),
990                format!("{:?}", lines(&fresh))
991            );
992            for (old, new) in before.iter().zip(lines(study)) {
993                assert_eq!(format!("{:?}", &old[..5]), format!("{:?}", &new[..5]));
994            }
995            data.pop();
996        }
997    }
998
999    /// Guards the resting age inputs of book size: seconds set the filter's age bound that the
1000    /// study's source applies and reads back, and 0 removes the bound.
1001    #[test]
1002    fn resting_age_inputs_set_the_book_size_filter() {
1003        let ten = Some(TimeDelta::seconds(10));
1004        for (id, value, ages) in [
1005            ("min_age", 10.0, (ten, None)),
1006            ("max_age", 10.0, (None, ten)),
1007            ("max_age", 0.0, (None, None)),
1008        ] {
1009            let mut study = OrderFlowStudy::new(OrderFlowKind::BookSize {
1010                levels: 10,
1011                distance: 0.0,
1012            });
1013            study.set_input(id, InputValue::Number(value)).unwrap();
1014            let read = study
1015                .inputs()
1016                .into_iter()
1017                .find(|i| i.id == id)
1018                .map(|i| i.value);
1019            assert_eq!(read, Some(InputValue::Number(value)), "{id}");
1020            let filter = study
1021                .order_flow_inputs()
1022                .first()
1023                .and_then(|input| input.order_filter())
1024                .unwrap_or_default();
1025            assert_eq!((filter.min_age, filter.max_age), ages, "{id} {value}");
1026        }
1027    }
1028
1029    /// An overnight exchange session opens at 17:00 UTC. Midnight stays in that session;
1030    /// calendar anchors use its closing date, including 31 December's session for 1 January.
1031    #[test]
1032    fn filtered_cvd_resets_on_exchange_trading_dates_for_every_anchor() {
1033        use crate::model::{Session, SessionWindow, Timeframe, TimezoneId};
1034        use chrono::{NaiveTime, Weekday};
1035
1036        let times = [
1037            "2025-12-31T16:00:00Z",
1038            "2025-12-31T17:00:00Z",
1039            "2026-01-01T00:00:00Z",
1040            "2026-01-01T17:00:00Z",
1041            "2026-01-04T17:00:00Z",
1042            "2026-02-01T17:00:00Z",
1043            "2026-04-01T17:00:00Z",
1044        ];
1045        let data: Vec<_> = times
1046            .map(|t| Bar::new(t.parse().unwrap(), 100.0, 100.0, 100.0, 100.0, 0.0))
1047            .to_vec();
1048        let clock = BarClock::new(Timeframe::Min1).with_session(
1049            TimezoneId::utc(),
1050            Session::new([SessionWindow::new(
1051                [
1052                    Weekday::Mon,
1053                    Weekday::Tue,
1054                    Weekday::Wed,
1055                    Weekday::Thu,
1056                    Weekday::Fri,
1057                    Weekday::Sat,
1058                    Weekday::Sun,
1059                ],
1060                NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
1061                NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
1062            )]),
1063            false,
1064        );
1065        let mut study = OrderFlowStudy::new(OrderFlowKind::FilteredCvd {
1066            min: 2.0,
1067            max: 13.0,
1068        });
1069        study.set_bar_clock(&clock);
1070        let input = study.order_flow_inputs()[0].clone();
1071        // The first bar of the new session has no input. Unknown sides and size-one buys
1072        // do not add to delta; bounds 2 and 13 are inclusive.
1073        for (i, size) in [(0, 4.0), (2, 3.0), (3, 5.0), (4, 7.0), (5, 11.0), (6, 13.0)] {
1074            for (offset, size, side) in [
1075                (0, size, Some(OrderSide::Buy)),
1076                (1, 1.0, Some(OrderSide::Buy)),
1077                (2, 2.0, None),
1078            ] {
1079                input.push_trade(
1080                    &clock,
1081                    &Tick::new(data[i].time + TimeDelta::seconds(offset), 100.0, size, side),
1082                );
1083            }
1084        }
1085        for (anchor, want) in [
1086            (0, [4.0, NAN, 7.0, 12.0, 19.0, 30.0, 43.0]),
1087            (1, [4.0, NAN, 3.0, 5.0, 7.0, 11.0, 13.0]),
1088            (2, [4.0, NAN, 7.0, 12.0, 7.0, 11.0, 13.0]),
1089            (3, [4.0, NAN, 3.0, 8.0, 15.0, 11.0, 13.0]),
1090            (4, [4.0, NAN, 3.0, 8.0, 15.0, 26.0, 13.0]),
1091            (5, [4.0, NAN, 3.0, 8.0, 15.0, 26.0, 39.0]),
1092            (6, [4.0, NAN, 7.0, 12.0, 19.0, 30.0, 43.0]),
1093            (7, [4.0, NAN, 7.0, 12.0, 19.0, 30.0, 43.0]),
1094        ] {
1095            study
1096                .set_input("anchor", InputValue::Choice(anchor))
1097                .unwrap();
1098            study.calculate(&data[..2]);
1099            study.update(&data, 2);
1100            for (got, want) in study.values().line(0).iter().zip(want) {
1101                assert!(
1102                    got == &want || (got.is_nan() && want.is_nan()),
1103                    "anchor {anchor}: {got} against {want}"
1104                );
1105            }
1106            let mut full = study.clone();
1107            full.calculate(&data);
1108            assert_eq!(
1109                format!("{:?}", lines(&study)),
1110                format!("{:?}", lines(&full))
1111            );
1112        }
1113    }
1114
1115    /// Two symmetric three-bar pivots: highs 101/103 with CVD 20/12, or lows
1116    /// 99/97 with CVD -20/-12. Only the close of the third right bar confirms.
1117    /// Boundaries either separate the swings or follow the second before it confirms.
1118    #[test]
1119    fn cvd_divergence_confirms_closed_swings_without_crossing_resets_or_gaps() {
1120        for bullish in [false, true] {
1121            for boundary in [
1122                "none",
1123                "reset",
1124                "gap",
1125                "combined",
1126                "confirm_reset",
1127                "confirm_gap",
1128            ] {
1129                let mut study = OrderFlowStudy::execution_delta(true);
1130                assert!(study.labels().is_empty());
1131                study
1132                    .set_input("divergence", InputValue::Toggle(true))
1133                    .unwrap();
1134                let configured = crate::studies::ConfiguredStudy::new(Box::new(study.clone()));
1135                for line in 0..configured.line_count() {
1136                    assert_eq!(configured.plot_style(line).visible, line < 3);
1137                    assert_eq!(configured.legend_visible(line), line < 3);
1138                    assert_eq!(configured.plot_tagged(line), line < 3);
1139                }
1140                let highs = [
1141                    97., 98., 99., 101., 99., 98., 97., 96., 95., 96., 100., 101., 103., 101.,
1142                    100., 99., 98., 97.,
1143                ];
1144                let mut deltas: [f64; 18] = [
1145                    1., 1., 1., 17., -1., -1., -1., 1., -1., 1., -1., -1., -4., 1., 1., 1., 1., 1.,
1146                ];
1147                if boundary == "combined" {
1148                    study
1149                        .set_input("exhaustion", InputValue::Toggle(true))
1150                        .unwrap();
1151                    // -11 + 12 + 8 + 3 = 12 at the second high, with exhaustion there too.
1152                    deltas[9..13].copy_from_slice(&[-28., 12., 8., 3.]);
1153                }
1154                let data: Vec<_> = highs
1155                    .iter()
1156                    .enumerate()
1157                    .map(|(i, &high)| {
1158                        let time = start()
1159                            + TimeDelta::minutes(i as i64)
1160                            + if (boundary == "reset" && i >= 7)
1161                                || (boundary == "confirm_reset" && i >= 14)
1162                            {
1163                                TimeDelta::days(1)
1164                            } else {
1165                                TimeDelta::zero()
1166                            };
1167                        let (high, low) = if bullish {
1168                            (202. - high, 200. - high)
1169                        } else {
1170                            (high, high - 2.)
1171                        };
1172                        Bar::new(time, low, high, low, high, 1.)
1173                    })
1174                    .collect();
1175                let input = study.order_flow_inputs()[0].clone();
1176                for (i, (bar, &delta)) in data.iter().zip(&deltas).enumerate() {
1177                    if (boundary == "gap" && i == 7) || (boundary == "confirm_gap" && i == 14) {
1178                        continue;
1179                    }
1180                    let delta = if bullish { -delta } else { delta };
1181                    input.push_trade(
1182                        &study.clock,
1183                        &Tick::new(
1184                            bar.time,
1185                            100.,
1186                            delta.abs(),
1187                            Some(if delta > 0. {
1188                                OrderSide::Buy
1189                            } else {
1190                                OrderSide::Sell
1191                            }),
1192                        ),
1193                    );
1194                }
1195                study.calculate(&data[..16]);
1196                let diverges = matches!(
1197                    boundary,
1198                    "none" | "combined" | "confirm_reset" | "confirm_gap"
1199                );
1200                if diverges {
1201                    let sign = if bullish { -1.0 } else { 1.0 };
1202                    assert_eq!(
1203                        (study.values().value(0, 3), study.values().value(0, 12)),
1204                        (Some(sign * 20.0), Some(sign * 12.0))
1205                    );
1206                }
1207                assert!(
1208                    study
1209                        .labels()
1210                        .iter()
1211                        .all(|m| !m.text.contains("divergence")),
1212                    "the third right bar is still live"
1213                );
1214                study.update(&data[..17], 16);
1215                let expected: Vec<_> = if diverges {
1216                    vec![(12, if bullish { 97. } else { 103. })]
1217                } else {
1218                    vec![]
1219                };
1220                assert_eq!(
1221                    study
1222                        .labels()
1223                        .iter()
1224                        .map(|m| (m.bar, m.price))
1225                        .collect::<Vec<_>>(),
1226                    expected,
1227                    "{bullish} {boundary}"
1228                );
1229                let line = if bullish { 3 } else { 4 };
1230                assert_eq!(
1231                    study.values().value(line, 15),
1232                    Some(if diverges { 1. } else { 0. })
1233                );
1234                #[cfg(feature = "alert-engine")]
1235                {
1236                    use crate::model::{
1237                        Alert, AlertCondition, AlertFrequency, AlertSource, Timeframe,
1238                    };
1239                    use std::sync::{
1240                        Arc,
1241                        atomic::{AtomicUsize, Ordering},
1242                    };
1243                    let fired = Arc::new(AtomicUsize::new(0));
1244                    let count = fired.clone();
1245                    let mut alert = Alert::new("SYM", AlertCondition::CrossingUp(0.5));
1246                    alert.source = AlertSource::Study {
1247                        id: "cvd".into(),
1248                        output: line,
1249                    };
1250                    alert.frequency = AlertFrequency::OncePerBarClose;
1251                    alert.timeframe = Some(Timeframe::Min1);
1252                    let engine = crate::services::AlertEngine::new()
1253                        .with_alerts(vec![alert])
1254                        .on_trigger(move |_, _| {
1255                            count.fetch_add(1, Ordering::Relaxed);
1256                        });
1257                    for len in [16, 17, 17] {
1258                        engine.update(
1259                            "SYM",
1260                            Timeframe::Min1,
1261                            &data[..len],
1262                            data[len - 1].time,
1263                            |_, at| study.values().value(line, at),
1264                        );
1265                    }
1266                    assert_eq!(fired.load(Ordering::Relaxed), usize::from(diverges));
1267                }
1268                #[cfg(feature = "widget")]
1269                if matches!(boundary, "none" | "combined") {
1270                    use crate::model::{BarData, LogicalRange, Timeframe};
1271                    use crate::theme::Theme;
1272                    use crate::widget::Chart;
1273                    for theme in [Theme::light(), Theme::dark()] {
1274                        for size in [egui::vec2(1440., 900.), egui::vec2(360., 480.)] {
1275                            let mut shown = data.to_vec();
1276                            let live = shown.last_mut().unwrap();
1277                            (live.low, live.high) = if bullish { (96., 98.) } else { (102., 104.) };
1278                            live.open = live.low + 0.5;
1279                            live.close = live.high - 0.5;
1280                            let mut chart = Chart::new(BarData::from_bars(shown));
1281                            chart.set_timeframe(Timeframe::Min1);
1282                            chart.set_theme_override(Some(theme.clone()));
1283                            chart.add_indicator(Box::new(study.clone()));
1284                            let ctx = egui::Context::default();
1285                            crate::theme::apply_to_egui(&ctx, &theme);
1286                            let frame = |chart: &mut Chart| {
1287                                ctx.run_ui(
1288                                    egui::RawInput {
1289                                        screen_rect: Some(egui::Rect::from_min_size(
1290                                            egui::Pos2::ZERO,
1291                                            size,
1292                                        )),
1293                                        ..Default::default()
1294                                    },
1295                                    |ui| {
1296                                        chart.show(ui);
1297                                    },
1298                                )
1299                            };
1300                            frame(&mut chart).drop_without_applying_deltas();
1301                            chart.set_visible_logical_range(LogicalRange::new(-2., 20.));
1302                            let output = frame(&mut chart);
1303                            let text_rect = |text: &egui::epaint::TextShape| {
1304                                egui::Rect::from_min_size(text.pos, text.galley.size())
1305                            };
1306                            let marks: Vec<_> = output
1307                                .shapes
1308                                .iter()
1309                                .filter_map(|shape| match &shape.shape {
1310                                    egui::Shape::Text(text)
1311                                        if text.galley.text().contains("divergence") =>
1312                                    {
1313                                        Some(text_rect(text))
1314                                    }
1315                                    _ => None,
1316                                })
1317                                .collect();
1318                            assert_eq!(
1319                                marks.len(),
1320                                1,
1321                                "the configured study paints its marker once"
1322                            );
1323                            assert!(
1324                                chart.get_rendered_price_rect().contains_rect(marks[0]),
1325                                "the whole caption belongs to the price pane"
1326                            );
1327                            let title = output
1328                                .shapes
1329                                .iter()
1330                                .find_map(|shape| match &shape.shape {
1331                                    egui::Shape::Text(text) if text.galley.text() == "1min" => {
1332                                        Some(text_rect(text))
1333                                    }
1334                                    _ => None,
1335                                })
1336                                .unwrap();
1337                            let legend_row = output
1338                                .shapes
1339                                .iter()
1340                                .filter_map(|shape| match &shape.shape {
1341                                    egui::Shape::Rect(rect)
1342                                        if rect.fill == theme.chart.bg
1343                                            && rect.rect.contains_rect(title) =>
1344                                    {
1345                                        Some(rect.rect)
1346                                    }
1347                                    _ => None,
1348                                })
1349                                .min_by(|a, b| a.area().total_cmp(&b.area()))
1350                                .unwrap();
1351                            assert!(
1352                                !marks[0].intersects(legend_row),
1353                                "caption under the price legend: {size:?} {boundary}"
1354                            );
1355                            output.drop_without_applying_deltas();
1356                        }
1357                    }
1358                }
1359                if boundary == "combined" {
1360                    assert_eq!(
1361                        study.labels()[0].text,
1362                        if bullish {
1363                            "Bull exhaustion, Bull divergence"
1364                        } else {
1365                            "Bear exhaustion, Bear divergence"
1366                        }
1367                    );
1368                }
1369                let confirmed = study.labels().to_vec();
1370                study.update(&data, 17);
1371                assert_eq!(study.labels(), confirmed);
1372                let mut batch = study.clone();
1373                batch.calculate(&data);
1374                assert_eq!(study.labels(), batch.labels());
1375                assert_eq!(
1376                    format!("{:?}", lines(&study)),
1377                    format!("{:?}", lines(&batch))
1378                );
1379                // A correction or replay rewind discards confirmations beyond the new end.
1380                study.update(&data[..16], 15);
1381                assert!(
1382                    study
1383                        .labels()
1384                        .iter()
1385                        .all(|m| !m.text.contains("divergence"))
1386                );
1387            }
1388        }
1389    }
1390
1391    /// A bar before the run establishes the extreme: 12 > 8 > 3 fades through three
1392    /// new extremes; 12 > 8 < 9 does not. Negating deltas mirrors the rule at lows.
1393    #[test]
1394    fn cvd_exhaustion_requires_consecutive_closed_extremes_and_fading_signed_delta() {
1395        for bullish in [false, true] {
1396            for (last, boundary, expected) in [
1397                (3., "none", true),
1398                (9., "none", false),
1399                (3., "gap", false),
1400                (3., "before", true),
1401                (3., "reset", true),
1402                (0., "none", false),
1403                (-3., "none", false),
1404            ] {
1405                let mut study = OrderFlowStudy::execution_delta(true);
1406                study
1407                    .set_input("exhaustion", InputValue::Toggle(true))
1408                    .unwrap();
1409                let data: Vec<_> = (0..6)
1410                    .map(|i| {
1411                        let p = if bullish {
1412                            100. - i as f64
1413                        } else {
1414                            100. + i as f64
1415                        };
1416                        let time = start()
1417                            + TimeDelta::minutes(i)
1418                            + if boundary == "reset" && i >= 2 {
1419                                TimeDelta::days(1)
1420                            } else {
1421                                TimeDelta::zero()
1422                            };
1423                        Bar::new(time, p, p, p, p, 1.)
1424                    })
1425                    .collect();
1426                let input = study.order_flow_inputs()[0].clone();
1427                for (i, delta) in [20., 12., 8., last, 2., 1.].into_iter().enumerate() {
1428                    if (boundary == "gap" && i == 2) || (boundary == "before" && i == 0) {
1429                        continue;
1430                    }
1431                    let delta: f64 = if bullish { -delta } else { delta };
1432                    input.push_trade(
1433                        &study.clock,
1434                        &Tick::new(
1435                            data[i].time,
1436                            data[i].close,
1437                            delta.abs(),
1438                            Some(if delta > 0. {
1439                                OrderSide::Buy
1440                            } else {
1441                                OrderSide::Sell
1442                            }),
1443                        ),
1444                    );
1445                }
1446                study.calculate(&data[..1]);
1447                assert_eq!(study.values().columns().len(), study.line_count());
1448                for len in 2..=4 {
1449                    study.update(&data[..len], len - 1);
1450                    assert!(study.labels().is_empty());
1451                }
1452                study.update(&data[..5], 4);
1453                assert_eq!(
1454                    study.labels().len(),
1455                    usize::from(expected),
1456                    "{bullish} {last} {boundary}"
1457                );
1458                if expected {
1459                    assert_eq!(
1460                        (study.labels()[0].bar, study.labels()[0].price),
1461                        (3, if bullish { 97. } else { 103. })
1462                    );
1463                }
1464                assert_eq!(
1465                    study.values().value(if bullish { 5 } else { 6 }, 3),
1466                    Some(if expected { 1.0 } else { 0.0 })
1467                );
1468                let mut batch = study.clone();
1469                batch.calculate(&data[..5]);
1470                assert_eq!(study.labels(), batch.labels());
1471            }
1472        }
1473    }
1474
1475    /// Signed sizes give -1 then +24 on three-tick/range bars. A sell of eight
1476    /// splits 4/4 on volume bars; a buy of sixteen splits 12/4 on delta bars.
1477    #[test]
1478    fn trade_intervals_allocate_every_fill_to_its_chart_bar() {
1479        use OrderSide::{Buy, Sell};
1480        let six = [
1481            (1.0, Buy),
1482            (2.0, Buy),
1483            (4.0, Sell),
1484            (8.0, Buy),
1485            (16.0, Sell),
1486            (32.0, Buy),
1487        ];
1488        let mut results = Vec::new();
1489        let mut expected = Vec::new();
1490        for (interval, fills, same_time, want) in [
1491            ("3t", six.to_vec(), false, vec![-1.0, 23.0]),
1492            ("2r", six.to_vec(), false, vec![-1.0, 23.0]),
1493            (
1494                "10v",
1495                vec![(6.0, Buy), (8.0, Sell), (6.0, Buy)],
1496                false,
1497                vec![2.0, 4.0],
1498            ),
1499            (
1500                "10delta",
1501                vec![(6.0, Buy), (8.0, Sell), (16.0, Buy)],
1502                false,
1503                vec![10.0, 14.0],
1504            ),
1505            (
1506                "1t",
1507                six.to_vec(),
1508                true,
1509                vec![1.0, 3.0, -1.0, 7.0, -9.0, 23.0],
1510            ),
1511        ] {
1512            let clock = BarClock::new(interval.parse().unwrap());
1513            let ticks: Vec<_> = fills
1514                .into_iter()
1515                .enumerate()
1516                .map(|(i, (size, side))| {
1517                    let second = if same_time { 0 } else { i as i64 };
1518                    Tick::new(
1519                        start() + TimeDelta::seconds(second),
1520                        100.0 + i as f64,
1521                        size,
1522                        Some(side),
1523                    )
1524                })
1525                .collect();
1526            let data =
1527                crate::data::aggregate_trades_with_tick_size(&ticks, clock.clone(), Some(1.0))
1528                    .unwrap();
1529            let (size, filtered) = match interval {
1530                "10v" => (8.0, vec![-4.0, -8.0]),
1531                "10delta" => (16.0, vec![12.0, 16.0]),
1532                "1t" => (8.0, vec![0.0, 0.0, 0.0, 8.0, 8.0, 8.0]),
1533                _ => (8.0, vec![0.0, 8.0]),
1534            };
1535            for (min, max, want) in [(0.0, f64::MAX, want), (size, size, filtered)] {
1536                let mut study = OrderFlowStudy::new(OrderFlowKind::FilteredCvd { min, max });
1537                study.set_bar_clock(&clock);
1538                let input = study.order_flow_inputs()[0].clone();
1539                input.set_tick_size(Some(1.0));
1540                for tick in &ticks {
1541                    input.push_trade(&clock, tick);
1542                }
1543                study.calculate(&data);
1544                results.push((
1545                    interval,
1546                    study.values().line(0).to_vec(),
1547                    input.values().keys().copied().collect::<Vec<_>>(),
1548                    input
1549                        .values()
1550                        .last_key_value()
1551                        .map(|(_, row)| [row[1], row[2]]),
1552                ));
1553                let last_range = match (interval, min == 0.0) {
1554                    ("3t" | "2r", true) => [24.0, -8.0],
1555                    ("3t" | "2r", false) => [8.0, 0.0],
1556                    ("10v", true) => [2.0, -4.0],
1557                    ("10v", false) => [0.0, -4.0],
1558                    ("10delta", _) => [4.0, 0.0],
1559                    ("1t", true) => [32.0, 0.0],
1560                    ("1t", false) => [0.0, 0.0],
1561                    _ => unreachable!(),
1562                };
1563                expected.push((
1564                    interval,
1565                    want,
1566                    data.iter().map(|bar| bar.time).collect::<Vec<_>>(),
1567                    Some(last_range),
1568                ));
1569            }
1570        }
1571        assert_eq!(results, expected);
1572    }
1573
1574    /// At 11,001 bars retention keeps bars 1,001..=11,000: 10,000 buys of one.
1575    /// A ten-bar sum first has a complete window at bar 1,010.
1576    #[test]
1577    fn incremental_values_restart_after_input_retention() {
1578        let data: Vec<_> = (0..11_001)
1579            .map(|i| {
1580                Bar::new(
1581                    start() + TimeDelta::minutes(i),
1582                    100.0,
1583                    100.0,
1584                    100.0,
1585                    100.0,
1586                    1.0,
1587                )
1588            })
1589            .collect();
1590        let mut results = Vec::new();
1591        for kind in [
1592            OrderFlowKind::FilteredCvd {
1593                min: 0.0,
1594                max: f64::MAX,
1595            },
1596            OrderFlowKind::FlowSum { bars: 10 },
1597        ] {
1598            let mut study = OrderFlowStudy::new(kind);
1599            for i in 0..11_000 {
1600                push(&study, i, &[(1.0, Some(OrderSide::Buy))]);
1601            }
1602            study.calculate(&data[..11_000]);
1603            push(&study, 11_000, &[(1.0, Some(OrderSide::Buy))]);
1604            study.update(&data, 11_000);
1605            let mut fresh = study.clone();
1606            fresh.calculate(&data);
1607            let line = study.values().line(0);
1608            results.push((
1609                line[11_000],
1610                line[1_001..1_010].iter().all(|v| v.is_nan()),
1611                // All outputs, including dropped history and warm-up gaps.
1612                format!("{:?}", lines(&study)) == format!("{:?}", lines(&fresh)),
1613            ));
1614        }
1615        assert_eq!(results, [(10_000.0, false, true), (10.0, true, true)]);
1616    }
1617
1618    /// Executions +5, -8, +5 reach 5, -3, 2. A cumulative candle adds the previous
1619    /// total of four until the exchange trading day changes, when its open returns to zero.
1620    #[cfg(feature = "widget")]
1621    #[test]
1622    fn registered_execution_candles_follow_chart_updates_and_reset_with_the_line() {
1623        use crate::model::{BarData, Timeframe};
1624        use crate::studies::IndicatorFactory;
1625        use crate::widget::{Chart, SeriesId};
1626        use egui::{Context, RawInput, Rect, Shape, pos2, vec2};
1627
1628        let start: DateTime<Utc> = "2026-01-04T23:58:00Z".parse().unwrap();
1629        let data: Vec<_> = (0..5)
1630            .map(|i| {
1631                Bar::new(
1632                    start + TimeDelta::minutes(i),
1633                    100.0,
1634                    100.0,
1635                    100.0,
1636                    100.0,
1637                    999.0,
1638                )
1639            })
1640            .collect();
1641        let factory = IndicatorFactory::new();
1642        for (name, expected) in [
1643            (
1644                "Execution Delta",
1645                vec![
1646                    [0.0, 4.0, 0.0, 4.0],
1647                    [0.0, 5.0, -3.0, 2.0],
1648                    [0.0, 5.0, -3.0, 2.0],
1649                ],
1650            ),
1651            (
1652                "Cumulative Execution Delta",
1653                vec![
1654                    [0.0, 4.0, 0.0, 4.0],
1655                    [4.0, 9.0, 1.0, 6.0],
1656                    [0.0, 5.0, -3.0, 2.0],
1657                ],
1658            ),
1659        ] {
1660            assert_eq!(factory.category(name), Some("Order flow"), "{name}");
1661            let mut chart = Chart::new(BarData::default());
1662            chart.set_timeframe(Timeframe::Min1);
1663            chart.set_data(SeriesId::MAIN, data[..3].to_vec());
1664            let id = chart.add_indicator(factory.create(name).unwrap());
1665            let input = chart.indicators().next().unwrap().1.order_flow_inputs()[0].clone();
1666            let mut trades = crate::data::trades::Trades::new(100);
1667            for (second, size, side) in [
1668                (0, 4.0, OrderSide::Buy),
1669                (60, 5.0, OrderSide::Buy),
1670                (61, 8.0, OrderSide::Sell),
1671                (62, 5.0, OrderSide::Buy),
1672                (120, 5.0, OrderSide::Buy),
1673                (121, 8.0, OrderSide::Sell),
1674                (122, 5.0, OrderSide::Buy),
1675            ] {
1676                let trade = Tick::new(start + TimeDelta::seconds(second), 100.0, size, Some(side));
1677                input.push_trade(&chart.bar_clock(), &trade);
1678                trades.push(trade);
1679            }
1680            let buy = Color32::from_rgb(10, 200, 40);
1681            chart
1682                .indicator_mut(id)
1683                .unwrap()
1684                .set_colors(vec![Color32::BLUE, buy, Color32::RED]);
1685            let ctx = Context::default();
1686            let frame = |chart: &mut Chart| {
1687                ctx.run_ui(
1688                    RawInput {
1689                        screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1440.0, 900.0))),
1690                        ..Default::default()
1691                    },
1692                    |ui| {
1693                        chart.show(ui);
1694                    },
1695                )
1696            };
1697            frame(&mut chart).drop_without_applying_deltas();
1698            let output = frame(&mut chart);
1699            assert!(output.shapes.iter().any(|s| matches!(&s.shape, Shape::LineSegment { points, stroke } if stroke.color == buy && points[0].x == points[1].x && points[0].y != points[1].y)), "{name} must reach the runtime candle painter");
1700            output.drop_without_applying_deltas();
1701            let drawn = |chart: &Chart| {
1702                chart
1703                    .indicators()
1704                    .next()
1705                    .unwrap()
1706                    .1
1707                    .candles()
1708                    .iter()
1709                    .map(|c| [c.bar.open, c.bar.high, c.bar.low, c.bar.close])
1710                    .collect::<Vec<_>>()
1711            };
1712            assert_eq!(drawn(&chart), expected, "{name}");
1713            // A live buy of three follows an empty bar, retaining the cumulative total.
1714            input.push_trade(
1715                &chart.bar_clock(),
1716                &Tick::new(data[4].time, 100.0, 3.0, Some(OrderSide::Buy)),
1717            );
1718            chart.update(SeriesId::MAIN, data[3].clone());
1719            chart.update(SeriesId::MAIN, data[4].clone());
1720            frame(&mut chart).drop_without_applying_deltas();
1721            assert_eq!(
1722                drawn(&chart).last().unwrap()[3],
1723                if name == "Execution Delta" { 3.0 } else { 5.0 }
1724            );
1725            chart
1726                .indicator_mut(id)
1727                .unwrap()
1728                .set_input("representation", InputValue::Choice(1))
1729                .unwrap();
1730            frame(&mut chart).drop_without_applying_deltas();
1731            let study = chart.indicators().next().unwrap().1;
1732            assert!(study.candles().is_empty());
1733            assert!(study.plot_style(0).visible);
1734            assert_eq!(
1735                study
1736                    .inputs()
1737                    .iter()
1738                    .find(|input| input.id == "representation")
1739                    .unwrap()
1740                    .default,
1741                InputValue::Choice(0)
1742            );
1743            assert!(
1744                matches!(&study.values().to_rows()[0], crate::studies::IndicatorValue::Multiple(values) if values.len() == 3)
1745            );
1746            assert_eq!(
1747                study.plot_style(0).plot_type,
1748                if name == "Execution Delta" {
1749                    PlotType::Histogram
1750                } else {
1751                    PlotType::Line
1752                }
1753            );
1754            if name == "Cumulative Execution Delta" {
1755                assert_eq!(
1756                    (
1757                        study.values().value(0, 1),
1758                        study.values().value(0, 2),
1759                        study.values().value(0, 4)
1760                    ),
1761                    (Some(6.0), Some(2.0), Some(5.0))
1762                );
1763                // Continuous keeps the prior six at midnight, on the same source.
1764                chart
1765                    .indicator_mut(id)
1766                    .unwrap()
1767                    .set_input("anchor", InputValue::Choice(0))
1768                    .unwrap();
1769                frame(&mut chart).drop_without_applying_deltas();
1770                assert_eq!(
1771                    chart.indicators().next().unwrap().1.values().value(0, 4),
1772                    Some(11.0)
1773                );
1774            }
1775            // Replaying inside bar 1 includes its first buy and excludes its later sell and buy.
1776            input.set_replay(
1777                &chart.bar_clock(),
1778                Some(start + TimeDelta::seconds(61)),
1779                Some(&trades),
1780            );
1781            chart
1782                .indicator_mut(id)
1783                .unwrap()
1784                .set_input("representation", InputValue::Choice(0))
1785                .unwrap();
1786            frame(&mut chart).drop_without_applying_deltas();
1787            assert_eq!(
1788                drawn(&chart),
1789                if name == "Execution Delta" {
1790                    vec![[0.0, 4.0, 0.0, 4.0], [0.0, 5.0, 0.0, 5.0]]
1791                } else {
1792                    vec![[0.0, 4.0, 0.0, 4.0], [4.0, 9.0, 4.0, 9.0]]
1793                }
1794            );
1795        }
1796    }
1797}