wickra_core/indicators/
apo.rs1use crate::error::{Error, Result};
4use crate::indicators::ema::Ema;
5use crate::traits::Indicator;
6
7#[derive(Debug, Clone)]
31pub struct Apo {
32 fast_period: usize,
33 slow_period: usize,
34 fast: Ema,
35 slow: Ema,
36}
37
38impl Apo {
39 pub fn new(fast: usize, slow: usize) -> Result<Self> {
43 if fast == 0 || slow == 0 {
44 return Err(Error::PeriodZero);
45 }
46 if fast >= slow {
47 return Err(Error::InvalidPeriod {
48 message: "APO fast period must be strictly less than slow",
49 });
50 }
51 Ok(Self {
52 fast_period: fast,
53 slow_period: slow,
54 fast: Ema::new(fast)?,
55 slow: Ema::new(slow)?,
56 })
57 }
58
59 pub fn classic() -> Self {
61 Self::new(12, 26).expect("classic APO parameters are valid")
62 }
63
64 pub const fn periods(&self) -> (usize, usize) {
66 (self.fast_period, self.slow_period)
67 }
68}
69
70impl Indicator for Apo {
71 type Input = f64;
72 type Output = f64;
73
74 #[inline]
75 fn update(&mut self, input: f64) -> Option<f64> {
76 let f = self.fast.update(input);
78 let s = self.slow.update(input);
79 Some(f? - s?)
80 }
81
82 fn reset(&mut self) {
83 self.fast.reset();
84 self.slow.reset();
85 }
86
87 #[inline]
88 fn warmup_period(&self) -> usize {
89 self.slow_period
91 }
92
93 #[inline]
94 fn is_ready(&self) -> bool {
95 self.slow.is_ready()
96 }
97
98 #[inline]
99 fn name(&self) -> &'static str {
100 "APO"
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use crate::traits::BatchExt;
108 use approx::assert_relative_eq;
109
110 #[test]
111 fn rejects_zero_period() {
112 assert!(matches!(Apo::new(0, 26), Err(Error::PeriodZero)));
113 assert!(matches!(Apo::new(12, 0), Err(Error::PeriodZero)));
114 }
115
116 #[test]
117 fn rejects_fast_geq_slow() {
118 assert!(matches!(Apo::new(26, 12), Err(Error::InvalidPeriod { .. })));
119 assert!(matches!(Apo::new(12, 12), Err(Error::InvalidPeriod { .. })));
120 }
121
122 #[test]
123 fn accessors_and_metadata() {
124 let apo = Apo::classic();
125 assert_eq!(apo.periods(), (12, 26));
126 assert_eq!(apo.warmup_period(), 26);
127 assert_eq!(apo.name(), "APO");
128 }
129
130 #[test]
131 fn classic_factory() {
132 assert_eq!(Apo::classic().periods(), (12, 26));
133 }
134
135 #[test]
136 fn constant_series_converges_to_zero() {
137 let mut apo = Apo::new(3, 5).unwrap();
139 let out = apo.batch(&[42.0_f64; 30]);
140 for v in out.iter().skip(4).flatten() {
141 assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
142 }
143 }
144
145 #[test]
146 fn warmup_emits_first_value_at_slow_period() {
147 let mut apo = Apo::new(2, 4).unwrap();
148 assert_eq!(apo.warmup_period(), 4);
149 for i in 1..=3 {
150 assert_eq!(apo.update(f64::from(i)), None);
151 }
152 assert!(apo.update(4.0).is_some());
153 }
154
155 #[test]
156 fn pure_uptrend_is_positive() {
157 let mut apo = Apo::classic();
159 let prices: Vec<f64> = (1..=200).map(f64::from).collect();
160 let out = apo.batch(&prices);
161 let last = out.iter().rev().flatten().next().unwrap();
162 assert!(*last > 0.0, "APO on uptrend should be positive: {last}");
163 }
164
165 #[test]
166 fn batch_equals_streaming() {
167 let prices: Vec<f64> = (1..=120)
168 .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
169 .collect();
170 let mut a = Apo::classic();
171 let mut b = Apo::classic();
172 assert_eq!(
173 a.batch(&prices),
174 prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
175 );
176 }
177
178 #[test]
179 fn reset_clears_state() {
180 let mut apo = Apo::classic();
181 apo.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
182 assert!(apo.is_ready());
183 apo.reset();
184 assert!(!apo.is_ready());
185 assert_eq!(apo.update(1.0), None);
186 }
187}