1use super::FeedError;
4use crate::model::{BarClock, OrderSide, Tick};
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8use std::ops::RangeBounds;
9
10pub(crate) fn meets_threshold(value: f64, threshold: f64) -> bool {
13 value >= threshold * (1.0 - 1e-9)
14}
15
16#[derive(Clone, Debug, Default, PartialEq)]
18pub struct FootprintLevel {
19 pub level: i64,
21 pub bid: f64,
23 pub ask: f64,
25 pub unknown: f64,
27}
28
29impl FootprintLevel {
30 pub fn delta(&self) -> f64 {
32 self.ask - self.bid
33 }
34
35 pub fn volume(&self) -> f64 {
37 self.bid + self.ask + self.unknown
38 }
39
40 pub fn ratio(&self) -> Option<f64> {
42 (self.bid > 0.0).then(|| self.ask / self.bid)
43 }
44}
45
46#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
51pub struct NodeSensitivity {
52 pub smoothing: u32,
55 pub prominence: f64,
59}
60
61#[derive(Clone, Debug, PartialEq)]
63pub struct FootprintBar {
64 pub time: DateTime<Utc>,
66 pub levels: BTreeMap<i64, FootprintLevel>,
68 pub bid: f64,
70 pub ask: f64,
72 pub unknown: f64,
74 pub min_delta: f64,
76 pub max_delta: f64,
78}
79
80impl FootprintBar {
81 pub(crate) fn empty(time: DateTime<Utc>) -> Self {
83 Self {
84 time,
85 levels: BTreeMap::new(),
86 bid: 0.0,
87 ask: 0.0,
88 unknown: 0.0,
89 min_delta: 0.0,
90 max_delta: 0.0,
91 }
92 }
93
94 pub(crate) fn absorb(&mut self, next: &FootprintBar) {
96 let open = self.delta();
97 self.min_delta = self.min_delta.min(open + next.min_delta);
98 self.max_delta = self.max_delta.max(open + next.max_delta);
99 self.bid += next.bid;
100 self.ask += next.ask;
101 self.unknown += next.unknown;
102 for (&level, row) in &next.levels {
103 let target = self.levels.entry(level).or_insert_with(|| FootprintLevel {
104 level,
105 ..Default::default()
106 });
107 target.bid += row.bid;
108 target.ask += row.ask;
109 target.unknown += row.unknown;
110 }
111 }
112
113 pub fn delta(&self) -> f64 {
115 self.ask - self.bid
116 }
117
118 pub fn volume(&self) -> f64 {
120 self.bid + self.ask + self.unknown
121 }
122
123 pub fn delta_percent(&self) -> Option<f64> {
125 (self.volume() > 0.0).then(|| self.delta() / self.volume() * 100.0)
126 }
127
128 pub fn poc(&self) -> Option<i64> {
130 self.levels
131 .values()
132 .max_by(|a, b| {
133 a.volume()
134 .total_cmp(&b.volume())
135 .then_with(|| b.level.cmp(&a.level))
136 })
137 .map(|level| level.level)
138 }
139
140 pub fn value_area(&self, share: f64) -> Option<(i64, i64)> {
145 if self.volume() <= 0.0 {
146 return None;
147 }
148 let rows: Vec<_> = self.levels.values().collect();
149 let poc = self.poc()?;
150 let mut low = rows.partition_point(|row| row.level < poc);
151 let mut high = low;
152 let mut volume = rows[low].volume();
153 let target = self.volume()
154 * if share.is_nan() {
155 0.0
156 } else {
157 share.clamp(0.0, 1.0)
158 };
159 let mut low_key = rows[low].level;
160 let mut high_key = low_key;
161 while volume < target && (low > 0 || high + 1 < rows.len()) {
162 let below = low.checked_sub(1).map(|i| rows[i]);
163 let above = rows.get(high + 1).copied();
164 let below_gap = below.map(|row| low_key as i128 - row.level as i128);
165 let above_gap = above.map(|row| row.level as i128 - high_key as i128);
166 let gap = below_gap.into_iter().chain(above_gap).min().unwrap_or(1);
168 if gap > 1 {
169 if below.is_some() {
170 low_key = (low_key as i128 - gap + 1) as i64;
171 }
172 if above.is_some() {
173 high_key = (high_key as i128 + gap - 1) as i64;
174 }
175 }
176 let bid_volume = below.map(|row| {
177 if row.level as i128 + 1 == low_key as i128 {
178 row.volume()
179 } else {
180 0.0
181 }
182 });
183 let ask_volume = above.map(|row| {
184 if row.level as i128 - 1 == high_key as i128 {
185 row.volume()
186 } else {
187 0.0
188 }
189 });
190 if below.is_some() && (above.is_none() || bid_volume >= ask_volume) {
191 low_key -= 1;
192 if below.is_some_and(|row| row.level == low_key) {
193 low -= 1;
194 volume += rows[low].volume();
195 }
196 }
197 if above.is_some() && (below.is_none() || ask_volume >= bid_volume) {
198 high_key += 1;
199 if above.is_some_and(|row| row.level == high_key) {
200 high += 1;
201 volume += rows[high].volume();
202 }
203 }
204 }
205 Some((low_key, high_key))
206 }
207
208 pub fn imbalance(
214 &self,
215 level: i64,
216 ratio: f64,
217 diagonal: bool,
218 min_volume: f64,
219 ) -> (bool, bool) {
220 let Some(row) = self.levels.get(&level) else {
221 return (false, false);
222 };
223 if !ratio.is_finite() || ratio < 1.0 || !min_volume.is_finite() || min_volume < 0.0 {
224 return (false, false);
225 }
226 let dominant = |a: f64, b: f64| {
227 a > b && meets_threshold(a, min_volume) && meets_threshold(a / ratio, b)
228 };
229 if !diagonal {
230 return (dominant(row.bid, row.ask), dominant(row.ask, row.bid));
231 }
232 let bid = level
233 .checked_add(1)
234 .and_then(|key| self.levels.get(&key))
235 .is_some_and(|above| dominant(row.bid, above.ask));
236 let ask = level
237 .checked_sub(1)
238 .and_then(|key| self.levels.get(&key))
239 .is_some_and(|below| dominant(row.ask, below.bid));
240 (bid, ask)
241 }
242}
243
244#[derive(Clone, Debug)]
251pub struct Footprint {
252 clock: BarClock,
253 step: f64,
254 tick_size: f64,
255 ticks_per_row: u32,
256 bars: BTreeMap<DateTime<Utc>, FootprintBar>,
257 rows: usize,
259 last: Option<DateTime<Utc>>,
260 min_size: f64,
261 max_size: f64,
262 max_bars: usize,
263 max_rows: usize,
264 incomplete_through: Option<DateTime<Utc>>,
266}
267
268impl Footprint {
269 pub fn new(clock: BarClock, tick_size: f64, ticks_per_row: u32) -> Result<Self, FeedError> {
273 let step = tick_size * ticks_per_row as f64;
274 if !tick_size.is_finite() || tick_size <= 0.0 {
275 return Err(FeedError::InvalidData(format!(
276 "footprint tick_size must be finite and above zero, got {tick_size:?}"
277 )));
278 }
279 if ticks_per_row == 0 {
280 return Err(FeedError::InvalidData(
281 "footprint ticks_per_row must be at least 1, got 0".into(),
282 ));
283 }
284 if !step.is_finite() {
285 return Err(FeedError::InvalidData(format!(
286 "footprint price step of tick_size {tick_size:?} times ticks_per_row {ticks_per_row} is not finite"
287 )));
288 }
289 Ok(Self {
290 clock,
291 step,
292 tick_size,
293 ticks_per_row,
294 bars: BTreeMap::new(),
295 rows: 0,
296 last: None,
297 min_size: 0.0,
298 max_size: f64::MAX,
299 max_bars: usize::MAX,
300 max_rows: usize::MAX,
301 incomplete_through: None,
302 })
303 }
304
305 pub(crate) fn set_budget(&mut self, bars: usize, rows: usize) {
308 self.max_bars = bars;
309 self.max_rows = rows;
310 self.enforce_budget();
311 }
312
313 pub fn complete_from(&self) -> Option<DateTime<Utc>> {
317 self.complete_bars(..).next().map(|bar| bar.time)
318 }
319
320 pub(crate) fn complete_bars(
323 &self,
324 range: impl RangeBounds<DateTime<Utc>>,
325 ) -> impl Iterator<Item = &FootprintBar> {
326 let cut = self.incomplete_through;
327 self.bars
328 .range(range)
329 .filter(move |(time, _)| cut.is_none_or(|cut| **time > cut))
330 .map(|(_, bar)| bar)
331 }
332
333 #[cfg_attr(
337 all(not(feature = "widget"), not(test)),
338 expect(dead_code, reason = "only the chart widget evicts from its footprint")
339 )]
340 pub(crate) fn drop_through<'a>(
341 &mut self,
342 through: DateTime<Utc>,
343 held: impl IntoIterator<Item = &'a Tick>,
344 ) {
345 let open = self.clock.bar_open(through);
346 self.incomplete_through = self.incomplete_through.max(Some(open));
347 self.bars = self.bars.split_off(&open);
348 if self.bars.contains_key(&open) {
349 let mut part = Self {
350 clock: self.clock.clone(),
351 bars: BTreeMap::new(),
352 rows: 0,
353 last: None,
354 ..*self
355 };
356 for trade in held
357 .into_iter()
358 .take_while(|trade| self.clock.bar_open(trade.ts) <= open)
359 {
360 let _ = part.push(trade);
361 }
362 match part.bars.remove(&open) {
363 Some(bar) => self.bars.insert(open, bar),
364 None => self.bars.remove(&open),
365 };
366 }
367 self.rows = self.bars.values().map(|bar| bar.levels.len()).sum();
368 }
369
370 #[cfg_attr(
373 not(feature = "widget"),
374 expect(
375 dead_code,
376 reason = "only the chart widget knows where its trades begin"
377 )
378 )]
379 pub(crate) fn received_from(&mut self, first: DateTime<Utc>) {
380 let open = self.clock.bar_open(first);
381 self.incomplete_through = self.incomplete_through.max(Some(open));
382 }
383
384 fn enforce_budget(&mut self) {
385 while self.bars.len() > 1 && (self.bars.len() > self.max_bars || self.rows > self.max_rows)
386 {
387 if let Some((time, bar)) = self.bars.pop_first() {
388 self.rows -= bar.levels.len();
389 self.incomplete_through = self.incomplete_through.max(Some(time));
390 }
391 }
392 }
393
394 pub fn set_size_filter(&mut self, min: f64, max: f64) -> Result<(), FeedError> {
397 if self.last.is_some() {
398 return Err(FeedError::InvalidData(
399 "footprint size filter must be set before the first trade".into(),
400 ));
401 }
402 if !min.is_finite() || min < 0.0 {
403 return Err(FeedError::InvalidData(format!(
404 "footprint size filter min must be finite and at least 0, got {min:?}"
405 )));
406 }
407 if !max.is_finite() {
408 return Err(FeedError::InvalidData(format!(
409 "footprint size filter max must be finite, got {max:?}"
410 )));
411 }
412 if max < min {
413 return Err(FeedError::InvalidData(format!(
414 "footprint size filter max {max:?} is below min {min:?}"
415 )));
416 }
417 self.min_size = min;
418 self.max_size = max;
419 Ok(())
420 }
421
422 pub fn tick_size(&self) -> f64 {
424 self.tick_size
425 }
426
427 pub fn price_step(&self) -> f64 {
429 self.step
430 }
431
432 #[cfg(feature = "studies")]
434 pub(crate) fn clock(&self) -> &BarClock {
435 &self.clock
436 }
437
438 pub fn level_of(&self, price: f64) -> Option<i64> {
441 let level = ((price / self.tick_size).round() / self.ticks_per_row as f64).floor();
442 (level.is_finite() && level >= i64::MIN as f64 && level < i64::MAX as f64)
443 .then_some(level as i64)
444 }
445
446 pub fn bars(&self) -> &BTreeMap<DateTime<Utc>, FootprintBar> {
448 &self.bars
449 }
450
451 pub fn bar_at(&self, time: DateTime<Utc>) -> Option<&FootprintBar> {
454 self.bars.get(&self.clock.bar_open(time))
455 }
456
457 pub fn profile(&self, first: DateTime<Utc>, last: DateTime<Utc>) -> FootprintBar {
462 let (first, last) = (self.clock.bar_open(first), self.clock.bar_open(last));
463 let mut profile = FootprintBar::empty(first);
464 if first <= last {
465 for bar in self.complete_bars(first..=last) {
466 profile.absorb(bar);
467 }
468 }
469 profile
470 }
471
472 pub fn push(&mut self, trade: &Tick) -> Result<bool, FeedError> {
476 if !trade.price.is_finite() {
477 return Err(FeedError::InvalidData(format!(
478 "footprint trade price must be finite, got {:?}",
479 trade.price
480 )));
481 }
482 if !trade.size.is_finite() || trade.size < 0.0 {
483 return Err(FeedError::InvalidData(format!(
484 "footprint trade size must be finite and at least 0, got {:?}",
485 trade.size
486 )));
487 }
488 let Some(level) = self.level_of(trade.price) else {
489 return Err(FeedError::InvalidData(format!(
490 "footprint trade price {:?} is outside the grid of step {:?}",
491 trade.price, self.step
492 )));
493 };
494 if let Some(last) = self.last.filter(|last| trade.ts < *last) {
495 return Err(FeedError::InvalidData(format!(
496 "footprint trade at {} precedes the last trade at {last}",
497 trade.ts
498 )));
499 }
500 if trade.size < self.min_size || trade.size > self.max_size || trade.size == 0.0 {
501 return Ok(false);
502 }
503 let time = self.clock.bar_open(trade.ts);
504 if self
505 .bars
506 .get(&time)
507 .is_some_and(|bar| !(bar.volume() + trade.size).is_finite())
508 {
509 return Err(FeedError::InvalidData("footprint volume overflow".into()));
510 }
511 let bar = self.bars.entry(time).or_insert_with(|| FootprintBar {
512 time,
513 levels: BTreeMap::new(),
514 bid: 0.0,
515 ask: 0.0,
516 unknown: 0.0,
517 min_delta: 0.0,
518 max_delta: 0.0,
519 });
520 let row = bar.levels.entry(level).or_insert_with(|| {
521 self.rows += 1;
522 FootprintLevel {
523 level,
524 ..Default::default()
525 }
526 });
527 match trade.side {
528 Some(OrderSide::Buy) => {
529 row.ask += trade.size;
530 bar.ask += trade.size;
531 }
532 Some(OrderSide::Sell) => {
533 row.bid += trade.size;
534 bar.bid += trade.size;
535 }
536 _ => {
537 row.unknown += trade.size;
538 bar.unknown += trade.size;
539 }
540 }
541 bar.min_delta = bar.min_delta.min(bar.delta());
542 bar.max_delta = bar.max_delta.max(bar.delta());
543 self.last = Some(trade.ts);
544 self.enforce_budget();
545 Ok(true)
546 }
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552 use crate::model::Timeframe;
553 use chrono::TimeDelta;
554
555 #[test]
556 fn argument_errors_name_the_argument_and_its_value() {
557 use crate::data::{
558 DepthHistory, DepthHistoryConfig, MarketEvent, ReplayClock, ReplayEvent,
559 aggregate_trades,
560 };
561 let t0: DateTime<Utc> = "2026-01-05T10:00:00Z".parse().unwrap();
562 let t1 = t0 + TimeDelta::seconds(1);
563 let clock = || BarClock::new(Timeframe::Min1);
564 let trade = |ts, price, size| Tick::new(ts, price, size, None);
565 let base = DepthHistoryConfig::default();
566 let depth = |config| DepthHistory::new(config).map(drop);
567 let fresh = || Footprint::new(clock(), 1.0, 1).unwrap();
568 let mut started = fresh();
569 started.push(&trade(t1, 100.0, 1.0)).unwrap();
570 let event = |ts| ReplayEvent::new("A", t0, 0, MarketEvent::Trade(trade(ts, 100.0, 1.0)));
571 #[rustfmt::skip]
572 let cases = [
573 (depth(DepthHistoryConfig { cadence: TimeDelta::zero(), ..base.clone() }), "depth history cadence must be at least one millisecond, got P0D"),
574 (depth(DepthHistoryConfig { rows: 0, ..base.clone() }), "depth history rows must be at least 1, got 0"),
575 (depth(DepthHistoryConfig { ticks_per_row: 0, ..base.clone() }), "depth history ticks_per_row must be at least 1, got 0"),
576 (depth(DepthHistoryConfig { columns: 1, ..base.clone() }), "depth history columns must be at least 2, got 1"),
577 (Footprint::new(clock(), f64::NAN, 1).map(drop), "footprint tick_size must be finite and above zero, got NaN"),
578 (Footprint::new(clock(), 1.0, 0).map(drop), "footprint ticks_per_row must be at least 1, got 0"),
579 (Footprint::new(clock(), 1e300, u32::MAX).map(drop), "footprint price step of tick_size 1e300 times ticks_per_row 4294967295 is not finite"),
580 (started.clone().set_size_filter(0.0, 1.0), "footprint size filter must be set before the first trade"),
581 (fresh().set_size_filter(-1.0, 1.0), "footprint size filter min must be finite and at least 0, got -1.0"),
582 (fresh().set_size_filter(0.0, f64::NAN), "footprint size filter max must be finite, got NaN"),
583 (fresh().set_size_filter(5.0, 1.0), "footprint size filter max 1.0 is below min 5.0"),
584 (started.clone().push(&trade(t1, f64::NAN, 1.0)).map(drop), "footprint trade price must be finite, got NaN"),
585 (started.clone().push(&trade(t1, 100.0, -1.0)).map(drop), "footprint trade size must be finite and at least 0, got -1.0"),
586 (Footprint::new(clock(), 1e-300, u32::MAX).unwrap().push(&trade(t1, 1e6, 1.0)).map(drop), "footprint trade price 1000000.0 is outside the grid of step 4.294967295e-291"),
587 (started.clone().push(&trade(t0, 100.0, 1.0)).map(drop), "footprint trade at 2026-01-05 10:00:00 UTC precedes the last trade at 2026-01-05 10:00:01 UTC"),
588 (ReplayClock::new(vec![event(t1)]).map(drop), "replay event of \"A\" at 2026-01-05 10:00:00 UTC with sequence 0 is invalid"),
589 (ReplayClock::new(vec![event(t0), event(t0)]).map(drop), "replay events of \"A\" at 2026-01-05 10:00:00 UTC share sequence 0"),
590 (ReplayClock::new(Vec::new()).unwrap().set_speed(-1.0), "replay speed must be finite and at least 0, got -1.0"),
591 (aggregate_trades(&[trade(t0, 100.0, 1.0), trade(t1, f64::NAN, 1.0)], Timeframe::Min1).map(drop), "trade 1 price must be finite, got NaN"),
592 (aggregate_trades(&[trade(t0, 100.0, 1.0), trade(t1, 100.0, -1.0)], Timeframe::Min1).map(drop), "trade 1 size must be finite and at least 0, got -1.0"),
593 (aggregate_trades(&[trade(t1, 100.0, 1.0), trade(t0, 100.0, 1.0)], Timeframe::Min1).map(drop), "trade 1 at 2026-01-05 10:00:00 UTC is out of time order"),
594 ];
595 for (result, expected) in cases {
596 assert_eq!(
597 result.unwrap_err().to_string(),
598 format!("invalid data: {expected}")
599 );
600 }
601 }
602
603 #[test]
604 fn trades_keep_side_path_grouping_and_value_area() {
605 let start: DateTime<Utc> = "2026-01-05T10:00:00Z".parse().unwrap();
606 let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
607 for (i, price, size, side) in [
609 (0, 100.0, 3.0, Some(OrderSide::Sell)),
610 (1, 100.25, 8.0, Some(OrderSide::Buy)),
611 (2, 100.0, 2.0, Some(OrderSide::Sell)),
612 (3, 100.5, 7.0, None),
613 (4, 100.75, 4.0, Some(OrderSide::Buy)),
614 ] {
615 flow.push(&Tick::new(start + TimeDelta::seconds(i), price, size, side))
616 .unwrap();
617 }
618 let bar = &flow.bars()[&start];
619 assert_eq!(
620 (
621 bar.bid,
622 bar.ask,
623 bar.volume(),
624 bar.delta(),
625 bar.min_delta,
626 bar.max_delta
627 ),
628 (5.0, 12.0, 24.0, 7.0, -3.0, 7.0)
629 );
630 assert_eq!(flow.bar_at(start + TimeDelta::seconds(59)), Some(bar));
631 assert_eq!(bar.poc(), Some(401));
632 assert_eq!(bar.value_area(0.7), Some((400, 402)));
634 assert_eq!(bar.imbalance(401, 1.5, true, 0.0), (false, true));
635 assert_eq!(bar.imbalance(403, 1.5, true, 0.0), (false, true));
636 assert_eq!(bar.delta_percent(), Some(7.0 / 24.0 * 100.0));
637 let snapshot = flow.bars().clone();
638 for trade in [
639 Tick::new(start, 100.0, 1.0, None),
640 Tick::new(start + TimeDelta::seconds(5), f64::NAN, 1.0, None),
641 ] {
642 assert!(flow.push(&trade).is_err());
643 assert_eq!(flow.bars(), &snapshot);
644 }
645 let mut grouped = Footprint::new(BarClock::new(Timeframe::Min1), 0.1, 3).unwrap();
647 for price in [-0.1, 0.3, 0.6] {
648 grouped.push(&Tick::new(start, price, 1.0, None)).unwrap();
649 }
650 assert_eq!(
651 grouped.bars()[&start]
652 .levels
653 .keys()
654 .copied()
655 .collect::<Vec<_>>(),
656 [-1, 1, 2]
657 );
658 let mut sparse = Footprint::new(BarClock::new(Timeframe::Min1), 1.0, 1).unwrap();
660 for (price, size) in [(99.0, 4.0), (100.0, 10.0), (110.0, 9.0)] {
661 sparse.push(&Tick::new(start, price, size, None)).unwrap();
662 }
663 assert_eq!(sparse.bars()[&start].value_area(0.6), Some((99, 100)));
664 }
665
666 #[test]
669 fn dropped_executions_match_the_held_ones_summed_again() {
670 let start: DateTime<Utc> = "2026-01-05T10:00:00Z".parse().unwrap();
671 let sells = [
672 Some(OrderSide::Sell),
673 Some(OrderSide::Buy),
674 Some(OrderSide::Buy),
675 ];
676 let trades: Vec<_> = (0..7)
679 .map(|i| {
680 Tick::new(
681 start + TimeDelta::seconds(i),
682 100.0 + i as f64,
683 1.0,
684 sells[i as usize % 3],
685 )
686 })
687 .collect();
688 let new = || Footprint::new(BarClock::new(Timeframe::Min1), 1.0, 1).unwrap();
689 let mut flow = new();
690 trades.iter().for_each(|t| _ = flow.push(t));
691 flow.drop_through(trades[1].ts, &trades[2..]);
692 let mut held = new();
693 trades[2..].iter().for_each(|t| _ = held.push(t));
694 assert_eq!(flow.bars(), held.bars());
695 assert_eq!(
696 (flow.bars()[&start].min_delta, flow.bars()[&start].max_delta),
697 (0.0, 2.0)
698 );
699 assert_eq!(flow.complete_from(), None);
700
701 let mut budget = new();
702 budget.set_budget(2, usize::MAX);
703 for minute in 0..3 {
704 let at = start + TimeDelta::minutes(minute);
705 budget.push(&Tick::new(at, 100.0, 1.0, None)).unwrap();
706 }
707 assert_eq!(budget.bars().len(), 2);
708 assert_eq!(budget.complete_from(), Some(start + TimeDelta::minutes(1)));
709 }
710}