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 fn update(&mut self, input: f64) -> Option<f64> {
75 let f = self.fast.update(input);
77 let s = self.slow.update(input);
78 Some(f? - s?)
79 }
80
81 fn reset(&mut self) {
82 self.fast.reset();
83 self.slow.reset();
84 }
85
86 fn warmup_period(&self) -> usize {
87 self.slow_period
89 }
90
91 fn is_ready(&self) -> bool {
92 self.slow.is_ready()
93 }
94
95 fn name(&self) -> &'static str {
96 "APO"
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103 use crate::traits::BatchExt;
104 use approx::assert_relative_eq;
105
106 #[test]
107 fn rejects_zero_period() {
108 assert!(matches!(Apo::new(0, 26), Err(Error::PeriodZero)));
109 assert!(matches!(Apo::new(12, 0), Err(Error::PeriodZero)));
110 }
111
112 #[test]
113 fn rejects_fast_geq_slow() {
114 assert!(matches!(Apo::new(26, 12), Err(Error::InvalidPeriod { .. })));
115 assert!(matches!(Apo::new(12, 12), Err(Error::InvalidPeriod { .. })));
116 }
117
118 #[test]
119 fn accessors_and_metadata() {
120 let apo = Apo::classic();
121 assert_eq!(apo.periods(), (12, 26));
122 assert_eq!(apo.warmup_period(), 26);
123 assert_eq!(apo.name(), "APO");
124 }
125
126 #[test]
127 fn classic_factory() {
128 assert_eq!(Apo::classic().periods(), (12, 26));
129 }
130
131 #[test]
132 fn constant_series_converges_to_zero() {
133 let mut apo = Apo::new(3, 5).unwrap();
135 let out = apo.batch(&[42.0_f64; 30]);
136 for v in out.iter().skip(4).flatten() {
137 assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
138 }
139 }
140
141 #[test]
142 fn warmup_emits_first_value_at_slow_period() {
143 let mut apo = Apo::new(2, 4).unwrap();
144 assert_eq!(apo.warmup_period(), 4);
145 for i in 1..=3 {
146 assert_eq!(apo.update(f64::from(i)), None);
147 }
148 assert!(apo.update(4.0).is_some());
149 }
150
151 #[test]
152 fn pure_uptrend_is_positive() {
153 let mut apo = Apo::classic();
155 let prices: Vec<f64> = (1..=200).map(f64::from).collect();
156 let out = apo.batch(&prices);
157 let last = out.iter().rev().flatten().next().unwrap();
158 assert!(*last > 0.0, "APO on uptrend should be positive: {last}");
159 }
160
161 #[test]
162 fn batch_equals_streaming() {
163 let prices: Vec<f64> = (1..=120)
164 .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
165 .collect();
166 let mut a = Apo::classic();
167 let mut b = Apo::classic();
168 assert_eq!(
169 a.batch(&prices),
170 prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
171 );
172 }
173
174 #[test]
175 fn reset_clears_state() {
176 let mut apo = Apo::classic();
177 apo.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
178 assert!(apo.is_ready());
179 apo.reset();
180 assert!(!apo.is_ready());
181 assert_eq!(apo.update(1.0), None);
182 }
183}