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    fn update(&mut self, input: f64) -> Option<f64> {
67        let rsi = self.rsi.update(input)?;
68        let x = ((rsi - 50.0) / 50.0).clamp(-0.999, 0.999);
69        Some(0.5 * ((1.0 + x) / (1.0 - x)).ln())
70    }
71
72    fn reset(&mut self) {
73        self.rsi.reset();
74    }
75
76    fn warmup_period(&self) -> usize {
77        self.rsi.warmup_period()
78    }
79
80    fn is_ready(&self) -> bool {
81        self.rsi.is_ready()
82    }
83
84    fn name(&self) -> &'static str {
85        "FisherRSI"
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use crate::traits::BatchExt;
93    use approx::assert_relative_eq;
94
95    #[test]
96    fn rejects_zero_period() {
97        assert!(FisherRsi::new(0).is_err());
98    }
99
100    /// Cover the const accessor `period` and the Indicator-impl `warmup_period`
101    /// + `name`.
102    #[test]
103    fn accessors_and_metadata() {
104        let f = FisherRsi::new(9).unwrap();
105        assert_eq!(f.period(), 9);
106        // RSI warmup is period + 1.
107        assert_eq!(f.warmup_period(), 10);
108        assert_eq!(f.name(), "FisherRSI");
109    }
110
111    #[test]
112    fn warmup_matches_rsi() {
113        let mut f = FisherRsi::new(3).unwrap();
114        // RSI(3) needs 4 inputs; the first three return None.
115        assert_eq!(f.update(1.0), None);
116        assert_eq!(f.update(2.0), None);
117        assert_eq!(f.update(3.0), None);
118        assert!(f.update(4.0).is_some());
119    }
120
121    #[test]
122    fn matches_fisher_of_rsi() {
123        // Fisher RSI must equal the Fisher transform of the standalone RSI.
124        let prices: Vec<f64> = (0..60)
125            .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
126            .collect();
127        let mut fr = FisherRsi::new(9).unwrap();
128        let mut rsi = Rsi::new(9).unwrap();
129        for (i, &p) in prices.iter().enumerate() {
130            let got = fr.update(p);
131            let want = rsi.update(p).map(|r| {
132                let x = ((r - 50.0) / 50.0).clamp(-0.999, 0.999);
133                0.5 * ((1.0 + x) / (1.0 - x)).ln()
134            });
135            assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
136            if let (Some(a), Some(b)) = (got, want) {
137                assert_relative_eq!(a, b, epsilon = 1e-12);
138            }
139        }
140    }
141
142    #[test]
143    fn strong_uptrend_is_positive() {
144        // A pure uptrend pins RSI near 100 -> x near +1 -> large positive Fisher.
145        let prices: Vec<f64> = (1..=40).map(f64::from).collect();
146        let mut f = FisherRsi::new(9).unwrap();
147        let last = f.batch(&prices).into_iter().flatten().last().unwrap();
148        assert!(
149            last > 1.0,
150            "strong uptrend should give a large positive value, got {last}"
151        );
152    }
153
154    #[test]
155    fn clamp_keeps_output_finite_at_extremes() {
156        // Monotonic rise pins RSI at 100; the clamp must keep Fisher finite.
157        let prices: Vec<f64> = (1..=30).map(f64::from).collect();
158        let mut f = FisherRsi::new(5).unwrap();
159        for v in f.batch(&prices).into_iter().flatten() {
160            assert!(v.is_finite(), "Fisher RSI must stay finite, got {v}");
161        }
162    }
163
164    #[test]
165    fn reset_clears_state() {
166        let mut f = FisherRsi::new(5).unwrap();
167        f.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
168        assert!(f.is_ready());
169        f.reset();
170        assert!(!f.is_ready());
171        assert_eq!(f.update(1.0), None);
172    }
173
174    #[test]
175    fn batch_equals_streaming() {
176        let prices: Vec<f64> = (1..=40)
177            .map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
178            .collect();
179        let mut a = FisherRsi::new(9).unwrap();
180        let mut b = FisherRsi::new(9).unwrap();
181        assert_eq!(
182            a.batch(&prices),
183            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
184        );
185    }
186}