Skip to main content

wickra_core/indicators/
intraday_intensity.rs

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