1use 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#[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 histogram: bool,
42 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 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 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 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 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 pub fn with_order_filter(mut self, filter: OrderFilter) -> Self {
228 self.filter = filter;
229 self.reset();
230 self
231 }
232
233 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 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 #[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 #[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 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 #[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 #[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 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 #[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 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 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 #[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 #[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 #[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 format!("{:?}", lines(&study)) == format!("{:?}", lines(&fresh)),
1613 ));
1614 }
1615 assert_eq!(results, [(10_000.0, false, true), (10.0, true, true)]);
1616 }
1617
1618 #[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 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 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 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}