Skip to main content

wickra_core/indicators/
intraday_intensity.rs

1//! Intraday Intensity (Bostian) — the per-bar volume-weighted close-location.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Intraday Intensity — David Bostian's per-bar measure that weights each bar's
7/// volume by where the close lands inside the bar's range:
8///
9/// ```text
10/// II_t = volume * (2*close − high − low) / (high − low)   (0 if high == low)
11/// ```
12///
13/// The fraction `(2*close − high − low) / (high − low)` is `+1` when the bar
14/// closes on its high, `−1` when it closes on its low, and `0` at the midpoint,
15/// so `II_t` is the volume pushed toward the extremes on that single bar —
16/// Bostian's proxy for per-bar accumulation (positive) or distribution
17/// (negative).
18///
19/// This emits the **raw per-bar** intensity, which is distinct from the two
20/// derived forms Wickra ships separately: the **cumulative** running total is
21/// the Accumulation/Distribution Line ([`Adl`](crate::Adl)), and the
22/// volume-normalized windowed form ("Intraday Intensity %") is mathematically
23/// the Chaikin Money Flow ([`Cmf`](crate::Cmf)). A doji whose `high == low`
24/// contributes nothing. Each `update` is O(1) and the first bar already emits a
25/// value.
26///
27/// # Example
28///
29/// ```
30/// use wickra_core::{Candle, Indicator, IntradayIntensity};
31///
32/// let mut indicator = IntradayIntensity::new();
33/// let mut last = None;
34/// for i in 0..20 {
35///     let base = 100.0 + f64::from(i);
36///     let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.9, 1_000.0, 0).unwrap();
37///     last = indicator.update(c);
38/// }
39/// assert!(last.is_some());
40/// ```
41#[derive(Debug, Clone, Default)]
42pub struct IntradayIntensity {
43    last: Option<f64>,
44}
45
46impl IntradayIntensity {
47    /// Construct a new Intraday Intensity. It is parameter-free.
48    #[must_use]
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Current value if available.
54    pub const fn value(&self) -> Option<f64> {
55        self.last
56    }
57}
58
59impl Indicator for IntradayIntensity {
60    type Input = Candle;
61    type Output = f64;
62
63    fn update(&mut self, candle: Candle) -> Option<f64> {
64        let range = candle.high - candle.low;
65        let ii = if range > 0.0 {
66            candle.volume * (2.0 * candle.close - candle.high - candle.low) / range
67        } else {
68            0.0
69        };
70        self.last = Some(ii);
71        Some(ii)
72    }
73
74    fn reset(&mut self) {
75        self.last = None;
76    }
77
78    fn warmup_period(&self) -> usize {
79        1
80    }
81
82    fn is_ready(&self) -> bool {
83        self.last.is_some()
84    }
85
86    fn name(&self) -> &'static str {
87        "IntradayIntensity"
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::traits::BatchExt;
95    use approx::assert_relative_eq;
96
97    fn candle(high: f64, low: f64, close: f64, volume: f64) -> Candle {
98        Candle::new_unchecked(low, high, low, close, volume, 0)
99    }
100
101    #[test]
102    fn accessors_and_metadata() {
103        let iii = IntradayIntensity::new();
104        assert_eq!(iii.warmup_period(), 1);
105        assert_eq!(iii.name(), "IntradayIntensity");
106        assert!(!iii.is_ready());
107        assert_eq!(iii.value(), None);
108    }
109
110    #[test]
111    fn first_bar_emits() {
112        // close at the high: (2*101 - 102 - 100)/(2) = 0/... wait, high=102 low=100 close=101 -> 0.
113        let mut iii = IntradayIntensity::new();
114        // close on the high -> +1 * volume.
115        let v = iii.update(candle(102.0, 100.0, 102.0, 500.0)).unwrap();
116        assert_relative_eq!(v, 500.0, epsilon = 1e-9);
117    }
118
119    #[test]
120    fn close_on_high_adds_full_volume() {
121        let mut iii = IntradayIntensity::new();
122        let v = iii.update(candle(110.0, 100.0, 110.0, 1_000.0)).unwrap();
123        assert_relative_eq!(v, 1_000.0, epsilon = 1e-9);
124    }
125
126    #[test]
127    fn close_on_low_subtracts_full_volume() {
128        let mut iii = IntradayIntensity::new();
129        let v = iii.update(candle(110.0, 100.0, 100.0, 1_000.0)).unwrap();
130        assert_relative_eq!(v, -1_000.0, epsilon = 1e-9);
131    }
132
133    #[test]
134    fn close_at_midpoint_adds_nothing() {
135        let mut iii = IntradayIntensity::new();
136        let v = iii.update(candle(110.0, 100.0, 105.0, 1_000.0)).unwrap();
137        assert_relative_eq!(v, 0.0, epsilon = 1e-12);
138    }
139
140    #[test]
141    fn zero_range_adds_nothing() {
142        let mut iii = IntradayIntensity::new();
143        let v = iii.update(candle(100.0, 100.0, 100.0, 1_000.0)).unwrap();
144        assert_relative_eq!(v, 0.0, epsilon = 1e-12);
145    }
146
147    #[test]
148    fn each_bar_is_independent() {
149        // Per-bar (non-cumulative): each output depends only on that bar, so a
150        // close-on-high +1000 bar is not carried into the next close-on-low bar.
151        let mut iii = IntradayIntensity::new();
152        let a = iii.update(candle(110.0, 100.0, 110.0, 1_000.0)).unwrap();
153        let b = iii.update(candle(110.0, 100.0, 100.0, 400.0)).unwrap();
154        assert_relative_eq!(a, 1_000.0, epsilon = 1e-9);
155        assert_relative_eq!(b, -400.0, epsilon = 1e-9);
156    }
157
158    #[test]
159    fn reset_clears_state() {
160        let mut iii = IntradayIntensity::new();
161        iii.batch(&[
162            candle(110.0, 100.0, 108.0, 1.0),
163            candle(110.0, 100.0, 102.0, 1.0),
164        ]);
165        assert!(iii.is_ready());
166        iii.reset();
167        assert!(!iii.is_ready());
168        assert_eq!(iii.value(), None);
169    }
170
171    #[test]
172    fn batch_equals_streaming() {
173        let candles: Vec<Candle> = (0..80)
174            .map(|i| {
175                let base = 100.0 + (f64::from(i) * 0.3).sin() * 6.0;
176                candle(base + 2.0, base - 2.0, base + 0.7, 1_000.0 + f64::from(i))
177            })
178            .collect();
179        let batch = IntradayIntensity::new().batch(&candles);
180        let mut b = IntradayIntensity::new();
181        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
182        assert_eq!(batch, streamed);
183    }
184}