Skip to main content

wickra_core/indicators/
linreg_angle.rs

1//! Linear Regression Angle.
2
3use crate::error::Result;
4use crate::indicators::linreg_slope::LinRegSlope;
5use crate::traits::Indicator;
6
7/// Linear Regression Angle — the slope of the rolling least-squares fit,
8/// expressed as an angle in degrees.
9///
10/// ```text
11/// LinRegAngle = atan(LinRegSlope) · 180 / π
12/// ```
13///
14/// It carries exactly the same information as [`LinRegSlope`](crate::LinRegSlope)
15/// — positive while price trends up, negative while it trends down — but maps
16/// the unbounded slope through `atan` onto `(−90°, +90°)`. That bounded,
17/// price-unit-free scale makes "how steep is the trend" comparable at a glance
18/// and across instruments. This is TA-Lib's `LINEARREG_ANGLE`.
19///
20/// # Example
21///
22/// ```
23/// use wickra_core::{Indicator, LinRegAngle};
24///
25/// let mut indicator = LinRegAngle::new(14).unwrap();
26/// let mut last = None;
27/// for i in 0..80 {
28///     last = indicator.update(f64::from(i));
29/// }
30/// assert!(last.is_some());
31/// ```
32#[derive(Debug, Clone)]
33pub struct LinRegAngle {
34    slope: LinRegSlope,
35}
36
37impl LinRegAngle {
38    /// Construct a new rolling linear-regression angle over `period` inputs.
39    ///
40    /// # Errors
41    /// Returns [`Error::InvalidPeriod`](crate::Error::InvalidPeriod) if
42    /// `period < 2` — a regression line is undefined for fewer than two points.
43    pub fn new(period: usize) -> Result<Self> {
44        Ok(Self {
45            slope: LinRegSlope::new(period)?,
46        })
47    }
48
49    /// Configured period.
50    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    #[inline]
60    fn update(&mut self, value: f64) -> Option<f64> {
61        if !value.is_finite() {
62            return None;
63        }
64        self.slope.update(value).map(|s| s.atan().to_degrees())
65    }
66
67    fn reset(&mut self) {
68        self.slope.reset();
69    }
70
71    #[inline]
72    fn warmup_period(&self) -> usize {
73        self.slope.warmup_period()
74    }
75
76    #[inline]
77    fn is_ready(&self) -> bool {
78        self.slope.is_ready()
79    }
80
81    #[inline]
82    fn name(&self) -> &'static str {
83        "LinRegAngle"
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::traits::BatchExt;
91    use approx::assert_relative_eq;
92
93    #[test]
94    fn unit_slope_is_forty_five_degrees() {
95        // A series rising by exactly 1 per step has slope 1, and atan(1) = 45°.
96        let mut angle = LinRegAngle::new(5).unwrap();
97        let out = angle.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
98        for (i, v) in out.iter().enumerate().take(4) {
99            assert!(v.is_none(), "index {i} must be None during warmup");
100        }
101        assert_relative_eq!(out[4].unwrap(), 45.0, epsilon = 1e-9);
102        assert_relative_eq!(out[5].unwrap(), 45.0, epsilon = 1e-9);
103    }
104
105    #[test]
106    fn reference_value_steep_slope() {
107        // period 3 over [1, 2, 9]: slope 4, angle = atan(4) in degrees.
108        let mut angle = LinRegAngle::new(3).unwrap();
109        let out = angle.batch(&[1.0, 2.0, 9.0]);
110        assert_relative_eq!(out[2].unwrap(), 4.0_f64.atan().to_degrees(), epsilon = 1e-9);
111    }
112
113    #[test]
114    fn constant_series_has_zero_angle() {
115        let mut angle = LinRegAngle::new(8).unwrap();
116        for v in angle.batch(&[42.0; 20]).into_iter().flatten() {
117            assert_relative_eq!(v, 0.0, epsilon = 1e-9);
118        }
119    }
120
121    #[test]
122    fn falling_series_has_negative_angle() {
123        let prices: Vec<f64> = (0..30).map(|i| 100.0 - f64::from(i)).collect();
124        let mut angle = LinRegAngle::new(10).unwrap();
125        for v in angle.batch(&prices).into_iter().flatten() {
126            assert!(v < 0.0, "a falling series must have a negative angle");
127        }
128    }
129
130    #[test]
131    fn stays_within_ninety_degrees() {
132        let prices: Vec<f64> = (0..60)
133            .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 1000.0)
134            .collect();
135        let mut angle = LinRegAngle::new(14).unwrap();
136        for v in angle.batch(&prices).into_iter().flatten() {
137            assert!(v > -90.0 && v < 90.0, "angle {v} outside (-90, 90)");
138        }
139    }
140
141    #[test]
142    fn rejects_period_below_two() {
143        assert!(LinRegAngle::new(0).is_err());
144        assert!(LinRegAngle::new(1).is_err());
145        assert!(LinRegAngle::new(2).is_ok());
146    }
147
148    /// Cover the const accessor `period` (50-52) and the Indicator-impl
149    /// `warmup_period` (67-69) + `name` (75-77). Existing tests inspect
150    /// angle output but never query the metadata.
151    #[test]
152    fn accessors_and_metadata() {
153        let a = LinRegAngle::new(14).unwrap();
154        assert_eq!(a.period(), 14);
155        assert_eq!(a.warmup_period(), 14);
156        assert_eq!(a.name(), "LinRegAngle");
157    }
158
159    #[test]
160    fn reset_clears_state() {
161        let mut angle = LinRegAngle::new(5).unwrap();
162        angle.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
163        assert!(angle.is_ready());
164        angle.reset();
165        assert!(!angle.is_ready());
166        assert_eq!(angle.update(1.0), None);
167    }
168
169    #[test]
170    fn batch_equals_streaming() {
171        let prices: Vec<f64> = (0..60)
172            .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
173            .collect();
174        let mut a = LinRegAngle::new(14).unwrap();
175        let mut b = LinRegAngle::new(14).unwrap();
176        assert_eq!(
177            a.batch(&prices),
178            prices.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
179        );
180    }
181}