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