Skip to main content

wickra_core/indicators/
inertia.rs

1//! Inertia (Donald Dorsey).
2
3use crate::error::{Error, Result};
4use crate::indicators::linreg::LinearRegression;
5use crate::indicators::rvi::Rvi;
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Donald Dorsey's Inertia — a Linear-Regression-smoothed `RVI` (Relative Vigor
10/// Index). The endpoint of an `n`-bar least-squares fit of the `RVI` series is
11/// taken as the indicator's reading, smoothing the underlying ratio while
12/// preserving its trend direction.
13///
14/// ```text
15/// Inertia_t = LinearRegression(RVI(close - open, high - low; rvi_period), linreg_period)_t
16/// ```
17///
18/// Dorsey's recommended defaults are `(rvi_period = 14, linreg_period = 20)`.
19///
20/// # Example
21///
22/// ```
23/// use wickra_core::{Candle, Indicator, Inertia};
24///
25/// let mut inertia = Inertia::new(14, 20).unwrap();
26/// let mut last = None;
27/// for i in 0..80 {
28///     let o = 100.0 + f64::from(i);
29///     let c = o + 0.5;
30///     let candle = Candle::new(o, c + 0.2, o - 0.2, c, 1.0, i64::from(i)).unwrap();
31///     last = inertia.update(candle);
32/// }
33/// assert!(last.is_some());
34/// ```
35#[derive(Debug, Clone)]
36pub struct Inertia {
37    rvi_period: usize,
38    linreg_period: usize,
39    rvi: Rvi,
40    linreg: LinearRegression,
41}
42
43impl Inertia {
44    /// # Errors
45    /// Returns [`Error::PeriodZero`] if either period is zero.
46    pub fn new(rvi_period: usize, linreg_period: usize) -> Result<Self> {
47        if rvi_period == 0 || linreg_period == 0 {
48            return Err(Error::PeriodZero);
49        }
50        Ok(Self {
51            rvi_period,
52            linreg_period,
53            rvi: Rvi::new(rvi_period)?,
54            linreg: LinearRegression::new(linreg_period)?,
55        })
56    }
57
58    /// Dorsey's recommended defaults `(rvi_period = 14, linreg_period = 20)`.
59    pub fn classic() -> Self {
60        Self::new(14, 20).expect("classic Inertia parameters are valid")
61    }
62
63    /// Configured `(rvi_period, linreg_period)`.
64    pub const fn periods(&self) -> (usize, usize) {
65        (self.rvi_period, self.linreg_period)
66    }
67}
68
69impl Indicator for Inertia {
70    type Input = Candle;
71    type Output = f64;
72
73    #[inline]
74    fn update(&mut self, candle: Candle) -> Option<f64> {
75        let rvi = self.rvi.update(candle)?;
76        self.linreg.update(rvi)
77    }
78
79    fn reset(&mut self) {
80        self.rvi.reset();
81        self.linreg.reset();
82    }
83
84    #[inline]
85    fn warmup_period(&self) -> usize {
86        // RVI emits at `rvi_period` candles; the LinearRegression then needs
87        // `linreg_period − 1` more RVI values to fill its window.
88        self.rvi_period + self.linreg_period - 1
89    }
90
91    #[inline]
92    fn is_ready(&self) -> bool {
93        self.linreg.is_ready()
94    }
95
96    #[inline]
97    fn name(&self) -> &'static str {
98        "Inertia"
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::traits::BatchExt;
106    use approx::assert_relative_eq;
107
108    fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
109        Candle::new(open, high, low, close, 1.0, ts).unwrap()
110    }
111
112    #[test]
113    fn rejects_zero_period() {
114        assert!(matches!(Inertia::new(0, 20), Err(Error::PeriodZero)));
115        assert!(matches!(Inertia::new(14, 0), Err(Error::PeriodZero)));
116    }
117
118    #[test]
119    fn accessors_and_metadata() {
120        let inertia = Inertia::classic();
121        assert_eq!(inertia.periods(), (14, 20));
122        assert_eq!(inertia.warmup_period(), 33);
123        assert_eq!(inertia.name(), "Inertia");
124    }
125
126    #[test]
127    fn classic_factory() {
128        assert_eq!(Inertia::classic().periods(), (14, 20));
129    }
130
131    #[test]
132    fn warmup_emits_first_value_at_warmup_period() {
133        // Smaller periods for a fast test: RVI(3) emits at 3 candles, then
134        // LinReg(4) needs 4 RVI values -> total 3 + 4 - 1 = 6.
135        let mut inertia = Inertia::new(3, 4).unwrap();
136        assert_eq!(inertia.warmup_period(), 6);
137        for i in 0..5 {
138            assert_eq!(inertia.update(candle(10.0, 11.0, 9.0, 10.5, i)), None);
139        }
140        assert!(inertia.update(candle(10.0, 11.0, 9.0, 10.5, 5)).is_some());
141    }
142
143    #[test]
144    fn constant_rvi_yields_constant_inertia() {
145        // Every bar identical -> RVI is constant -> LinReg of a constant
146        // series equals that constant after warmup.
147        let mut inertia = Inertia::new(3, 4).unwrap();
148        let mut last = None;
149        for i in 0..40 {
150            last = inertia.update(candle(10.0, 11.0, 9.0, 10.5, i));
151        }
152        // RVI = SMA(c-o, 3) / SMA(h-l, 3) = 0.5 / 2.0 = 0.25 on every bar.
153        let v = last.unwrap();
154        assert_relative_eq!(v, 0.25, epsilon = 1e-12);
155    }
156
157    #[test]
158    fn batch_equals_streaming() {
159        let candles: Vec<Candle> = (0..80_i64)
160            .map(|i| {
161                let o = 100.0 + (i as f64 * 0.3).sin() * 5.0;
162                let c = o + (i as f64 * 0.1).cos();
163                candle(o, o.max(c) + 0.5, o.min(c) - 0.5, c, i)
164            })
165            .collect();
166        let batch = Inertia::classic().batch(&candles);
167        let mut b = Inertia::classic();
168        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
169        assert_eq!(batch, streamed);
170    }
171
172    #[test]
173    fn reset_clears_state() {
174        let mut inertia = Inertia::classic();
175        for i in 0..50 {
176            inertia.update(candle(10.0, 11.0, 9.0, 10.5, i));
177        }
178        assert!(inertia.is_ready());
179        inertia.reset();
180        assert!(!inertia.is_ready());
181        assert_eq!(inertia.update(candle(10.0, 11.0, 9.0, 10.5, 0)), None);
182    }
183}