use chrono::{DateTime, Utc};
#[derive(Debug, Clone)]
pub struct FootprintCell {
pub price: f64,
pub bid_volume: f64,
pub ask_volume: f64,
}
impl FootprintCell {
pub fn new(price: f64) -> Self {
Self {
price,
bid_volume: 0.0,
ask_volume: 0.0,
}
}
pub fn delta(&self) -> f64 {
self.ask_volume - self.bid_volume
}
pub fn total_volume(&self) -> f64 {
self.bid_volume + self.ask_volume
}
pub fn imbalance_ratio(&self) -> f64 {
let total = self.total_volume();
if total > 0.0 {
self.ask_volume / total
} else {
0.5
}
}
pub fn has_imbalance(&self, threshold: f64) -> bool {
let ratio = self.imbalance_ratio();
ratio > threshold || ratio < (1.0 - threshold)
}
}
#[derive(Debug, Clone)]
pub struct FootprintBar {
pub ts: DateTime<Utc>,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: f64,
pub cells: Vec<FootprintCell>,
pub poc_price: Option<f64>,
pub delta: f64,
pub cumulative_delta: f64,
}
impl FootprintBar {
pub fn new(ts: DateTime<Utc>, _tick_size: f64) -> Self {
Self {
ts,
open: 0.0,
high: f64::NEG_INFINITY,
low: f64::INFINITY,
close: 0.0,
volume: 0.0,
cells: Vec::new(),
poc_price: None,
delta: 0.0,
cumulative_delta: 0.0,
}
}
pub fn add_trade(&mut self, price: f64, size: f64, is_buyer_aggressor: bool, tick_size: f64) {
let rounded_price = (price / tick_size).round() * tick_size;
if self.volume == 0.0 {
self.open = rounded_price;
}
self.high = self.high.max(rounded_price);
self.low = self.low.min(rounded_price);
self.close = rounded_price;
self.volume += size;
if is_buyer_aggressor {
self.delta += size;
} else {
self.delta -= size;
}
if let Some(cell) = self
.cells
.iter_mut()
.find(|c| (c.price - rounded_price).abs() < tick_size * 0.5)
{
if is_buyer_aggressor {
cell.ask_volume += size;
} else {
cell.bid_volume += size;
}
} else {
let mut cell = FootprintCell::new(rounded_price);
if is_buyer_aggressor {
cell.ask_volume = size;
} else {
cell.bid_volume = size;
}
self.cells.push(cell);
}
self.cells.sort_by(|a, b| {
b.price
.partial_cmp(&a.price)
.unwrap_or(std::cmp::Ordering::Equal)
});
self.update_poc();
}
fn update_poc(&mut self) {
self.poc_price = self
.cells
.iter()
.max_by(|a, b| {
a.total_volume()
.partial_cmp(&b.total_volume())
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|c| c.price);
}
pub fn val_area(&self, percentage: f64) -> Option<(f64, f64)> {
if self.cells.is_empty() {
return None;
}
let total_volume: f64 = self.cells.iter().map(|c| c.total_volume()).sum();
let target_volume = total_volume * percentage;
let mut cells_by_volume: Vec<_> = self.cells.clone();
cells_by_volume.sort_by(|a, b| {
b.total_volume()
.partial_cmp(&a.total_volume())
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut accumulated = 0.0;
let mut prices = Vec::new();
for cell in cells_by_volume {
accumulated += cell.total_volume();
prices.push(cell.price);
if accumulated >= target_volume {
break;
}
}
if prices.is_empty() {
return None;
}
let low = prices.iter().cloned().fold(f64::INFINITY, f64::min);
let high = prices.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
Some((low, high))
}
pub fn bid_imbalances(&self, threshold: f64) -> Vec<&FootprintCell> {
self.cells
.iter()
.filter(|c| c.imbalance_ratio() < (1.0 - threshold))
.collect()
}
pub fn ask_imbalances(&self, threshold: f64) -> Vec<&FootprintCell> {
self.cells
.iter()
.filter(|c| c.imbalance_ratio() > threshold)
.collect()
}
pub fn stacked_imbalances(&self, threshold: f64, min_cnt: usize) -> Vec<(f64, f64, bool)> {
let mut result = Vec::new();
let mut curr_stack: Vec<&FootprintCell> = Vec::new();
let mut curr_is_ask = None;
for cell in &self.cells {
let ratio = cell.imbalance_ratio();
if ratio > threshold {
if curr_is_ask == Some(true) {
curr_stack.push(cell);
} else {
if curr_stack.len() >= min_cnt
&& let (Some(first), Some(last)) = (curr_stack.first(), curr_stack.last())
{
result.push((first.price, last.price, curr_is_ask.unwrap_or(false)));
}
curr_stack = vec![cell];
curr_is_ask = Some(true);
}
} else if ratio < (1.0 - threshold) {
if curr_is_ask == Some(false) {
curr_stack.push(cell);
} else {
if curr_stack.len() >= min_cnt
&& let (Some(first), Some(last)) = (curr_stack.first(), curr_stack.last())
{
result.push((first.price, last.price, curr_is_ask.unwrap_or(false)));
}
curr_stack = vec![cell];
curr_is_ask = Some(false);
}
} else {
if curr_stack.len() >= min_cnt
&& let (Some(first), Some(last)) = (curr_stack.first(), curr_stack.last())
{
result.push((first.price, last.price, curr_is_ask.unwrap_or(false)));
}
curr_stack.clear();
curr_is_ask = None;
}
}
if curr_stack.len() >= min_cnt
&& let (Some(first), Some(last)) = (curr_stack.first(), curr_stack.last())
{
result.push((first.price, last.price, curr_is_ask.unwrap_or(false)));
}
result
}
pub fn diagonal_imbalances(&self, threshold: f64, tick_size: f64) -> Vec<DiagonalImbalance> {
let mut result = Vec::new();
for i in 0..self.cells.len().saturating_sub(1) {
let upper_cell = &self.cells[i];
let lower_cell = &self.cells[i + 1];
let price_diff = (upper_cell.price - lower_cell.price).abs();
if (price_diff - tick_size).abs() > tick_size * 0.1 {
continue; }
if lower_cell.bid_volume > 0.0 {
let ratio = upper_cell.ask_volume / lower_cell.bid_volume;
if ratio >= threshold {
result.push(DiagonalImbalance {
price: upper_cell.price,
volume: upper_cell.ask_volume,
is_buying: true,
ratio,
});
}
}
if upper_cell.ask_volume > 0.0 {
let ratio = lower_cell.bid_volume / upper_cell.ask_volume;
if ratio >= threshold {
result.push(DiagonalImbalance {
price: lower_cell.price,
volume: lower_cell.bid_volume,
is_buying: false,
ratio,
});
}
}
}
result
}
pub fn cells_in_value_area(&self) -> Vec<&FootprintCell> {
if let Some((va_low, va_high)) = self.val_area(0.70) {
self.cells
.iter()
.filter(|c| c.price >= va_low && c.price <= va_high)
.collect()
} else {
Vec::new()
}
}
pub fn is_poc(&self, price: f64, tick_size: f64) -> bool {
self.poc_price
.map(|poc| (poc - price).abs() < tick_size * 0.5)
.unwrap_or(false)
}
}
#[derive(Debug, Clone)]
pub struct FootprintConfig {
pub tick_size: f64,
pub show_delta: bool,
pub show_poc: bool,
pub show_value_area: bool,
pub val_area_pct: f64,
pub imbalance_threshold: f64,
pub show_stacked_imbalances: bool,
pub stacked_min_cnt: usize,
pub color_mode: FootprintColorMode,
pub display_mode: FootprintDisplayMode,
pub tick_grouping: TickGrouping,
pub min_cell_height: f32,
pub diagonal_imbalance_threshold: f64,
pub show_cumulative_delta_line: bool,
pub show_delta_histogram: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FootprintColorMode {
Delta,
Volume,
Imbalance,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FootprintDisplayMode {
#[default]
BidAskSplit,
VolumeOnly,
DeltaOnly,
DeltaPlusVolume,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum TickGrouping {
#[default]
Automatic,
Manual { ticks_per_level: usize },
FixedInterval { interval: f64 },
BarBased { levels_per_bar: usize },
}
#[derive(Debug, Clone)]
pub struct DiagonalImbalance {
pub price: f64,
pub volume: f64,
pub is_buying: bool,
pub ratio: f64,
}
impl Default for FootprintConfig {
fn default() -> Self {
Self {
tick_size: 1.0,
show_delta: true,
show_poc: true,
show_value_area: true,
val_area_pct: 0.70,
imbalance_threshold: 0.75, show_stacked_imbalances: true,
stacked_min_cnt: 3,
color_mode: FootprintColorMode::Delta,
display_mode: FootprintDisplayMode::default(),
tick_grouping: TickGrouping::default(),
min_cell_height: 14.0, diagonal_imbalance_threshold: 3.0, show_cumulative_delta_line: false,
show_delta_histogram: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_bar() -> FootprintBar {
let mut bar = FootprintBar::new(Utc::now(), 1.0);
bar.add_trade(100.0, 10.0, true, 1.0); bar.add_trade(100.0, 5.0, false, 1.0); bar.add_trade(101.0, 15.0, true, 1.0); bar.add_trade(99.0, 8.0, false, 1.0);
bar
}
#[test]
fn test_footprint_cell() {
let mut cell = FootprintCell::new(100.0);
cell.bid_volume = 5.0;
cell.ask_volume = 15.0;
assert_eq!(cell.delta(), 10.0);
assert_eq!(cell.total_volume(), 20.0);
assert!((cell.imbalance_ratio() - 0.75).abs() < 0.001);
}
#[test]
fn test_footprint_bar_trades() {
let bar = create_test_bar();
assert_eq!(bar.open, 100.0);
assert_eq!(bar.high, 101.0);
assert_eq!(bar.low, 99.0);
assert_eq!(bar.close, 99.0);
assert_eq!(bar.volume, 38.0);
}
#[test]
fn test_footprint_bar_delta() {
let bar = create_test_bar();
assert_eq!(bar.delta, 12.0);
}
#[test]
fn test_footprint_poc() {
let bar = create_test_bar();
assert!(bar.poc_price.is_some());
}
#[test]
fn test_footprint_value_area() {
let bar = create_test_bar();
let va = bar.val_area(0.70);
assert!(va.is_some());
let (low, high) = va.unwrap();
assert!(low <= high);
}
#[test]
fn test_footprint_imbalances() {
let mut bar = FootprintBar::new(Utc::now(), 1.0);
bar.add_trade(100.0, 10.0, true, 1.0);
bar.add_trade(100.0, 1.0, false, 1.0);
let ask_imbalances = bar.ask_imbalances(0.75);
assert!(!ask_imbalances.is_empty());
}
}