wickra_core/indicators/
linreg_angle.rs1use crate::error::Result;
4use crate::indicators::linreg_slope::LinRegSlope;
5use crate::traits::Indicator;
6
7#[derive(Debug, Clone)]
33pub struct LinRegAngle {
34 slope: LinRegSlope,
35}
36
37impl LinRegAngle {
38 pub fn new(period: usize) -> Result<Self> {
44 Ok(Self {
45 slope: LinRegSlope::new(period)?,
46 })
47 }
48
49 pub const fn period(&self) -> usize {
51 self.slope.period()
52 }
53}
54
55impl Indicator for LinRegAngle {
56 type Input = f64;
57 type Output = f64;
58
59 fn update(&mut self, value: f64) -> Option<f64> {
60 if !value.is_finite() {
61 return None;
62 }
63 self.slope.update(value).map(|s| s.atan().to_degrees())
64 }
65
66 fn reset(&mut self) {
67 self.slope.reset();
68 }
69
70 fn warmup_period(&self) -> usize {
71 self.slope.warmup_period()
72 }
73
74 fn is_ready(&self) -> bool {
75 self.slope.is_ready()
76 }
77
78 fn name(&self) -> &'static str {
79 "LinRegAngle"
80 }
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86 use crate::traits::BatchExt;
87 use approx::assert_relative_eq;
88
89 #[test]
90 fn unit_slope_is_forty_five_degrees() {
91 let mut angle = LinRegAngle::new(5).unwrap();
93 let out = angle.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
94 for (i, v) in out.iter().enumerate().take(4) {
95 assert!(v.is_none(), "index {i} must be None during warmup");
96 }
97 assert_relative_eq!(out[4].unwrap(), 45.0, epsilon = 1e-9);
98 assert_relative_eq!(out[5].unwrap(), 45.0, epsilon = 1e-9);
99 }
100
101 #[test]
102 fn reference_value_steep_slope() {
103 let mut angle = LinRegAngle::new(3).unwrap();
105 let out = angle.batch(&[1.0, 2.0, 9.0]);
106 assert_relative_eq!(out[2].unwrap(), 4.0_f64.atan().to_degrees(), epsilon = 1e-9);
107 }
108
109 #[test]
110 fn constant_series_has_zero_angle() {
111 let mut angle = LinRegAngle::new(8).unwrap();
112 for v in angle.batch(&[42.0; 20]).into_iter().flatten() {
113 assert_relative_eq!(v, 0.0, epsilon = 1e-9);
114 }
115 }
116
117 #[test]
118 fn falling_series_has_negative_angle() {
119 let prices: Vec<f64> = (0..30).map(|i| 100.0 - f64::from(i)).collect();
120 let mut angle = LinRegAngle::new(10).unwrap();
121 for v in angle.batch(&prices).into_iter().flatten() {
122 assert!(v < 0.0, "a falling series must have a negative angle");
123 }
124 }
125
126 #[test]
127 fn stays_within_ninety_degrees() {
128 let prices: Vec<f64> = (0..60)
129 .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 1000.0)
130 .collect();
131 let mut angle = LinRegAngle::new(14).unwrap();
132 for v in angle.batch(&prices).into_iter().flatten() {
133 assert!(v > -90.0 && v < 90.0, "angle {v} outside (-90, 90)");
134 }
135 }
136
137 #[test]
138 fn rejects_period_below_two() {
139 assert!(LinRegAngle::new(0).is_err());
140 assert!(LinRegAngle::new(1).is_err());
141 assert!(LinRegAngle::new(2).is_ok());
142 }
143
144 #[test]
148 fn accessors_and_metadata() {
149 let a = LinRegAngle::new(14).unwrap();
150 assert_eq!(a.period(), 14);
151 assert_eq!(a.warmup_period(), 14);
152 assert_eq!(a.name(), "LinRegAngle");
153 }
154
155 #[test]
156 fn reset_clears_state() {
157 let mut angle = LinRegAngle::new(5).unwrap();
158 angle.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
159 assert!(angle.is_ready());
160 angle.reset();
161 assert!(!angle.is_ready());
162 assert_eq!(angle.update(1.0), None);
163 }
164
165 #[test]
166 fn batch_equals_streaming() {
167 let prices: Vec<f64> = (0..60)
168 .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
169 .collect();
170 let mut a = LinRegAngle::new(14).unwrap();
171 let mut b = LinRegAngle::new(14).unwrap();
172 assert_eq!(
173 a.batch(&prices),
174 prices.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
175 );
176 }
177}