Skip to main content

wickra_core/indicators/
fisher_rsi.rs

1//! Fisher-transformed RSI.
2
3use crate::error::Result;
4use crate::indicators::rsi::Rsi;
5use crate::traits::Indicator;
6
7/// Fisher RSI — the Fisher transform applied to a normalised [`Rsi`](crate::Rsi).
8///
9/// The RSI is bounded in `[0, 100]` and its distribution piles up near the
10/// middle, which blurs turning points. The Fisher transform reshapes a bounded
11/// input toward a Gaussian, sharpening the extremes into clear, near-symmetric
12/// peaks:
13///
14/// ```text
15/// rsi   = RSI(price, period)            in [0, 100]
16/// x     = clamp((rsi - 50) / 50, ±0.999)   normalise to (-1, 1)
17/// Fisher = 0.5 * ln((1 + x) / (1 - x))
18/// ```
19///
20/// The clamp keeps the logarithm finite when the RSI pins at `0` or `100`. The
21/// output is unbounded but in practice oscillates in roughly `[-3, 3]`, with
22/// sharp excursions marking momentum extremes. The first value lands with the
23/// inner RSI, after `period + 1` inputs.
24///
25/// # Example
26///
27/// ```
28/// use wickra_core::{FisherRsi, Indicator};
29///
30/// let mut indicator = FisherRsi::new(9).unwrap();
31/// let mut last = None;
32/// for i in 0..80 {
33///     last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
34/// }
35/// assert!(last.is_some());
36/// ```
37#[derive(Debug, Clone)]
38pub struct FisherRsi {
39    period: usize,
40    rsi: Rsi,
41}
42
43impl FisherRsi {
44    /// Construct a Fisher RSI with the given RSI period.
45    ///
46    /// # Errors
47    ///
48    /// Returns [`crate::Error::PeriodZero`] if `period == 0`.
49    pub fn new(period: usize) -> Result<Self> {
50        Ok(Self {
51            period,
52            rsi: Rsi::new(period)?,
53        })
54    }
55
56    /// Configured period.
57    pub const fn period(&self) -> usize {
58        self.period
59    }
60}
61
62impl Indicator for FisherRsi {
63    type Input = f64;
64    type Output = f64;
65
66    #[inline]
67    fn update(&mut self, input: f64) -> Option<f64> {
68        let rsi = self.rsi.update(input)?;
69        let x = ((rsi - 50.0) / 50.0).clamp(-0.999, 0.999);
70        Some(0.5 * ((1.0 + x) / (1.0 - x)).ln())
71    }
72
73    fn reset(&mut self) {
74        self.rsi.reset();
75    }
76
77    #[inline]
78    fn warmup_period(&self) -> usize {
79        self.rsi.warmup_period()
80    }
81
82    #[inline]
83    fn is_ready(&self) -> bool {
84        self.rsi.is_ready()
85    }
86
87    #[inline]
88    fn name(&self) -> &'static str {
89        "FisherRSI"
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::traits::BatchExt;
97    use approx::assert_relative_eq;
98
99    #[test]
100    fn rejects_zero_period() {
101        assert!(FisherRsi::new(0).is_err());
102    }
103
104    /// Cover the const accessor `period` and the Indicator-impl `warmup_period`
105    /// + `name`.
106    #[test]
107    fn accessors_and_metadata() {
108        let f = FisherRsi::new(9).unwrap();
109        assert_eq!(f.period(), 9);
110        // RSI warmup is period + 1.
111        assert_eq!(f.warmup_period(), 10);
112        assert_eq!(f.name(), "FisherRSI");
113    }
114
115    #[test]
116    fn warmup_matches_rsi() {
117        let mut f = FisherRsi::new(3).unwrap();
118        // RSI(3) needs 4 inputs; the first three return None.
119        assert_eq!(f.update(1.0), None);
120        assert_eq!(f.update(2.0), None);
121        assert_eq!(f.update(3.0), None);
122        assert!(f.update(4.0).is_some());
123    }
124
125    #[test]
126    fn matches_fisher_of_rsi() {
127        // Fisher RSI must equal the Fisher transform of the standalone RSI.
128        let prices: Vec<f64> = (0..60)
129            .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
130            .collect();
131        let mut fr = FisherRsi::new(9).unwrap();
132        let mut rsi = Rsi::new(9).unwrap();
133        for (i, &p) in prices.iter().enumerate() {
134            let got = fr.update(p);
135            let want = rsi.update(p).map(|r| {
136                let x = ((r - 50.0) / 50.0).clamp(-0.999, 0.999);
137                0.5 * ((1.0 + x) / (1.0 - x)).ln()
138            });
139            assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
140            if let (Some(a), Some(b)) = (got, want) {
141                assert_relative_eq!(a, b, epsilon = 1e-12);
142            }
143        }
144    }
145
146    #[test]
147    fn strong_uptrend_is_positive() {
148        // A pure uptrend pins RSI near 100 -> x near +1 -> large positive Fisher.
149        let prices: Vec<f64> = (1..=40).map(f64::from).collect();
150        let mut f = FisherRsi::new(9).unwrap();
151        let last = f.batch(&prices).into_iter().flatten().last().unwrap();
152        assert!(
153            last > 1.0,
154            "strong uptrend should give a large positive value, got {last}"
155        );
156    }
157
158    #[test]
159    fn clamp_keeps_output_finite_at_extremes() {
160        // Monotonic rise pins RSI at 100; the clamp must keep Fisher finite.
161        let prices: Vec<f64> = (1..=30).map(f64::from).collect();
162        let mut f = FisherRsi::new(5).unwrap();
163        for v in f.batch(&prices).into_iter().flatten() {
164            assert!(v.is_finite(), "Fisher RSI must stay finite, got {v}");
165        }
166    }
167
168    #[test]
169    fn reset_clears_state() {
170        let mut f = FisherRsi::new(5).unwrap();
171        f.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
172        assert!(f.is_ready());
173        f.reset();
174        assert!(!f.is_ready());
175        assert_eq!(f.update(1.0), None);
176    }
177
178    #[test]
179    fn batch_equals_streaming() {
180        let prices: Vec<f64> = (1..=40)
181            .map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
182            .collect();
183        let mut a = FisherRsi::new(9).unwrap();
184        let mut b = FisherRsi::new(9).unwrap();
185        assert_eq!(
186            a.batch(&prices),
187            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
188        );
189    }
190}