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 ([`ChaikinMoneyFlow`](crate::ChaikinMoneyFlow)). 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    #[inline]
64    fn update(&mut self, candle: Candle) -> Option<f64> {
65        let range = candle.high - candle.low;
66        let ii = if range > 0.0 {
67            candle.volume * (2.0 * candle.close - candle.high - candle.low) / range
68        } else {
69            0.0
70        };
71        self.last = Some(ii);
72        Some(ii)
73    }
74
75    fn reset(&mut self) {
76        self.last = None;
77    }
78
79    #[inline]
80    fn warmup_period(&self) -> usize {
81        1
82    }
83
84    #[inline]
85    fn is_ready(&self) -> bool {
86        self.last.is_some()
87    }
88
89    #[inline]
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 each_bar_is_independent() {
153        // Per-bar (non-cumulative): each output depends only on that bar, so a
154        // close-on-high +1000 bar is not carried into the next close-on-low bar.
155        let mut iii = IntradayIntensity::new();
156        let a = iii.update(candle(110.0, 100.0, 110.0, 1_000.0)).unwrap();
157        let b = iii.update(candle(110.0, 100.0, 100.0, 400.0)).unwrap();
158        assert_relative_eq!(a, 1_000.0, epsilon = 1e-9);
159        assert_relative_eq!(b, -400.0, epsilon = 1e-9);
160    }
161
162    #[test]
163    fn reset_clears_state() {
164        let mut iii = IntradayIntensity::new();
165        iii.batch(&[
166            candle(110.0, 100.0, 108.0, 1.0),
167            candle(110.0, 100.0, 102.0, 1.0),
168        ]);
169        assert!(iii.is_ready());
170        iii.reset();
171        assert!(!iii.is_ready());
172        assert_eq!(iii.value(), None);
173    }
174
175    #[test]
176    fn batch_equals_streaming() {
177        let candles: Vec<Candle> = (0..80)
178            .map(|i| {
179                let base = 100.0 + (f64::from(i) * 0.3).sin() * 6.0;
180                candle(base + 2.0, base - 2.0, base + 0.7, 1_000.0 + f64::from(i))
181            })
182            .collect();
183        let batch = IntradayIntensity::new().batch(&candles);
184        let mut b = IntradayIntensity::new();
185        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
186        assert_eq!(batch, streamed);
187    }
188}