Skip to main content

indicators/signal/
liquidity.rs

1//! Layer 5 — Liquidity Thermal Map.
2//!
3//! Rolling volume profile with configurable price bins. Tracks the Point of Control
4//! (POC), Value Area High/Low, and buy/sell liquidity imbalance.
5
6use std::collections::{HashMap, VecDeque};
7
8use crate::error::IndicatorError;
9use crate::indicator::{Indicator, IndicatorOutput};
10use crate::registry::param_usize;
11use crate::types::Candle;
12
13// ── Params ────────────────────────────────────────────────────────────────────
14
15#[derive(Debug, Clone)]
16pub struct LiquidityParams {
17    /// Number of candles in the rolling volume-profile window.
18    pub period: usize,
19    /// Number of price bins in the volume profile.
20    pub n_bins: usize,
21}
22
23impl Default for LiquidityParams {
24    fn default() -> Self {
25        Self {
26            period: 50,
27            n_bins: 20,
28        }
29    }
30}
31
32// ── Indicator wrapper ─────────────────────────────────────────────────────────
33
34/// Batch `Indicator` adapter for [`LiquidityProfile`].
35///
36/// Replays candles through the rolling volume profile and emits per-bar:
37/// `liq_poc`, `liq_buy_pct`, `liq_imbalance`, `liq_vah`, `liq_val`.
38#[derive(Debug, Clone)]
39pub struct LiquidityIndicator {
40    pub params: LiquidityParams,
41}
42
43impl LiquidityIndicator {
44    pub fn new(params: LiquidityParams) -> Self {
45        Self { params }
46    }
47    pub fn with_defaults() -> Self {
48        Self::new(LiquidityParams::default())
49    }
50}
51
52impl Indicator for LiquidityIndicator {
53    fn name(&self) -> &'static str {
54        "Liquidity"
55    }
56    fn required_len(&self) -> usize {
57        self.params.period
58    }
59    fn required_columns(&self) -> &[&'static str] {
60        &["high", "low", "close", "volume"]
61    }
62
63    fn calculate(&self, candles: &[Candle]) -> Result<IndicatorOutput, IndicatorError> {
64        self.check_len(candles)?;
65        let p = &self.params;
66        let mut liq = LiquidityProfile::new(p.period, p.n_bins);
67        let n = candles.len();
68        let mut poc = vec![f64::NAN; n];
69        let mut buy_pct = vec![f64::NAN; n];
70        let mut imbalance = vec![f64::NAN; n];
71        let mut vah = vec![f64::NAN; n];
72        let mut val = vec![f64::NAN; n];
73        for (i, c) in candles.iter().enumerate() {
74            liq.update(c);
75            poc[i] = liq.poc_price.unwrap_or(f64::NAN);
76            buy_pct[i] = liq.buy_pct;
77            imbalance[i] = liq.imbalance;
78            vah[i] = liq.vah.unwrap_or(f64::NAN);
79            val[i] = liq.val.unwrap_or(f64::NAN);
80        }
81        Ok(IndicatorOutput::from_pairs([
82            ("liq_poc", poc),
83            ("liq_buy_pct", buy_pct),
84            ("liq_imbalance", imbalance),
85            ("liq_vah", vah),
86            ("liq_val", val),
87        ]))
88    }
89}
90
91// ── Registry factory ──────────────────────────────────────────────────────────
92
93pub fn factory<S: ::std::hash::BuildHasher>(
94    params: &HashMap<String, String, S>,
95) -> Result<Box<dyn Indicator>, IndicatorError> {
96    let period = param_usize(params, "period", 50)?;
97    let n_bins = param_usize(params, "n_bins", 20)?;
98    Ok(Box::new(LiquidityIndicator::new(LiquidityParams {
99        period,
100        n_bins,
101    })))
102}
103
104/// Rolling volume-profile liquidity tracker.
105#[derive(Debug)]
106pub struct LiquidityProfile {
107    period: usize,
108    n_bins: usize,
109    candles: VecDeque<Candle>,
110
111    pub poc_price: Option<f64>,
112    pub vah: Option<f64>,
113    pub val: Option<f64>,
114    pub buy_liq: f64,
115    pub sell_liq: f64,
116    pub imbalance: f64,
117    pub buy_pct: f64,
118}
119
120impl LiquidityProfile {
121    pub fn new(period: usize, n_bins: usize) -> Self {
122        Self {
123            period,
124            n_bins,
125            candles: VecDeque::with_capacity(period),
126            poc_price: None,
127            vah: None,
128            val: None,
129            buy_liq: 0.0,
130            sell_liq: 0.0,
131            imbalance: 0.0,
132            buy_pct: 0.5,
133        }
134    }
135
136    pub fn update(&mut self, candle: &Candle) {
137        if self.candles.len() == self.period {
138            self.candles.pop_front();
139        }
140        self.candles.push_back(candle.clone());
141
142        if self.candles.len() < 5 {
143            return;
144        }
145
146        let h: f64 = self
147            .candles
148            .iter()
149            .map(|c| c.high)
150            .fold(f64::NEG_INFINITY, f64::max);
151        let l: f64 = self
152            .candles
153            .iter()
154            .map(|c| c.low)
155            .fold(f64::INFINITY, f64::min);
156        let rng = h - l;
157        if rng <= 0.0 {
158            return;
159        }
160
161        let step = rng / self.n_bins as f64;
162        let mut bins = vec![0.0_f64; self.n_bins];
163
164        for c in &self.candles {
165            let bar_rng = c.high - c.low;
166            if bar_rng <= 0.0 || c.volume <= 0.0 {
167                continue;
168            }
169            #[allow(clippy::needless_range_loop)]
170            for i in 0..self.n_bins {
171                let bin_lo = l + step * i as f64;
172                let bin_hi = bin_lo + step;
173                let overlap = c.high.min(bin_hi) - c.low.max(bin_lo);
174                if overlap > 0.0 {
175                    bins[i] += c.volume * overlap / bar_rng;
176                }
177            }
178        }
179
180        // Point of Control
181        let poc_idx = bins
182            .iter()
183            .enumerate()
184            .max_by(|a, b| a.1.total_cmp(b.1))
185            .map_or(0, |(i, _)| i);
186        self.poc_price = Some(l + step * poc_idx as f64 + step / 2.0);
187
188        // Value Area (70% of volume around POC)
189        let total_vol: f64 = bins.iter().sum();
190        let target = total_vol * 0.70;
191        let mut area_vol = bins[poc_idx];
192        let mut upper = poc_idx;
193        let mut lower = poc_idx;
194
195        while area_vol < target {
196            let can_up = upper + 1 < self.n_bins;
197            let can_down = lower > 0;
198            if !can_up && !can_down {
199                break;
200            }
201            let vol_up = if can_up { bins[upper + 1] } else { -1.0 };
202            let vol_down = if can_down { bins[lower - 1] } else { -1.0 };
203            if vol_up >= vol_down {
204                upper += 1;
205                area_vol += bins[upper];
206            } else {
207                lower -= 1;
208                area_vol += bins[lower];
209            }
210        }
211
212        self.vah = Some(l + step * upper as f64 + step / 2.0);
213        self.val = Some(l + step * lower as f64 + step / 2.0);
214
215        // Buy / sell liquidity split around close
216        let cl = candle.close;
217        self.buy_liq = (0..self.n_bins)
218            .map(|i| {
219                if l + step * i as f64 + step / 2.0 < cl {
220                    bins[i]
221                } else {
222                    0.0
223                }
224            })
225            .sum();
226        self.sell_liq = (0..self.n_bins)
227            .map(|i| {
228                if l + step * i as f64 + step / 2.0 >= cl {
229                    bins[i]
230                } else {
231                    0.0
232                }
233            })
234            .sum();
235
236        let total = self.buy_liq + self.sell_liq;
237        self.buy_pct = if total > 0.0 {
238            self.buy_liq / total
239        } else {
240            0.5
241        };
242        self.imbalance = self.buy_liq - self.sell_liq;
243    }
244
245    pub fn bullish(&self) -> bool {
246        self.imbalance > 0.0
247    }
248}