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
//! Disparity Index.
use crate::error::Result;
use crate::indicators::sma::Sma;
use crate::traits::Indicator;
/// Disparity Index — the percentage gap between price and its moving average.
///
/// ```text
/// Disparity = 100 * (price - SMA(price, period)) / SMA(price, period)
/// ```
///
/// Originating in Japanese technical analysis (*kairi*), the disparity index
/// expresses how far price has stretched from its `period`-bar simple moving
/// average, as a percentage of that average. Positive readings mean price is
/// above the mean (potentially overbought / strong), negative readings mean it
/// is below (potentially oversold / weak); the magnitude measures how
/// over-extended the move is.
///
/// The first output lands once the inner SMA is ready (input `period`). If the
/// moving average is exactly zero the gap percentage is undefined and the index
/// returns `0.0`.
///
/// # Example
///
/// ```
/// use wickra_core::{DisparityIndex, Indicator};
///
/// let mut indicator = DisparityIndex::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct DisparityIndex {
period: usize,
sma: Sma,
}
impl DisparityIndex {
/// Construct a disparity index over `period` inputs.
///
/// # Errors
///
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
period,
sma: Sma::new(period)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for DisparityIndex {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let mean = self.sma.update(input)?;
if mean == 0.0 {
return Some(0.0);
}
Some(100.0 * (input - mean) / mean)
}
fn reset(&mut self) {
self.sma.reset();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.sma.is_ready()
}
fn name(&self) -> &'static str {
"DisparityIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(DisparityIndex::new(0).is_err());
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let di = DisparityIndex::new(14).unwrap();
assert_eq!(di.period(), 14);
assert_eq!(di.warmup_period(), 14);
assert_eq!(di.name(), "DisparityIndex");
}
#[test]
fn warmup_then_known_value() {
// SMA(3) of [2, 4, 6] = 4; price 6 -> 100 * (6 - 4) / 4 = 50.
let mut di = DisparityIndex::new(3).unwrap();
assert_eq!(di.update(2.0), None);
assert_eq!(di.update(4.0), None);
assert_relative_eq!(di.update(6.0).unwrap(), 50.0, epsilon = 1e-12);
}
#[test]
fn constant_series_is_zero() {
// Price equals its own mean -> zero disparity.
let mut di = DisparityIndex::new(5).unwrap();
for v in di.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn negative_when_below_mean() {
// SMA(3) of [10, 8, 6] = 8; price 6 -> 100 * (6 - 8) / 8 = -25.
let mut di = DisparityIndex::new(3).unwrap();
let v = di.batch(&[10.0, 8.0, 6.0]);
assert_relative_eq!(v[2].unwrap(), -25.0, epsilon = 1e-12);
}
#[test]
fn zero_mean_returns_zero() {
// A window summing to zero (mean 0) makes the percentage undefined; the
// index returns 0.0 rather than a non-finite value.
let mut di = DisparityIndex::new(2).unwrap();
assert_eq!(di.update(-3.0), None);
// SMA(2) of [-3, 3] = 0 -> guarded to 0.0.
assert_relative_eq!(di.update(3.0).unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut di = DisparityIndex::new(5).unwrap();
di.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(di.is_ready());
di.reset();
assert!(!di.is_ready());
assert_eq!(di.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=30)
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
let mut a = DisparityIndex::new(7).unwrap();
let mut b = DisparityIndex::new(7).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}