1use crate::data::{Footprint, FootprintBar, meets_threshold};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum FootprintStudyKind {
10 StackedBid,
12 StackedAsk,
14 UnfinishedHigh,
16 UnfinishedLow,
18 NakedPoc,
20 DeltaPoc,
22 VolumeCluster,
24 SinglePrint,
26 DeltaAsk,
28 DeltaBid,
30 RatioAsk,
32 RatioBid,
34}
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum FootprintStudyState {
39 Local,
41 Unvisited,
43 Revisited,
45 Expired,
47}
48
49#[derive(Clone, Debug, PartialEq)]
51pub struct FootprintStudy {
52 pub start: DateTime<Utc>,
54 pub end: Option<DateTime<Utc>>,
58 pub low: i64,
60 pub high: i64,
62 pub kind: FootprintStudyKind,
64 pub state: FootprintStudyState,
66}
67
68#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
71#[serde(default)]
72pub struct FootprintStudyConfig {
73 pub stacked_imbalances: bool,
75 pub naked_poc: bool,
77 pub delta_poc: bool,
79 pub unfinished_auctions: bool,
81 pub imbalance_ratio: f64,
83 pub imbalance_min_volume: f64,
87 pub stack: usize,
89 pub diagonal: bool,
92 pub extend_bars: Option<usize>,
95 pub cluster_share: Option<f64>,
98 pub single_print_max: Option<f64>,
102 pub delta_percent: Option<f64>,
106 pub ratio: Option<f64>,
110 pub min_delta: f64,
112}
113
114impl Default for FootprintStudyConfig {
115 fn default() -> Self {
116 Self {
117 stacked_imbalances: true,
118 naked_poc: true,
119 delta_poc: true,
120 unfinished_auctions: true,
121 imbalance_ratio: 3.0,
122 imbalance_min_volume: 0.0,
123 stack: 3,
124 diagonal: true,
125 extend_bars: None,
126 cluster_share: None,
127 single_print_max: None,
128 delta_percent: None,
129 ratio: None,
130 min_delta: 0.0,
131 }
132 }
133}
134
135pub fn footprint_studies(flow: &Footprint, config: &FootprintStudyConfig) -> Vec<FootprintStudy> {
144 let bars: Vec<_> = flow.complete_bars(..).map(|bar| (bar, 1)).collect();
145 footprint_studies_on_rows(&bars, config)
146}
147
148pub(crate) fn footprint_studies_on_rows(
150 bars: &[(&FootprintBar, i64)],
151 config: &FootprintStudyConfig,
152) -> Vec<FootprintStudy> {
153 let mut result = Vec::new();
154 for (index, &(bar, group)) in bars.iter().enumerate() {
155 let mut add = |low: i64, high: i64, kind, extended| {
156 let enabled = match kind {
157 FootprintStudyKind::StackedBid | FootprintStudyKind::StackedAsk => {
158 config.stacked_imbalances
159 }
160 FootprintStudyKind::NakedPoc => config.naked_poc,
161 FootprintStudyKind::DeltaPoc => config.delta_poc,
162 FootprintStudyKind::UnfinishedHigh | FootprintStudyKind::UnfinishedLow => {
163 config.unfinished_auctions
164 }
165 _ => true,
166 };
167 if !enabled {
168 return;
169 }
170 let low = low.saturating_mul(group);
171 let high = high.saturating_mul(group).saturating_add(group - 1);
172 let (end, state) = if extended {
173 let later = bars[index + 1..]
174 .iter()
175 .take(config.extend_bars.unwrap_or(usize::MAX));
176 let revisit = later.clone().find(|(next, next_group)| {
177 next.levels
178 .first_key_value()
179 .zip(next.levels.last_key_value())
180 .is_some_and(|((first, _), (last, _))| {
181 first.saturating_mul(*next_group) <= high
182 && last
183 .saturating_mul(*next_group)
184 .saturating_add(*next_group - 1)
185 >= low
186 })
187 });
188 match (revisit, config.extend_bars) {
189 (Some((next, _)), _) => (Some(next.time), FootprintStudyState::Revisited),
190 (None, Some(n)) if later.count() == n => (
191 bars.get(index + n).map(|(last, _)| last.time),
192 FootprintStudyState::Expired,
193 ),
194 _ => (None, FootprintStudyState::Unvisited),
195 }
196 } else {
197 (Some(bar.time), FootprintStudyState::Local)
198 };
199 result.push(FootprintStudy {
200 start: bar.time,
201 end,
202 low,
203 high,
204 kind,
205 state,
206 });
207 };
208 let (Some((&first, first_row)), Some((&last, last_row))) =
209 (bar.levels.first_key_value(), bar.levels.last_key_value())
210 else {
211 continue;
212 };
213 if let Some(poc) = bar.poc() {
214 add(poc, poc, FootprintStudyKind::NakedPoc, true);
215 }
216 if let Some(row) = bar
217 .levels
218 .values()
219 .max_by(|a, b| {
220 a.delta()
221 .abs()
222 .total_cmp(&b.delta().abs())
223 .then_with(|| b.level.cmp(&a.level))
224 })
225 .filter(|row| row.delta().abs() >= config.min_delta)
226 {
227 add(row.level, row.level, FootprintStudyKind::DeltaPoc, false);
228 }
229 if last_row.bid > 0.0 {
230 add(last, last, FootprintStudyKind::UnfinishedHigh, true);
231 }
232 if first_row.ask > 0.0 {
233 add(first, first, FootprintStudyKind::UnfinishedLow, true);
234 }
235 if let Some(max) = config.single_print_max.filter(|max| max.is_finite()) {
236 for (i, (key, row)) in [(first, first_row), (last, last_row)]
237 .into_iter()
238 .enumerate()
239 {
240 if (i == 0 || first != last) && row.volume() > 0.0 && row.volume() <= max {
241 add(key, key, FootprintStudyKind::SinglePrint, false);
242 }
243 }
244 }
245 if let Some(share) = config
246 .cluster_share
247 .filter(|share| (0.0..=1.0).contains(share))
248 && bar.volume() > 0.0
249 {
250 for row in bar.levels.values() {
251 if row.volume() / bar.volume() > share {
252 add(
253 row.level,
254 row.level,
255 FootprintStudyKind::VolumeCluster,
256 false,
257 );
258 }
259 }
260 }
261 if let Some((percent, threshold)) = bar
262 .delta_percent()
263 .zip(config.delta_percent.filter(|t| *t >= 0.0))
264 && percent != 0.0
265 && meets_threshold(percent.abs(), threshold)
266 {
267 let kind = if percent > 0.0 {
268 FootprintStudyKind::DeltaAsk
269 } else {
270 FootprintStudyKind::DeltaBid
271 };
272 add(first, last, kind, false);
273 }
274 if let Some(ratio) = config.ratio.filter(|r| *r >= 1.0) {
275 let dominant = |a: f64, b: f64| a > b && meets_threshold(a, b * ratio);
276 if dominant(bar.ask, bar.bid) {
277 add(first, last, FootprintStudyKind::RatioAsk, false);
278 }
279 if dominant(bar.bid, bar.ask) {
280 add(first, last, FootprintStudyKind::RatioBid, false);
281 }
282 }
283 for (side, kind) in [
284 (0, FootprintStudyKind::StackedBid),
285 (1, FootprintStudyKind::StackedAsk),
286 ] {
287 let mut run: Option<(i64, i64, usize)> = None;
288 for row in bar.levels.values() {
289 let imbalance = bar.imbalance(
290 row.level,
291 config.imbalance_ratio,
292 config.diagonal,
293 config.imbalance_min_volume,
294 );
295 let active = if side == 0 { imbalance.0 } else { imbalance.1 };
296 if let Some((low, high, count)) = run
297 && (!active || high.checked_add(1) != Some(row.level))
298 {
299 if count >= config.stack.max(1) {
300 add(low, high, kind, true);
301 }
302 run = None;
303 }
304 if active {
305 run = Some(match run {
306 Some((low, _, count)) => (low, row.level, count + 1),
307 None => (row.level, row.level, 1),
308 });
309 }
310 }
311 if let Some((low, high, count)) = run
312 && count >= config.stack.max(1)
313 {
314 add(low, high, kind, true);
315 }
316 }
317 }
318 result
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use crate::model::{BarClock, OrderSide, Tick, Timeframe};
325 use chrono::TimeDelta;
326
327 #[test]
328 fn adjacent_diagonals_extend_only_until_a_later_revisit() {
329 let start: DateTime<Utc> = "2026-01-05T10:00:00Z".parse().unwrap();
330 let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 1.0, 1).unwrap();
331 for (second, price, size, side) in [
332 (0, 100.0, 1.0, OrderSide::Sell),
333 (1, 101.0, 1.0, OrderSide::Sell),
334 (2, 101.0, 4.0, OrderSide::Buy),
335 (3, 102.0, 1.0, OrderSide::Sell),
336 (4, 102.0, 4.0, OrderSide::Buy),
337 (5, 103.0, 4.0, OrderSide::Buy),
338 (60, 110.0, 1.0, OrderSide::Buy),
339 (120, 102.0, 1.0, OrderSide::Sell),
340 ] {
341 flow.push(&Tick::new(
342 start + TimeDelta::seconds(second),
343 price,
344 size,
345 Some(side),
346 ))
347 .unwrap();
348 }
349 let config = |cluster_share| FootprintStudyConfig {
351 cluster_share: Some(cluster_share),
352 single_print_max: Some(1.0),
353 ..Default::default()
354 };
355 assert!(
356 !footprint_studies(&flow, &config(1.0 / 3.0))
357 .iter()
358 .any(|s| s.start == start && s.kind == FootprintStudyKind::VolumeCluster)
359 );
360 let studies = footprint_studies(&flow, &config(0.3));
361 let first: Vec<_> = studies.iter().filter(|s| s.start == start).collect();
362 let single = first
363 .iter()
364 .find(|s| s.kind == FootprintStudyKind::SinglePrint)
365 .unwrap();
366 assert_eq!(
367 (single.low, single.high, single.end),
368 (100, 100, Some(start))
369 );
370 let stack = first
371 .iter()
372 .find(|s| s.kind == FootprintStudyKind::StackedAsk)
373 .unwrap();
374 assert_eq!(
375 (stack.low, stack.high, stack.end),
376 (101, 103, Some(start + TimeDelta::minutes(2)))
377 );
378 let poc = first
379 .iter()
380 .find(|s| s.kind == FootprintStudyKind::NakedPoc)
381 .unwrap();
382 assert_eq!((poc.low, poc.end), (101, None)); assert_eq!(poc.state, FootprintStudyState::Unvisited);
384 assert_eq!(stack.state, FootprintStudyState::Revisited);
385 assert!(!first.iter().any(|s| matches!(
386 s.kind,
387 FootprintStudyKind::UnfinishedHigh | FootprintStudyKind::UnfinishedLow
388 )));
389 assert_eq!(
390 first
391 .iter()
392 .filter(|s| s.kind == FootprintStudyKind::VolumeCluster)
393 .count(),
394 2
395 ); }
397
398 fn flow(trades: &[(i64, f64, f64, Option<OrderSide>)]) -> Footprint {
401 let start: DateTime<Utc> = "2026-01-05T10:00:00Z".parse().unwrap();
402 let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 1.0, 1).unwrap();
403 for &(second, price, size, side) in trades {
404 flow.push(&Tick::new(
405 start + TimeDelta::seconds(second),
406 price,
407 size,
408 side,
409 ))
410 .unwrap();
411 }
412 flow
413 }
414
415 fn found(
417 flow: &Footprint,
418 config: &FootprintStudyConfig,
419 kind: FootprintStudyKind,
420 ) -> Vec<(i64, i64, Option<i64>, FootprintStudyState)> {
421 let start: DateTime<Utc> = "2026-01-05T10:00:00Z".parse().unwrap();
422 footprint_studies(flow, config)
423 .iter()
424 .filter(|s| s.kind == kind && s.start == start)
425 .map(|s| {
426 let end = s.end.map(|end| (end - start).num_minutes());
427 (s.low, s.high, end, s.state)
428 })
429 .collect()
430 }
431
432 #[test]
439 fn thresholds_unfinished_levels_and_zones_follow_their_rules() {
440 use FootprintStudyKind::*;
441 use FootprintStudyState::*;
442 let (buy, sell) = (Some(OrderSide::Buy), Some(OrderSide::Sell));
443 let marked = |trades: &[_], config: &FootprintStudyConfig, kind| {
444 !found(&flow(trades), config, kind).is_empty()
445 };
446 let half = [(0, 100.0, 3.0, buy), (1, 100.0, 1.0, sell)];
448 let diluted = [
449 (0, 100.0, 3.0, buy),
450 (1, 100.0, 1.0, sell),
451 (2, 100.0, 4.0, None),
452 ];
453 let flat = [(0, 100.0, 2.0, buy), (1, 100.0, 2.0, sell)];
454 let delta = |t: f64| FootprintStudyConfig {
455 delta_percent: Some(t),
456 ..Default::default()
457 };
458 assert!(marked(&half, &delta(50.0), DeltaAsk));
459 assert!(!marked(&half, &delta(50.1), DeltaAsk));
460 assert!(marked(&diluted, &delta(25.0), DeltaAsk));
461 assert!(!marked(&diluted, &delta(26.0), DeltaAsk));
462 assert!(marked(
463 &[(0, 100.0, 3.0, sell), (1, 100.0, 1.0, buy)],
464 &delta(50.0),
465 DeltaBid
466 ));
467 assert!(!marked(&flat, &delta(0.0), DeltaAsk) && !marked(&flat, &delta(0.0), DeltaBid));
468 let ratio = |r: f64| FootprintStudyConfig {
470 ratio: Some(r),
471 ..Default::default()
472 };
473 let six = [(0, 100.0, 6.0, buy), (1, 100.0, 2.0, sell)];
474 assert!(marked(&six, &ratio(3.0), RatioAsk));
475 assert!(!marked(&six, &ratio(3.5), RatioAsk));
476 assert!(marked(&[(0, 100.0, 5.0, buy)], &ratio(1000.0), RatioAsk));
477 assert!(marked(
478 &[(0, 100.0, 2.0, buy), (1, 100.0, 6.0, sell)],
479 &ratio(3.0),
480 RatioBid
481 ));
482 assert!(!marked(&[(0, 100.0, 5.0, None)], &ratio(1.0), RatioAsk));
483 assert!(!marked(&flat, &ratio(1.0), RatioAsk) && !marked(&flat, &ratio(1.0), RatioBid));
484 for scale in [1.0, 0.1] {
486 for (side, other, delta_kind, ratio_kind) in [
487 (buy, sell, DeltaAsk, RatioAsk),
488 (sell, buy, DeltaBid, RatioBid),
489 ] {
490 let pair =
491 |a: f64, b: f64| [(0, 100.0, a * scale, side), (1, 100.0, b * scale, other)];
492 let imbalance = |a, b| {
493 let level = flow(&pair(a, b));
494 level
495 .bars()
496 .values()
497 .next()
498 .unwrap()
499 .imbalance(100, 1.1, false, 0.0)
500 };
501 let share = [
502 (0, 100.0, 29.0 * scale, side),
503 (1, 100.0, 71.0 * scale, None),
504 ];
505 assert!(marked(&share, &delta(29.0), delta_kind), "scale {scale}");
506 assert_eq!(imbalance(33.0, 30.0), (side == sell, side == buy));
507 assert_eq!(imbalance(32.0, 30.0), (false, false));
508 assert!(marked(&pair(55.0, 50.0), &ratio(1.1), ratio_kind));
509 assert!(!marked(&pair(54.0, 50.0), &ratio(1.1), ratio_kind));
510 }
511 }
512 let lots = |buys: usize, sells: usize, size: f64| {
515 let mut trades = vec![(0, 100.0, size, buy); buys];
516 trades.extend(vec![(0, 100.0, size, sell); sells]);
517 trades
518 };
519 let accumulated = lots(209, 190, 0.01);
520 assert_eq!(
521 flow(&accumulated)
522 .bars()
523 .values()
524 .next()
525 .unwrap()
526 .imbalance(100, 1.1, false, 0.0),
527 (false, true)
528 );
529 assert!(marked(&accumulated, &ratio(1.1), RatioAsk));
530 for (minimum, expected) in [(2.09, true), (2.1, false), (-1.0, false)] {
531 let config = FootprintStudyConfig {
532 diagonal: false,
533 stack: 1,
534 imbalance_ratio: 1.1,
535 imbalance_min_volume: minimum,
536 ..Default::default()
537 };
538 assert_eq!(
539 marked(&accumulated, &config, StackedAsk),
540 expected,
541 "minimum {minimum}"
542 );
543 }
544 assert!(!marked(&lots(208, 190, 0.01), &ratio(1.1), RatioAsk));
545 assert!(marked(&lots(129, 71, 0.1), &delta(29.0), DeltaAsk));
546 let rows = [(0, 100.0, 2.0, buy), (1, 101.0, 3.0, sell)];
548 let tied = [(0, 100.0, 3.0, buy), (1, 101.0, 3.0, sell)];
549 let poc = |min_delta: f64| FootprintStudyConfig {
550 min_delta,
551 ..Default::default()
552 };
553 assert_eq!(
554 found(&flow(&rows), &poc(3.0), DeltaPoc),
555 [(101, 101, Some(0), Local)]
556 );
557 assert!(found(&flow(&rows), &poc(3.5), DeltaPoc).is_empty());
558 assert_eq!(
559 found(&flow(&tied), &poc(0.0), DeltaPoc),
560 [(100, 100, Some(0), Local)]
561 );
562 for (trades, high, low) in [
564 (vec![(0, 100.0, 1.0, buy), (1, 100.0, 1.0, sell)], 1, 1),
565 (vec![(0, 100.0, 1.0, buy)], 0, 1),
566 (vec![(0, 100.0, 1.0, sell), (1, 100.0, 1.0, None)], 1, 0),
567 (vec![(0, 100.0, 1.0, None)], 0, 0),
568 (vec![(0, 100.0, 1.0, sell), (1, 101.0, 1.0, buy)], 0, 0),
569 ] {
570 let flow = flow(&trades);
571 let config = FootprintStudyConfig::default();
572 assert_eq!(
573 found(&flow, &config, UnfinishedHigh).len(),
574 high,
575 "{trades:?}"
576 );
577 assert_eq!(
578 found(&flow, &config, UnfinishedLow).len(),
579 low,
580 "{trades:?}"
581 );
582 }
583 let tape = [
586 (0, 100.0, 1.0, buy),
587 (1, 101.0, 1.0, sell),
588 (2, 101.0, 1.0, buy),
589 (60, 103.0, 1.0, buy),
590 (120, 103.0, 1.0, buy),
591 (180, 101.0, 1.0, buy),
592 ];
593 for (extend_bars, high, low) in [
594 (None, (Some(3), Revisited), (None, Unvisited)),
595 (Some(0), (Some(0), Expired), (Some(0), Expired)),
596 (Some(2), (Some(2), Expired), (Some(2), Expired)),
597 (Some(3), (Some(3), Revisited), (Some(3), Expired)),
598 (Some(5), (Some(3), Revisited), (None, Unvisited)),
599 ] {
600 let flow = flow(&tape);
601 let config = FootprintStudyConfig {
602 extend_bars,
603 ..Default::default()
604 };
605 assert_eq!(
606 found(&flow, &config, UnfinishedHigh),
607 [(101, 101, high.0, high.1)],
608 "high, {extend_bars:?}"
609 );
610 assert_eq!(
611 found(&flow, &config, UnfinishedLow),
612 [(100, 100, low.0, low.1)],
613 "low, {extend_bars:?}"
614 );
615 }
616 let wall: Vec<_> = (0..3)
619 .flat_map(|i| {
620 let price = 100.0 + i as f64;
621 [(2 * i, price, 6.0, sell), (2 * i + 1, price, 1.0, buy)]
622 })
623 .collect();
624 let diagonal = |diagonal| FootprintStudyConfig {
625 diagonal,
626 ..Default::default()
627 };
628 assert_eq!(
629 found(&flow(&wall), &diagonal(false), StackedBid),
630 [(100, 102, None, Unvisited)]
631 );
632 assert!(found(&flow(&wall), &diagonal(true), StackedBid).is_empty());
633 for (side, other, kind) in [(buy, sell, StackedAsk), (sell, buy, StackedBid)] {
636 for diagonal in [false, true] {
637 let opposite_price = if !diagonal {
638 101.0
639 } else if side == buy {
640 100.0
641 } else {
642 102.0
643 };
644 for (dominant, opposite, minimum, expected) in [
645 (9.0, 1.0, 0.0, true),
646 (12.0, 4.0, 0.0, true),
647 (9.0, 1.0, 10.0, false),
648 (12.0, 4.0, 10.0, true),
649 (10.0, 1.0, 10.0, true),
650 (10.0, 4.0, 10.0, false),
651 ] {
652 let trades = [
653 (0, opposite_price, opposite, other),
654 (0, opposite_price, 100.0, None),
655 (1, 101.0, dominant, side),
656 (2, 101.0, 100.0, None),
657 ];
658 let config = FootprintStudyConfig {
659 diagonal,
660 stack: 1,
661 imbalance_min_volume: minimum,
662 ..Default::default()
663 };
664 assert_eq!(
665 marked(&trades, &config, kind),
666 expected,
667 "{side:?}, diagonal {diagonal}, {dominant}/{opposite}, minimum {minimum}"
668 );
669 }
670 }
671 }
672 let thin = [
675 (0, 100.0, 1.0, sell),
676 (1, 101.0, 1.0, sell),
677 (2, 102.0, 1.0, sell),
678 (3, 101.0, 12.0, buy),
679 (4, 102.0, 9.0, buy),
680 (5, 103.0, 15.0, buy),
681 ];
682 for (minimum, expected) in [(0.0, true), (10.0, false)] {
683 let config = FootprintStudyConfig {
684 imbalance_min_volume: minimum,
685 ..Default::default()
686 };
687 let zones = found(&flow(&thin), &config, StackedAsk);
688 assert_eq!(zones.len(), usize::from(expected), "minimum {minimum}");
689 if expected {
690 assert_eq!(zones, [(101, 103, None, Unvisited)]);
691 }
692 }
693 }
694}