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    #[inline]
68    fn update(&mut self, candle: Candle) -> Option<f64> {
69        let Some(prev) = self.prev_close else {
70            // The first candle only establishes the previous close.
71            self.prev_close = Some(candle.close);
72            return None;
73        };
74        let raw = (candle.close - prev) * candle.volume;
75        self.prev_close = Some(candle.close);
76        self.ema.update(raw)
77    }
78
79    fn reset(&mut self) {
80        self.prev_close = None;
81        self.ema.reset();
82    }
83
84    #[inline]
85    fn warmup_period(&self) -> usize {
86        // One seed candle establishes the first previous close, then the EMA
87        // needs `period` raw values.
88        self.period + 1
89    }
90
91    #[inline]
92    fn is_ready(&self) -> bool {
93        self.ema.is_ready()
94    }
95
96    #[inline]
97    fn name(&self) -> &'static str {
98        "ForceIndex"
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 c(close: f64, volume: f64, ts: i64) -> Candle {
109        Candle::new(close, close, close, close, volume, ts).unwrap()
110    }
111
112    #[test]
113    fn reference_values() {
114        // ForceIndex(1): EMA(1) has alpha = 1, so it passes raw force through.
115        //   candle 1 (close 10) only seeds the previous close -> None.
116        //   candle 2: raw = (12 - 10) * 100 = +200.
117        //   candle 3: raw = (11 - 12) * 200 = -200.
118        let mut fi = ForceIndex::new(1).unwrap();
119        let out = fi.batch(&[c(10.0, 100.0, 0), c(12.0, 100.0, 1), c(11.0, 200.0, 2)]);
120        assert!(out[0].is_none());
121        assert_relative_eq!(out[1].unwrap(), 200.0, epsilon = 1e-9);
122        assert_relative_eq!(out[2].unwrap(), -200.0, epsilon = 1e-9);
123    }
124
125    #[test]
126    fn pure_uptrend_is_positive() {
127        // Strictly rising closes on constant volume -> every raw force is
128        // positive, so the smoothed force is positive too.
129        let candles: Vec<Candle> = (1..40)
130            .map(|i| c(f64::from(i), 100.0, i64::from(i)))
131            .collect();
132        let mut fi = ForceIndex::new(13).unwrap();
133        for v in fi.batch(&candles).into_iter().flatten() {
134            assert!(v > 0.0, "force {v} should be positive in an uptrend");
135        }
136    }
137
138    #[test]
139    fn pure_downtrend_is_negative() {
140        let candles: Vec<Candle> = (1..40)
141            .rev()
142            .map(|i| c(f64::from(i), 100.0, i64::from(i)))
143            .collect();
144        let mut fi = ForceIndex::new(13).unwrap();
145        for v in fi.batch(&candles).into_iter().flatten() {
146            assert!(v < 0.0, "force {v} should be negative in a downtrend");
147        }
148    }
149
150    #[test]
151    fn first_value_on_period_plus_one_candle() {
152        let candles: Vec<Candle> = (0..12).map(|i| c(10.0 + i as f64, 50.0, i)).collect();
153        let mut fi = ForceIndex::new(5).unwrap();
154        let out = fi.batch(&candles);
155        for (i, v) in out.iter().enumerate().take(5) {
156            assert!(v.is_none(), "index {i} must be None during warmup");
157        }
158        assert!(out[5].is_some(), "first force lands at index period");
159        assert_eq!(fi.warmup_period(), 6);
160    }
161
162    #[test]
163    fn rejects_zero_period() {
164        assert!(ForceIndex::new(0).is_err());
165    }
166
167    /// Cover the const accessor `period` (58-60) and the Indicator-impl
168    /// `name` body (93-95). `warmup_period` is exercised elsewhere.
169    #[test]
170    fn accessors_and_metadata() {
171        let fi = ForceIndex::new(13).unwrap();
172        assert_eq!(fi.period(), 13);
173        assert_eq!(fi.name(), "ForceIndex");
174    }
175
176    #[test]
177    fn reset_clears_state() {
178        let candles: Vec<Candle> = (0..30).map(|i| c(10.0 + i as f64, 50.0, i)).collect();
179        let mut fi = ForceIndex::new(13).unwrap();
180        fi.batch(&candles);
181        assert!(fi.is_ready());
182        fi.reset();
183        assert!(!fi.is_ready());
184        assert_eq!(fi.update(candles[0]), None);
185    }
186
187    #[test]
188    fn batch_equals_streaming() {
189        let candles: Vec<Candle> = (0..80)
190            .map(|i| {
191                let close = 100.0 + (i as f64 * 0.3).sin() * 8.0;
192                c(close, 10.0 + (i % 5) as f64, i)
193            })
194            .collect();
195        let mut a = ForceIndex::new(13).unwrap();
196        let mut b = ForceIndex::new(13).unwrap();
197        assert_eq!(
198            a.batch(&candles),
199            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
200        );
201    }
202}