use crate::model::Bar;
use crate::studies::{Indicator, IndicatorValue};
use egui::Color32;
use std::collections::HashMap;
#[derive(Clone)]
pub struct LiquidityTracker {
period: usize,
tick_size: f64,
volume_threshold: f64,
values: Vec<IndicatorValue>,
high_liq_color: Color32,
sweep_color: Color32,
visible: bool,
liquidity_map: HashMap<i64, LiquidityLevel>,
zones: Vec<LiquidityZone>,
sweeps: Vec<LiquiditySweep>,
}
#[derive(Debug, Clone)]
pub struct LiquidityLevel {
pub price: f64,
pub volume: f64,
pub touches: usize,
pub last_touch: usize,
pub is_swing_level: bool,
pub side_bias: f64,
}
#[derive(Debug, Clone)]
pub struct LiquidityZone {
pub price_low: f64,
pub price_high: f64,
pub total_volume: f64,
pub avg_touches: f64,
pub zone_type: LiquidityZoneType,
pub start_idx: usize,
pub end_idx: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LiquidityZoneType {
Accumulation,
Distribution,
Neutral,
SwingHigh,
SwingLow,
}
#[derive(Debug, Clone)]
pub struct LiquiditySweep {
pub bar_idx: usize,
pub swept_price: f64,
pub swept_volume: f64,
pub direction: SweepDirection,
pub is_clean: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SweepDirection {
SweepLows,
SweepHighs,
}
impl LiquidityTracker {
pub fn new(period: usize) -> Self {
Self {
period,
tick_size: 1.0,
volume_threshold: 2.0, values: Vec::new(),
high_liq_color: Color32::from_rgba_unmultiplied(255, 215, 0, 128), sweep_color: Color32::from_rgba_unmultiplied(255, 0, 255, 180), visible: true,
liquidity_map: HashMap::new(),
zones: Vec::new(),
sweeps: Vec::new(),
}
}
pub fn with_tick_size(mut self, tick_size: f64) -> Self {
self.tick_size = tick_size;
self
}
pub fn with_volume_threshold(mut self, threshold: f64) -> Self {
self.volume_threshold = threshold;
self
}
pub fn with_colors(mut self, high_liq: Color32, sweep: Color32) -> Self {
self.high_liq_color = high_liq;
self.sweep_color = sweep;
self
}
pub fn zones(&self) -> &[LiquidityZone] {
&self.zones
}
pub fn sweeps(&self) -> &[LiquiditySweep] {
&self.sweeps
}
pub fn liquidity_at(&self, price: f64) -> Option<&LiquidityLevel> {
let key = self.price_to_key(price);
self.liquidity_map.get(&key)
}
pub fn significant_levels(&self, avg_volume: f64) -> Vec<&LiquidityLevel> {
let threshold = avg_volume * self.volume_threshold;
self.liquidity_map
.values()
.filter(|l| l.volume >= threshold)
.collect()
}
fn price_to_key(&self, price: f64) -> i64 {
(price / self.tick_size).round() as i64
}
fn key_to_price(&self, key: i64) -> f64 {
key as f64 * self.tick_size
}
fn add_volume(&mut self, price: f64, volume: f64, bar_idx: usize, is_buy: bool) {
let key = self.price_to_key(price);
let price_at_key = self.key_to_price(key);
let level = self
.liquidity_map
.entry(key)
.or_insert_with(|| LiquidityLevel {
price: price_at_key,
volume: 0.0,
touches: 0,
last_touch: bar_idx,
is_swing_level: false,
side_bias: 0.0,
});
level.volume += volume;
level.touches += 1;
level.last_touch = bar_idx;
level.side_bias += if is_buy { volume } else { -volume };
}
fn decay_old_liquidity(&mut self, current_idx: usize) {
let decay_threshold = current_idx.saturating_sub(self.period);
self.liquidity_map
.retain(|_, level| level.last_touch > decay_threshold);
}
fn check_sweep(&mut self, bar: &Bar, bar_idx: usize, avg_volume: f64) {
let threshold = avg_volume * self.volume_threshold;
let low_key = self.price_to_key(bar.low);
for (key, level) in &self.liquidity_map {
if *key <= low_key && level.volume >= threshold {
let is_clean = bar.close > level.price;
self.sweeps.push(LiquiditySweep {
bar_idx,
swept_price: level.price,
swept_volume: level.volume,
direction: SweepDirection::SweepLows,
is_clean,
});
}
}
let high_key = self.price_to_key(bar.high);
for (key, level) in &self.liquidity_map {
if *key >= high_key && level.volume >= threshold {
let is_clean = bar.close < level.price;
self.sweeps.push(LiquiditySweep {
bar_idx,
swept_price: level.price,
swept_volume: level.volume,
direction: SweepDirection::SweepHighs,
is_clean,
});
}
}
}
fn identify_zones(&mut self, avg_volume: f64) {
self.zones.clear();
let threshold = avg_volume * self.volume_threshold;
let mut significant: Vec<_> = self
.liquidity_map
.values()
.filter(|l| l.volume >= threshold)
.cloned()
.collect();
significant.sort_by(|a, b| a.price.partial_cmp(&b.price).unwrap());
if significant.is_empty() {
return;
}
let mut current_zone_start = 0;
let zone_gap = self.tick_size * 3.0;
for i in 1..significant.len() {
let gap = significant[i].price - significant[i - 1].price;
if gap > zone_gap {
self.create_zone(&significant[current_zone_start..i]);
current_zone_start = i;
}
}
self.create_zone(&significant[current_zone_start..]);
}
fn create_zone(&mut self, levels: &[LiquidityLevel]) {
if levels.is_empty() {
return;
}
let price_low = levels.iter().map(|l| l.price).fold(f64::MAX, f64::min);
let price_high = levels.iter().map(|l| l.price).fold(f64::MIN, f64::max);
let total_volume: f64 = levels.iter().map(|l| l.volume).sum();
let avg_touches =
levels.iter().map(|l| l.touches).sum::<usize>() as f64 / levels.len() as f64;
let total_bias: f64 = levels.iter().map(|l| l.side_bias).sum();
let zone_type = if total_bias > total_volume * 0.3 {
LiquidityZoneType::Accumulation
} else if total_bias < -total_volume * 0.3 {
LiquidityZoneType::Distribution
} else {
LiquidityZoneType::Neutral
};
let start_idx = levels.iter().map(|l| l.last_touch).min().unwrap_or(0);
let end_idx = levels.iter().map(|l| l.last_touch).max().unwrap_or(0);
self.zones.push(LiquidityZone {
price_low,
price_high,
total_volume,
avg_touches,
zone_type,
start_idx,
end_idx,
});
}
}
impl Default for LiquidityTracker {
fn default() -> Self {
Self::new(20)
}
}
impl Indicator for LiquidityTracker {
fn name(&self) -> &str {
"Liquidity"
}
fn desc(&self) -> &str {
"Liquidity Tracker - Tracks volume accumulation at price levels"
}
fn calculate(&mut self, data: &[Bar]) {
self.values.clear();
self.liquidity_map.clear();
self.zones.clear();
self.sweeps.clear();
if data.is_empty() {
return;
}
let avg_volume: f64 = data.iter().map(|b| b.volume).sum::<f64>() / data.len() as f64;
for (idx, bar) in data.iter().enumerate() {
let is_bullish = bar.close >= bar.open;
let range = bar.high - bar.low;
if range > 0.0 {
let low_key = self.price_to_key(bar.low);
let high_key = self.price_to_key(bar.high);
let levels_count = (high_key - low_key + 1) as f64;
let vol_per_level = bar.volume / levels_count;
for key in low_key..=high_key {
let price = self.key_to_price(key);
self.add_volume(price, vol_per_level, idx, is_bullish);
}
} else {
self.add_volume(bar.close, bar.volume, idx, is_bullish);
}
self.check_sweep(bar, idx, avg_volume);
self.decay_old_liquidity(idx);
let max_liq = self
.liquidity_map
.values()
.map(|l| l.volume)
.fold(0.0_f64, f64::max);
let liquidity_ratio = if avg_volume > 0.0 {
max_liq / avg_volume
} else {
0.0
};
self.values.push(IndicatorValue::Single(liquidity_ratio));
}
self.identify_zones(avg_volume);
}
fn values(&self) -> &[IndicatorValue] {
&self.values
}
fn colors(&self) -> Vec<Color32> {
vec![self.high_liq_color, self.sweep_color]
}
fn set_colors(&mut self, colors: Vec<Color32>) {
if !colors.is_empty() {
self.high_liq_color = colors[0];
}
if colors.len() > 1 {
self.sweep_color = colors[1];
}
}
fn is_overlay(&self) -> bool {
true }
fn is_visible(&self) -> bool {
self.visible
}
fn set_visible(&mut self, visible: bool) {
self.visible = visible;
}
fn clone_box(&self) -> Box<dyn Indicator> {
Box::new(self.clone())
}
fn line_names(&self) -> Vec<String> {
vec!["Liquidity".to_string()]
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn create_test_bars() -> Vec<Bar> {
let ts = Utc::now();
(0..20)
.map(|i| {
let base = 100.0 + (i % 3) as f64; Bar {
time: ts,
open: base,
high: base + 1.0,
low: base - 1.0,
close: base + 0.5,
volume: 1000.0,
}
})
.collect()
}
#[test]
fn test_liquidity_tracking() {
let mut tracker = LiquidityTracker::new(10).with_tick_size(0.5);
let bars = create_test_bars();
tracker.calculate(&bars);
assert_eq!(tracker.values.len(), bars.len());
assert!(!tracker.liquidity_map.is_empty());
}
#[test]
fn test_liquidity_zones() {
let mut tracker = LiquidityTracker::new(10)
.with_tick_size(0.5)
.with_volume_threshold(1.0);
let bars = create_test_bars();
tracker.calculate(&bars);
let _ = tracker.zones();
}
#[test]
fn test_price_key_conversion() {
let tracker = LiquidityTracker::new(10).with_tick_size(0.25);
let price = 100.5;
let key = tracker.price_to_key(price);
let back = tracker.key_to_price(key);
assert!((back - 100.5).abs() < 0.01);
}
}