Skip to main content

wickra_core/indicators/
force_index.rs

1//! Force Index (Elder).
2
3use crate::error::Result;
4use crate::indicators::ema::Ema;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8/// Alexander Elder's Force Index — price change scaled by volume, EMA-smoothed.
9///
10/// ```text
11/// raw_t   = (close_t − close_{t−1}) · volume_t
12/// Force_t = EMA(raw, period)_t
13/// ```
14///
15/// The raw force is positive on an up-close and negative on a down-close, and
16/// its magnitude grows with the volume that backed the move — a big move on
17/// heavy volume registers a large force. Smoothing the raw series with an EMA
18/// gives a tradeable line; Elder's classic period is `13`. The first candle
19/// only establishes the previous close, so the first raw value appears on
20/// candle 2 and the first smoothed value on candle `period + 1`.
21///
22/// # Example
23///
24/// ```
25/// use wickra_core::{Candle, Indicator, ForceIndex};
26///
27/// let mut indicator = ForceIndex::new(13).unwrap();
28/// let mut last = None;
29/// for i in 0..80 {
30///     let base = 100.0 + f64::from(i);
31///     let candle =
32///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
33///     last = indicator.update(candle);
34/// }
35/// assert!(last.is_some());
36/// ```
37#[derive(Debug, Clone)]
38pub struct ForceIndex {
39    period: usize,
40    prev_close: Option<f64>,
41    ema: Ema,
42}
43
44impl ForceIndex {
45    /// Construct a new Force Index with the given EMA smoothing period.
46    ///
47    /// # Errors
48    /// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `period == 0`.
49    pub fn new(period: usize) -> Result<Self> {
50        Ok(Self {
51            period,
52            prev_close: None,
53            ema: Ema::new(period)?,
54        })
55    }
56
57    /// Configured smoothing period.
58    pub const fn period(&self) -> usize {
59        self.period
60    }
61}
62
63impl Indicator for ForceIndex {
64    type Input = Candle;
65    type Output = f64;
66
67    fn update(&mut self, candle: Candle) -> Option<f64> {
68        let Some(prev) = self.prev_close else {
69            // The first candle only establishes the previous close.
70            self.prev_close = Some(candle.close);
71            return None;
72        };
73        let raw = (candle.close - prev) * candle.volume;
74        self.prev_close = Some(candle.close);
75        self.ema.update(raw)
76    }
77
78    fn reset(&mut self) {
79        self.prev_close = None;
80        self.ema.reset();
81    }
82
83    fn warmup_period(&self) -> usize {
84        // One seed candle establishes the first previous close, then the EMA
85        // needs `period` raw values.
86        self.period + 1
87    }
88
89    fn is_ready(&self) -> bool {
90        self.ema.is_ready()
91    }
92
93    fn name(&self) -> &'static str {
94        "ForceIndex"
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::traits::BatchExt;
102    use approx::assert_relative_eq;
103
104    fn c(close: f64, volume: f64, ts: i64) -> Candle {
105        Candle::new(close, close, close, close, volume, ts).unwrap()
106    }
107
108    #[test]
109    fn reference_values() {
110        // ForceIndex(1): EMA(1) has alpha = 1, so it passes raw force through.
111        //   candle 1 (close 10) only seeds the previous close -> None.
112        //   candle 2: raw = (12 - 10) * 100 = +200.
113        //   candle 3: raw = (11 - 12) * 200 = -200.
114        let mut fi = ForceIndex::new(1).unwrap();
115        let out = fi.batch(&[c(10.0, 100.0, 0), c(12.0, 100.0, 1), c(11.0, 200.0, 2)]);
116        assert!(out[0].is_none());
117        assert_relative_eq!(out[1].unwrap(), 200.0, epsilon = 1e-9);
118        assert_relative_eq!(out[2].unwrap(), -200.0, epsilon = 1e-9);
119    }
120
121    #[test]
122    fn pure_uptrend_is_positive() {
123        // Strictly rising closes on constant volume -> every raw force is
124        // positive, so the smoothed force is positive too.
125        let candles: Vec<Candle> = (1..40)
126            .map(|i| c(f64::from(i), 100.0, i64::from(i)))
127            .collect();
128        let mut fi = ForceIndex::new(13).unwrap();
129        for v in fi.batch(&candles).into_iter().flatten() {
130            assert!(v > 0.0, "force {v} should be positive in an uptrend");
131        }
132    }
133
134    #[test]
135    fn pure_downtrend_is_negative() {
136        let candles: Vec<Candle> = (1..40)
137            .rev()
138            .map(|i| c(f64::from(i), 100.0, i64::from(i)))
139            .collect();
140        let mut fi = ForceIndex::new(13).unwrap();
141        for v in fi.batch(&candles).into_iter().flatten() {
142            assert!(v < 0.0, "force {v} should be negative in a downtrend");
143        }
144    }
145
146    #[test]
147    fn first_value_on_period_plus_one_candle() {
148        let candles: Vec<Candle> = (0..12).map(|i| c(10.0 + i as f64, 50.0, i)).collect();
149        let mut fi = ForceIndex::new(5).unwrap();
150        let out = fi.batch(&candles);
151        for (i, v) in out.iter().enumerate().take(5) {
152            assert!(v.is_none(), "index {i} must be None during warmup");
153        }
154        assert!(out[5].is_some(), "first force lands at index period");
155        assert_eq!(fi.warmup_period(), 6);
156    }
157
158    #[test]
159    fn rejects_zero_period() {
160        assert!(ForceIndex::new(0).is_err());
161    }
162
163    /// Cover the const accessor `period` (58-60) and the Indicator-impl
164    /// `name` body (93-95). `warmup_period` is exercised elsewhere.
165    #[test]
166    fn accessors_and_metadata() {
167        let fi = ForceIndex::new(13).unwrap();
168        assert_eq!(fi.period(), 13);
169        assert_eq!(fi.name(), "ForceIndex");
170    }
171
172    #[test]
173    fn reset_clears_state() {
174        let candles: Vec<Candle> = (0..30).map(|i| c(10.0 + i as f64, 50.0, i)).collect();
175        let mut fi = ForceIndex::new(13).unwrap();
176        fi.batch(&candles);
177        assert!(fi.is_ready());
178        fi.reset();
179        assert!(!fi.is_ready());
180        assert_eq!(fi.update(candles[0]), None);
181    }
182
183    #[test]
184    fn batch_equals_streaming() {
185        let candles: Vec<Candle> = (0..80)
186            .map(|i| {
187                let close = 100.0 + (i as f64 * 0.3).sin() * 8.0;
188                c(close, 10.0 + (i % 5) as f64, i)
189            })
190            .collect();
191        let mut a = ForceIndex::new(13).unwrap();
192        let mut b = ForceIndex::new(13).unwrap();
193        assert_eq!(
194            a.batch(&candles),
195            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
196        );
197    }
198}