Skip to main content

kestrel_chartkit/indicator/
buy_sell_pressure.rs

1use super::smoothing::Ema;
2use super::{Indicator, IndicatorAlert, IndicatorOutput};
3use crate::model::Bar;
4use std::collections::HashMap;
5
6/// Buy/Sell Pressure: where bars close within their range and which wick dominates, smoothed,
7/// `-100..=100`.
8///
9/// Per bar, with `range = max(high - low, 1e-8)`:
10///
11/// - `location = 2 · (close - low) / range - 1`: `-1` at the low, `+1` at the high;
12/// - `wick_balance = (lower_wick - upper_wick) / range`, where `upper_wick = high - max(open,
13///   close)` and `lower_wick = min(open, close) - low`, a body outside the range clipped to it;
14/// - `raw = (0.6 · location + 0.4 · wick_balance) · 100`.
15///
16/// `value = clamp(EMA(period)(raw), -100, 100)` with the EMA seeded by the first bar's raw value
17/// (see [`Ema`]), so the first output comes with the first bar, although `warmup_period()`
18/// reports `period`. `extra["location"]` and `extra["wick_balance"]` are the current bar's,
19/// unsmoothed. Volume does not enter.
20#[derive(Debug, Clone)]
21pub struct BuySellPressureEstimator {
22    period: usize,
23    ema: Ema,
24}
25
26impl BuySellPressureEstimator {
27    pub fn new(period: usize) -> Self {
28        Self {
29            period: period.max(1),
30            ema: Ema::new(period),
31        }
32    }
33
34    pub fn with_defaults() -> Self {
35        Self::new(14)
36    }
37}
38
39impl Indicator for BuySellPressureEstimator {
40    fn name(&self) -> &str {
41        "buy_sell_pressure"
42    }
43
44    fn warmup_period(&self) -> usize {
45        self.period
46    }
47
48    fn reset(&mut self) {
49        self.ema.reset();
50    }
51
52    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
53        let range = (bar.high - bar.low).max(1e-8);
54
55        // Location of close within range (-1.0 .. +1.0)
56        let location = (2.0 * (bar.close - bar.low) / range) - 1.0;
57
58        // Upper wick vs Lower wick ratio
59        let upper_wick = bar.high - bar.high.min(bar.open.max(bar.close));
60        let lower_wick = bar.low.max(bar.open.min(bar.close)) - bar.low;
61        let wick_balance = (lower_wick - upper_wick) / range;
62
63        // Raw pressure per bar
64        let raw_pressure = (location * 0.6 + wick_balance * 0.4) * 100.0;
65        let smoothed_pressure = self.ema.update(raw_pressure)?.clamp(-100.0, 100.0);
66
67        let mut extra = HashMap::new();
68        extra.insert("location".to_string(), location);
69        extra.insert("wick_balance".to_string(), wick_balance);
70
71        Some(IndicatorOutput::with_extra(smoothed_pressure, extra))
72    }
73
74    fn alerts(&self) -> Vec<IndicatorAlert> {
75        Vec::new()
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn test_buy_sell_pressure() {
85        let mut bsp = BuySellPressureEstimator::with_defaults();
86        let mut out = None;
87        for i in 0..20 {
88            let b = Bar::new(i, 100.0, 105.0, 95.0, 104.0, 1000.0);
89            out = bsp.on_bar(&b);
90        }
91        assert!(out.is_some());
92        let val = out.unwrap().value;
93        assert!((-100.0..=100.0).contains(&val));
94    }
95}