wickra_core/indicators/
tsf_oscillator.rs1use crate::error::{Error, Result};
4use crate::indicators::tsf::Tsf;
5use crate::traits::Indicator;
6
7#[derive(Debug, Clone)]
40pub struct TsfOscillator {
41 period: usize,
42 tsf: Tsf,
43 current: Option<f64>,
44}
45
46impl TsfOscillator {
47 pub fn new(period: usize) -> Result<Self> {
53 if period < 2 {
54 return Err(Error::InvalidPeriod {
55 message: "TSF oscillator needs period >= 2",
56 });
57 }
58 Ok(Self {
59 period,
60 tsf: Tsf::new(period)?,
61 current: None,
62 })
63 }
64
65 pub const fn period(&self) -> usize {
67 self.period
68 }
69}
70
71impl Indicator for TsfOscillator {
72 type Input = f64;
73 type Output = f64;
74
75 fn update(&mut self, input: f64) -> Option<f64> {
76 let forecast = self.tsf.update(input)?;
77 if input == 0.0 {
80 return self.current;
81 }
82 let value = 100.0 * (input - forecast) / input;
83 self.current = Some(value);
84 Some(value)
85 }
86
87 fn reset(&mut self) {
88 self.tsf.reset();
89 self.current = None;
90 }
91
92 fn warmup_period(&self) -> usize {
93 self.period
94 }
95
96 fn is_ready(&self) -> bool {
97 self.current.is_some()
98 }
99
100 fn name(&self) -> &'static str {
101 "TsfOscillator"
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108 use crate::traits::BatchExt;
109 use approx::assert_relative_eq;
110
111 #[test]
112 fn rejects_short_period() {
113 assert!(matches!(
114 TsfOscillator::new(1),
115 Err(Error::InvalidPeriod { .. })
116 ));
117 assert!(matches!(
118 TsfOscillator::new(0),
119 Err(Error::InvalidPeriod { .. })
120 ));
121 }
122
123 #[test]
124 fn accessors_and_metadata() {
125 let osc = TsfOscillator::new(14).unwrap();
126 assert_eq!(osc.period(), 14);
127 assert_eq!(osc.warmup_period(), 14);
128 assert_eq!(osc.name(), "TsfOscillator");
129 assert!(!osc.is_ready());
130 }
131
132 #[test]
133 fn reference_value() {
134 let mut osc = TsfOscillator::new(3).unwrap();
137 let out = osc.batch(&[1.0_f64, 2.0, 9.0]);
138 assert!(out[0].is_none());
139 assert!(out[1].is_none());
140 assert_relative_eq!(out[2].unwrap(), -100.0 / 3.0, epsilon = 1e-9);
141 assert!(osc.is_ready());
142 }
143
144 #[test]
145 fn constant_series_yields_zero() {
146 let mut osc = TsfOscillator::new(5).unwrap();
149 let out = osc.batch(&[42.0_f64; 30]);
150 for v in out.iter().skip(4).flatten() {
151 assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
152 }
153 }
154
155 #[test]
156 fn linear_uptrend_reads_negative() {
157 let mut osc = TsfOscillator::new(5).unwrap();
161 let prices: Vec<f64> = (1..=20).map(|i| f64::from(i) * 2.0).collect();
162 let out = osc.batch(&prices);
163 for v in out.iter().skip(4).flatten() {
164 assert!(*v < 0.0, "uptrend forecast overshoots close, got {v}");
165 }
166 }
167
168 #[test]
169 fn warmup_emits_first_value_at_period() {
170 let mut osc = TsfOscillator::new(3).unwrap();
171 assert_eq!(osc.update(1.0), None);
172 assert_eq!(osc.update(2.0), None);
173 assert!(osc.update(3.0).is_some());
174 }
175
176 #[test]
177 fn batch_equals_streaming() {
178 let prices: Vec<f64> = (1..=80)
179 .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
180 .collect();
181 let mut a = TsfOscillator::new(14).unwrap();
182 let mut b = TsfOscillator::new(14).unwrap();
183 assert_eq!(
184 a.batch(&prices),
185 prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
186 );
187 }
188
189 #[test]
190 fn reset_clears_state() {
191 let mut osc = TsfOscillator::new(5).unwrap();
192 osc.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
193 assert!(osc.is_ready());
194 osc.reset();
195 assert!(!osc.is_ready());
196 assert_eq!(osc.update(1.0), None);
197 }
198
199 #[test]
200 fn zero_close_holds_value() {
201 let mut osc = TsfOscillator::new(3).unwrap();
202 osc.batch(&[1.0_f64, 2.0, 3.0]);
203 let before = osc.current;
204 assert_eq!(osc.update(0.0), before);
205 }
206}