Skip to main content

kestrel_chartkit/indicator/
force_index.rs

1use std::collections::HashMap;
2
3use crate::model::Bar;
4
5use super::smoothing::Ema;
6use super::{Indicator, IndicatorOutput};
7
8/// Elder's Force Index: the bar-to-bar price change weighted by the volume that moved it.
9///
10/// Raw value: `(close_t - close_{t-1}) * volume_t`. There is no output on the first bar of a
11/// series, because a change needs a previous close — a first bar is not a zero-force bar.
12/// The main line is that raw series smoothed with an [`Ema`] of `ema_len`.
13///
14/// Unit: price change times volume, i.e. the series' price unit multiplied by whatever the
15/// series counts as volume (traded turnover, contracts, or — on a tick-volume series — update
16/// counts, which makes the magnitude meaningless even though the sign still reads). Values from
17/// different instruments or volume kinds are not comparable; the sign and the zero crossing are.
18/// See [`crate::applicability::data_requirements`], which declares real traded volume for this
19/// indicator.
20///
21/// A bar with zero volume, or one that closes exactly where the previous bar closed, has a raw
22/// force of 0. That zero enters the average as an ordinary observation: it pulls the smoothed
23/// line towards zero, it does not set it to zero.
24///
25/// Per-bar outputs:
26/// - `value`: the smoothed force index.
27/// - `extra["raw"]`: the unsmoothed force of this bar, in the same unit.
28///
29/// First output: with the `ema_len`-th price change, i.e. after `ema_len + 1` bars. The internal
30/// EMA runs from the first change onward — seeded with that first real observation, not with an
31/// invented starting value — but its seed-dominated early values are not published.
32/// [`Indicator::reset`] clears the previous close and the average, so the next series starts
33/// deterministically; a series switch must go through it rather than continuing the average
34/// across the boundary.
35#[derive(Debug, Clone)]
36pub struct ElderForceIndex {
37    ema_len: usize,
38    prev_close: Option<f64>,
39    ema: Ema,
40    changes_seen: usize,
41}
42
43impl ElderForceIndex {
44    pub fn new(ema_len: usize) -> Self {
45        Self {
46            ema_len: ema_len.max(1),
47            prev_close: None,
48            ema: Ema::new(ema_len.max(1)),
49            changes_seen: 0,
50        }
51    }
52
53    pub fn with_defaults() -> Self {
54        Self::new(13)
55    }
56}
57
58impl Indicator for ElderForceIndex {
59    fn name(&self) -> &str {
60        "efi"
61    }
62
63    fn warmup_period(&self) -> usize {
64        self.ema_len + 1
65    }
66
67    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
68        let prev_close = match self.prev_close {
69            None => {
70                self.prev_close = Some(bar.close);
71                return None;
72            }
73            Some(p) => p,
74        };
75        self.prev_close = Some(bar.close);
76
77        let raw = (bar.close - prev_close) * bar.volume;
78        let line = self.ema.update(raw)?;
79        self.changes_seen += 1;
80        if self.changes_seen < self.ema_len {
81            return None;
82        }
83
84        let mut extra = HashMap::new();
85        extra.insert("raw".to_string(), raw);
86
87        Some(IndicatorOutput::with_extra(line, extra))
88    }
89
90    fn reset(&mut self) {
91        self.prev_close = None;
92        self.ema.reset();
93        self.changes_seen = 0;
94    }
95}