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>(params: &HashMap<String, String, S>) -> Result<Box<dyn Indicator>, IndicatorError> {
94    let period = param_usize(params, "period", 50)?;
95    let n_bins = param_usize(params, "n_bins", 20)?;
96    Ok(Box::new(LiquidityIndicator::new(LiquidityParams {
97        period,
98        n_bins,
99    })))
100}
101
102/// Rolling volume-profile liquidity tracker.
103#[derive(Debug)]
104pub struct LiquidityProfile {
105    period: usize,
106    n_bins: usize,
107    candles: VecDeque<Candle>,
108
109    pub poc_price: Option<f64>,
110    pub vah: Option<f64>,
111    pub val: Option<f64>,
112    pub buy_liq: f64,
113    pub sell_liq: f64,
114    pub imbalance: f64,
115    pub buy_pct: f64,
116}
117
118impl LiquidityProfile {
119    pub fn new(period: usize, n_bins: usize) -> Self {
120        Self {
121            period,
122            n_bins,
123            candles: VecDeque::with_capacity(period),
124            poc_price: None,
125            vah: None,
126            val: None,
127            buy_liq: 0.0,
128            sell_liq: 0.0,
129            imbalance: 0.0,
130            buy_pct: 0.5,
131        }
132    }
133
134    pub fn update(&mut self, candle: &Candle) {
135        if self.candles.len() == self.period {
136            self.candles.pop_front();
137        }
138        self.candles.push_back(candle.clone());
139
140        if self.candles.len() < 5 {
141            return;
142        }
143
144        let h: f64 = self
145            .candles
146            .iter()
147            .map(|c| c.high)
148            .fold(f64::NEG_INFINITY, f64::max);
149        let l: f64 = self
150            .candles
151            .iter()
152            .map(|c| c.low)
153            .fold(f64::INFINITY, f64::min);
154        let rng = h - l;
155        if rng <= 0.0 {
156            return;
157        }
158
159        let step = rng / self.n_bins as f64;
160        let mut bins = vec![0.0_f64; self.n_bins];
161
162        for c in &self.candles {
163            let bar_rng = c.high - c.low;
164            if bar_rng <= 0.0 || c.volume <= 0.0 {
165                continue;
166            }
167            #[allow(clippy::needless_range_loop)]
168            for i in 0..self.n_bins {
169                let bin_lo = l + step * i as f64;
170                let bin_hi = bin_lo + step;
171                let overlap = c.high.min(bin_hi) - c.low.max(bin_lo);
172                if overlap > 0.0 {
173                    bins[i] += c.volume * overlap / bar_rng;
174                }
175            }
176        }
177
178        // Point of Control
179        let poc_idx = bins
180            .iter()
181            .enumerate()
182            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
183            .map_or(0, |(i, _)| i);
184        self.poc_price = Some(l + step * poc_idx as f64 + step / 2.0);
185
186        // Value Area (70% of volume around POC)
187        let total_vol: f64 = bins.iter().sum();
188        let target = total_vol * 0.70;
189        let mut area_vol = bins[poc_idx];
190        let mut upper = poc_idx;
191        let mut lower = poc_idx;
192
193        while area_vol < target {
194            let can_up = upper + 1 < self.n_bins;
195            let can_down = lower > 0;
196            if !can_up && !can_down {
197                break;
198            }
199            let vol_up = if can_up { bins[upper + 1] } else { -1.0 };
200            let vol_down = if can_down { bins[lower - 1] } else { -1.0 };
201            if vol_up >= vol_down {
202                upper += 1;
203                area_vol += bins[upper];
204            } else {
205                lower -= 1;
206                area_vol += bins[lower];
207            }
208        }
209
210        self.vah = Some(l + step * upper as f64 + step / 2.0);
211        self.val = Some(l + step * lower as f64 + step / 2.0);
212
213        // Buy / sell liquidity split around close
214        let cl = candle.close;
215        self.buy_liq = (0..self.n_bins)
216            .map(|i| {
217                if l + step * i as f64 + step / 2.0 < cl {
218                    bins[i]
219                } else {
220                    0.0
221                }
222            })
223            .sum();
224        self.sell_liq = (0..self.n_bins)
225            .map(|i| {
226                if l + step * i as f64 + step / 2.0 >= cl {
227                    bins[i]
228                } else {
229                    0.0
230                }
231            })
232            .sum();
233
234        let total = self.buy_liq + self.sell_liq;
235        self.buy_pct = if total > 0.0 {
236            self.buy_liq / total
237        } else {
238            0.5
239        };
240        self.imbalance = self.buy_liq - self.sell_liq;
241    }
242
243    pub fn bullish(&self) -> bool {
244        self.imbalance > 0.0
245    }
246}