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    fn update(&mut self, value: f64) -> Option<f64> {
60        self.slope.update(value).map(|s| s.atan().to_degrees())
61    }
62
63    fn reset(&mut self) {
64        self.slope.reset();
65    }
66
67    fn warmup_period(&self) -> usize {
68        self.slope.warmup_period()
69    }
70
71    fn is_ready(&self) -> bool {
72        self.slope.is_ready()
73    }
74
75    fn name(&self) -> &'static str {
76        "LinRegAngle"
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use crate::traits::BatchExt;
84    use approx::assert_relative_eq;
85
86    #[test]
87    fn unit_slope_is_forty_five_degrees() {
88        // A series rising by exactly 1 per step has slope 1, and atan(1) = 45°.
89        let mut angle = LinRegAngle::new(5).unwrap();
90        let out = angle.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
91        for (i, v) in out.iter().enumerate().take(4) {
92            assert!(v.is_none(), "index {i} must be None during warmup");
93        }
94        assert_relative_eq!(out[4].unwrap(), 45.0, epsilon = 1e-9);
95        assert_relative_eq!(out[5].unwrap(), 45.0, epsilon = 1e-9);
96    }
97
98    #[test]
99    fn reference_value_steep_slope() {
100        // period 3 over [1, 2, 9]: slope 4, angle = atan(4) in degrees.
101        let mut angle = LinRegAngle::new(3).unwrap();
102        let out = angle.batch(&[1.0, 2.0, 9.0]);
103        assert_relative_eq!(out[2].unwrap(), 4.0_f64.atan().to_degrees(), epsilon = 1e-9);
104    }
105
106    #[test]
107    fn constant_series_has_zero_angle() {
108        let mut angle = LinRegAngle::new(8).unwrap();
109        for v in angle.batch(&[42.0; 20]).into_iter().flatten() {
110            assert_relative_eq!(v, 0.0, epsilon = 1e-9);
111        }
112    }
113
114    #[test]
115    fn falling_series_has_negative_angle() {
116        let prices: Vec<f64> = (0..30).map(|i| 100.0 - f64::from(i)).collect();
117        let mut angle = LinRegAngle::new(10).unwrap();
118        for v in angle.batch(&prices).into_iter().flatten() {
119            assert!(v < 0.0, "a falling series must have a negative angle");
120        }
121    }
122
123    #[test]
124    fn stays_within_ninety_degrees() {
125        let prices: Vec<f64> = (0..60)
126            .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 1000.0)
127            .collect();
128        let mut angle = LinRegAngle::new(14).unwrap();
129        for v in angle.batch(&prices).into_iter().flatten() {
130            assert!(v > -90.0 && v < 90.0, "angle {v} outside (-90, 90)");
131        }
132    }
133
134    #[test]
135    fn rejects_period_below_two() {
136        assert!(LinRegAngle::new(0).is_err());
137        assert!(LinRegAngle::new(1).is_err());
138        assert!(LinRegAngle::new(2).is_ok());
139    }
140
141    /// Cover the const accessor `period` (50-52) and the Indicator-impl
142    /// `warmup_period` (67-69) + `name` (75-77). Existing tests inspect
143    /// angle output but never query the metadata.
144    #[test]
145    fn accessors_and_metadata() {
146        let a = LinRegAngle::new(14).unwrap();
147        assert_eq!(a.period(), 14);
148        assert_eq!(a.warmup_period(), 14);
149        assert_eq!(a.name(), "LinRegAngle");
150    }
151
152    #[test]
153    fn reset_clears_state() {
154        let mut angle = LinRegAngle::new(5).unwrap();
155        angle.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
156        assert!(angle.is_ready());
157        angle.reset();
158        assert!(!angle.is_ready());
159        assert_eq!(angle.update(1.0), None);
160    }
161
162    #[test]
163    fn batch_equals_streaming() {
164        let prices: Vec<f64> = (0..60)
165            .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
166            .collect();
167        let mut a = LinRegAngle::new(14).unwrap();
168        let mut b = LinRegAngle::new(14).unwrap();
169        assert_eq!(
170            a.batch(&prices),
171            prices.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
172        );
173    }
174}