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        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        // A series rising by exactly 1 per step has slope 1, and atan(1) = 45°.
92        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        // period 3 over [1, 2, 9]: slope 4, angle = atan(4) in degrees.
104        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    /// Cover the const accessor `period` (50-52) and the Indicator-impl
145    /// `warmup_period` (67-69) + `name` (75-77). Existing tests inspect
146    /// angle output but never query the metadata.
147    #[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}