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}