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    #[inline]
100    fn update(&mut self, input: (f64, f64)) -> Option<RelativeStrengthOutput> {
101        let (a, b) = input;
102        if b == 0.0 || !a.is_finite() || !b.is_finite() {
103            // Undefined ratio: skip without disturbing the internal averages.
104            return None;
105        }
106        let ratio = a / b;
107        let ma = self.ma.update(ratio);
108        let rsi = self.rsi.update(ratio);
109        match (ma, rsi) {
110            (Some(ratio_ma), Some(ratio_rsi)) => Some(RelativeStrengthOutput {
111                ratio,
112                ratio_ma,
113                ratio_rsi,
114            }),
115            _ => None,
116        }
117    }
118
119    fn reset(&mut self) {
120        self.ma.reset();
121        self.rsi.reset();
122    }
123
124    #[inline]
125    fn warmup_period(&self) -> usize {
126        self.ma.warmup_period().max(self.rsi.warmup_period())
127    }
128
129    #[inline]
130    fn is_ready(&self) -> bool {
131        self.ma.is_ready() && self.rsi.is_ready()
132    }
133
134    #[inline]
135    fn name(&self) -> &'static str {
136        "RelativeStrengthAB"
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::traits::BatchExt;
144    use approx::assert_relative_eq;
145
146    #[test]
147    fn rejects_zero_periods() {
148        assert!(RelativeStrengthAB::new(0, 5).is_err());
149        assert!(RelativeStrengthAB::new(5, 0).is_err());
150        assert!(RelativeStrengthAB::new(5, 5).is_ok());
151    }
152
153    #[test]
154    fn accessors_and_metadata() {
155        let rs = RelativeStrengthAB::new(10, 14).unwrap();
156        assert_eq!(rs.ma_period(), 10);
157        assert_eq!(rs.rsi_period(), 14);
158        // SMA warmup = 10, RSI warmup = 15 ⇒ combined = 15.
159        assert_eq!(rs.warmup_period(), 15);
160        assert_eq!(rs.name(), "RelativeStrengthAB");
161    }
162
163    #[test]
164    fn constant_ratio_is_flat() {
165        // a = 2·b ⇒ ratio is a constant 2 ⇒ MA = 2, RSI = neutral 50.
166        let pairs: Vec<(f64, f64)> = (0..20).map(|_| (200.0, 100.0)).collect();
167        let out = RelativeStrengthAB::new(5, 5)
168            .unwrap()
169            .batch(&pairs)
170            .into_iter()
171            .flatten()
172            .last()
173            .unwrap();
174        assert_relative_eq!(out.ratio, 2.0, epsilon = 1e-12);
175        assert_relative_eq!(out.ratio_ma, 2.0, epsilon = 1e-12);
176        assert_relative_eq!(out.ratio_rsi, 50.0, epsilon = 1e-9);
177    }
178
179    #[test]
180    fn rising_ratio_is_overbought() {
181        // a grows while b is flat ⇒ ratio strictly rises ⇒ RSI saturates at 100.
182        let pairs: Vec<(f64, f64)> = (0..20)
183            .map(|t| (100.0 + 2.0 * f64::from(t), 100.0))
184            .collect();
185        let out = RelativeStrengthAB::new(5, 5)
186            .unwrap()
187            .batch(&pairs)
188            .into_iter()
189            .flatten()
190            .last()
191            .unwrap();
192        assert!(out.ratio > 1.0);
193        assert_relative_eq!(out.ratio_rsi, 100.0, epsilon = 1e-9);
194    }
195
196    #[test]
197    fn zero_denominator_is_skipped() {
198        let mut rs = RelativeStrengthAB::new(3, 3).unwrap();
199        // b == 0 and non-finite inputs never reach the internal averages.
200        assert_eq!(rs.update((100.0, 0.0)), None);
201        assert_eq!(rs.update((f64::NAN, 100.0)), None);
202        assert!(!rs.is_ready());
203        for _ in 0..8 {
204            rs.update((150.0, 100.0));
205        }
206        assert!(rs.is_ready());
207    }
208
209    #[test]
210    fn reset_clears_state() {
211        let mut rs = RelativeStrengthAB::new(3, 3).unwrap();
212        for t in 0..10 {
213            rs.update((100.0 + f64::from(t), 100.0));
214        }
215        assert!(rs.is_ready());
216        rs.reset();
217        assert!(!rs.is_ready());
218        assert_eq!(rs.update((100.0, 100.0)), None);
219    }
220
221    #[test]
222    fn batch_equals_streaming() {
223        let pairs: Vec<(f64, f64)> = (0..60)
224            .map(|t| {
225                let tt = f64::from(t);
226                (
227                    100.0 + 5.0 * (tt * 0.3).sin(),
228                    100.0 + 2.0 * (tt * 0.2).cos(),
229                )
230            })
231            .collect();
232        let batch = RelativeStrengthAB::new(10, 14).unwrap().batch(&pairs);
233        let mut rs = RelativeStrengthAB::new(10, 14).unwrap();
234        let streamed: Vec<_> = pairs.iter().map(|p| rs.update(*p)).collect();
235        assert_eq!(batch, streamed);
236    }
237}