wickra_core/indicators/
force_index.rs1use crate::error::Result;
4use crate::indicators::ema::Ema;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
38pub struct ForceIndex {
39 period: usize,
40 prev_close: Option<f64>,
41 ema: Ema,
42}
43
44impl ForceIndex {
45 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 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 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 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 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 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 #[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}