Skip to main content

wickra_core/indicators/
relative_strength_ab.rs

1//! Relative Strength A-vs-B — the price ratio of two assets, plus its MA and RSI.
2
3use crate::error::Result;
4use crate::indicators::{Rsi, Sma};
5use crate::traits::Indicator;
6
7/// Output of [`RelativeStrengthAB`].
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct RelativeStrengthOutput {
10    /// The raw relative-strength ratio `a / b`.
11    pub ratio: f64,
12    /// Simple moving average of the ratio over `ma_period`.
13    pub ratio_ma: f64,
14    /// Relative Strength Index of the ratio over `rsi_period`.
15    pub ratio_rsi: f64,
16}
17
18/// Comparative relative strength of asset `a` against asset `b`.
19///
20/// Each `update` receives one `(a, b)` price pair and forms the **ratio line**
21/// `a / b`. The ratio is then smoothed with a simple moving average and run
22/// through an RSI, so a single indicator gives you the relative-strength level,
23/// its trend, and whether that trend is overbought or oversold:
24///
25/// ```text
26/// ratio     = a / b
27/// ratio_ma  = SMA(ratio, ma_period)
28/// ratio_rsi = RSI(ratio, rsi_period)
29/// ```
30///
31/// A rising ratio means `a` is outperforming `b`; `ratio_ma` shows the trend of
32/// that outperformance and `ratio_rsi` flags exhaustion (e.g. `> 70` after a
33/// strong run of `a` over `b`). This is the classic "asset-vs-asset" or
34/// "asset-vs-index" rotation screen.
35///
36/// The first output appears once both the moving average and the RSI have
37/// warmed up; the ratio itself is computed from the first valid pair. A
38/// non-finite price or a zero denominator (`b == 0`) makes the ratio undefined
39/// and is skipped, leaving the internal averages untouched.
40///
41/// # Example
42///
43/// ```
44/// use wickra_core::{Indicator, RelativeStrengthAB};
45///
46/// let mut rs = RelativeStrengthAB::new(5, 5).unwrap();
47/// let mut last = None;
48/// for _ in 0..20 {
49///     last = rs.update((200.0, 100.0)); // ratio is a constant 2.0
50/// }
51/// let out = last.unwrap();
52/// assert!((out.ratio - 2.0).abs() < 1e-12);
53/// assert!((out.ratio_ma - 2.0).abs() < 1e-12);
54/// // A flat ratio has no gains or losses, so its RSI sits at the neutral 50.
55/// assert!((out.ratio_rsi - 50.0).abs() < 1e-9);
56/// ```
57#[derive(Debug, Clone)]
58pub struct RelativeStrengthAB {
59    ma_period: usize,
60    rsi_period: usize,
61    ma: Sma,
62    rsi: Rsi,
63}
64
65impl RelativeStrengthAB {
66    /// Construct a new comparative relative-strength indicator.
67    ///
68    /// `ma_period` is the moving-average look-back of the ratio; `rsi_period`
69    /// is the RSI look-back of the ratio.
70    ///
71    /// # Errors
72    /// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if either period
73    /// is zero.
74    pub fn new(ma_period: usize, rsi_period: usize) -> Result<Self> {
75        Ok(Self {
76            ma_period,
77            rsi_period,
78            ma: Sma::new(ma_period)?,
79            rsi: Rsi::new(rsi_period)?,
80        })
81    }
82
83    /// Moving-average look-back of the ratio.
84    pub const fn ma_period(&self) -> usize {
85        self.ma_period
86    }
87
88    /// RSI look-back of the ratio.
89    pub const fn rsi_period(&self) -> usize {
90        self.rsi_period
91    }
92}
93
94impl Indicator for RelativeStrengthAB {
95    /// `(a, b)` price pair.
96    type Input = (f64, f64);
97    type Output = RelativeStrengthOutput;
98
99    fn update(&mut self, input: (f64, f64)) -> Option<RelativeStrengthOutput> {
100        let (a, b) = input;
101        if b == 0.0 || !a.is_finite() || !b.is_finite() {
102            // Undefined ratio: skip without disturbing the internal averages.
103            return None;
104        }
105        let ratio = a / b;
106        let ma = self.ma.update(ratio);
107        let rsi = self.rsi.update(ratio);
108        match (ma, rsi) {
109            (Some(ratio_ma), Some(ratio_rsi)) => Some(RelativeStrengthOutput {
110                ratio,
111                ratio_ma,
112                ratio_rsi,
113            }),
114            _ => None,
115        }
116    }
117
118    fn reset(&mut self) {
119        self.ma.reset();
120        self.rsi.reset();
121    }
122
123    fn warmup_period(&self) -> usize {
124        self.ma.warmup_period().max(self.rsi.warmup_period())
125    }
126
127    fn is_ready(&self) -> bool {
128        self.ma.is_ready() && self.rsi.is_ready()
129    }
130
131    fn name(&self) -> &'static str {
132        "RelativeStrengthAB"
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::traits::BatchExt;
140    use approx::assert_relative_eq;
141
142    #[test]
143    fn rejects_zero_periods() {
144        assert!(RelativeStrengthAB::new(0, 5).is_err());
145        assert!(RelativeStrengthAB::new(5, 0).is_err());
146        assert!(RelativeStrengthAB::new(5, 5).is_ok());
147    }
148
149    #[test]
150    fn accessors_and_metadata() {
151        let rs = RelativeStrengthAB::new(10, 14).unwrap();
152        assert_eq!(rs.ma_period(), 10);
153        assert_eq!(rs.rsi_period(), 14);
154        // SMA warmup = 10, RSI warmup = 15 ⇒ combined = 15.
155        assert_eq!(rs.warmup_period(), 15);
156        assert_eq!(rs.name(), "RelativeStrengthAB");
157    }
158
159    #[test]
160    fn constant_ratio_is_flat() {
161        // a = 2·b ⇒ ratio is a constant 2 ⇒ MA = 2, RSI = neutral 50.
162        let pairs: Vec<(f64, f64)> = (0..20).map(|_| (200.0, 100.0)).collect();
163        let out = RelativeStrengthAB::new(5, 5)
164            .unwrap()
165            .batch(&pairs)
166            .into_iter()
167            .flatten()
168            .last()
169            .unwrap();
170        assert_relative_eq!(out.ratio, 2.0, epsilon = 1e-12);
171        assert_relative_eq!(out.ratio_ma, 2.0, epsilon = 1e-12);
172        assert_relative_eq!(out.ratio_rsi, 50.0, epsilon = 1e-9);
173    }
174
175    #[test]
176    fn rising_ratio_is_overbought() {
177        // a grows while b is flat ⇒ ratio strictly rises ⇒ RSI saturates at 100.
178        let pairs: Vec<(f64, f64)> = (0..20)
179            .map(|t| (100.0 + 2.0 * f64::from(t), 100.0))
180            .collect();
181        let out = RelativeStrengthAB::new(5, 5)
182            .unwrap()
183            .batch(&pairs)
184            .into_iter()
185            .flatten()
186            .last()
187            .unwrap();
188        assert!(out.ratio > 1.0);
189        assert_relative_eq!(out.ratio_rsi, 100.0, epsilon = 1e-9);
190    }
191
192    #[test]
193    fn zero_denominator_is_skipped() {
194        let mut rs = RelativeStrengthAB::new(3, 3).unwrap();
195        // b == 0 and non-finite inputs never reach the internal averages.
196        assert_eq!(rs.update((100.0, 0.0)), None);
197        assert_eq!(rs.update((f64::NAN, 100.0)), None);
198        assert!(!rs.is_ready());
199        for _ in 0..8 {
200            rs.update((150.0, 100.0));
201        }
202        assert!(rs.is_ready());
203    }
204
205    #[test]
206    fn reset_clears_state() {
207        let mut rs = RelativeStrengthAB::new(3, 3).unwrap();
208        for t in 0..10 {
209            rs.update((100.0 + f64::from(t), 100.0));
210        }
211        assert!(rs.is_ready());
212        rs.reset();
213        assert!(!rs.is_ready());
214        assert_eq!(rs.update((100.0, 100.0)), None);
215    }
216
217    #[test]
218    fn batch_equals_streaming() {
219        let pairs: Vec<(f64, f64)> = (0..60)
220            .map(|t| {
221                let tt = f64::from(t);
222                (
223                    100.0 + 5.0 * (tt * 0.3).sin(),
224                    100.0 + 2.0 * (tt * 0.2).cos(),
225                )
226            })
227            .collect();
228        let batch = RelativeStrengthAB::new(10, 14).unwrap().batch(&pairs);
229        let mut rs = RelativeStrengthAB::new(10, 14).unwrap();
230        let streamed: Vec<_> = pairs.iter().map(|p| rs.update(*p)).collect();
231        assert_eq!(batch, streamed);
232    }
233}