Skip to main content

finance_solution/stocks/ta/
rsi.rs

1//! # Relative Strength Index (RSI)
2//!
3//! Wilder RSI on closes:
4//!
5//! ```text
6//! change[i] = close[i] − close[i−1]
7//! avg_gain, avg_loss: Wilder smooth over `period` (first seed = SMA of gains/losses)
8//! RS  = avg_gain / avg_loss
9//! RSI = 100 − 100 / (1 + RS)
10//! ```
11//!
12//! Default pack: **period 14** ([`RsiParams::period_14`]).
13//!
14//! ---
15//!
16//! ## Trading perspective
17//!
18//! | Region | Habit (classic, not a rule) |
19//! |--------|-----------------------------|
20//! | RSI \> 70 | “Overbought” screen |
21//! | RSI \< 30 | “Oversold” screen |
22//! | Divergences | Price vs RSI direction stories |
23//!
24//! ---
25//!
26//! ## Engineering perspective
27//!
28//! Same TA layers: [`RsiParams`] → [`ValidatedRsi`] / [`rsi`] → [`RsiState`] → [`rsi_solution`].
29//! Warm-up bars are `None` until the Wilder seed is ready (index `period` first possible).
30//!
31//! ## Word problem
32//!
33//! > Fourteen closes are flat then one up-bar. Is RSI defined on the last bar of a 15-long series?
34//!
35//! Yes after seed: first RSI appears at index `period` (needs `period` changes ⇒ `period+1` closes).
36//!
37//! ```
38//! use finance_solution::stocks::ta::{rsi, RsiParams};
39//! let mut c: Vec<f64> = (0..15).map(|i| 100.0 + i as f64).collect();
40//! let s = rsi(&c, RsiParams::period_14()).unwrap();
41//! assert!(s.rsi[14].is_some());
42//! assert!(s.rsi[13].is_none());
43//! ```
44
45use crate::stocks::ta::common::{opt_cell, validate_series};
46use crate::util::error::FinanceResult;
47use crate::util::primitives::PeriodLength;
48use crate::{columns_with_strings, print_table_locale_opt};
49
50/// RSI lookback pack (Wilder).
51#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
52pub struct RsiParams {
53    pub period: usize,
54}
55
56impl RsiParams {
57    pub const fn new(period: usize) -> Self {
58        Self { period }
59    }
60
61    /// Classic 14-period RSI.
62    pub const fn period_14() -> Self {
63        Self { period: 14 }
64    }
65
66    pub const fn period_7() -> Self {
67        Self { period: 7 }
68    }
69}
70
71/// Validated RSI config.
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
73pub struct ValidatedRsi {
74    params: RsiParams,
75}
76
77impl ValidatedRsi {
78    pub fn new(params: RsiParams) -> FinanceResult<Self> {
79        PeriodLength::new(params.period)?;
80        Ok(Self { params })
81    }
82
83    pub fn params(self) -> RsiParams {
84        self.params
85    }
86
87    pub fn compute(self, closes: &[f64]) -> FinanceResult<RsiSeries> {
88        rsi_validated(closes, self)
89    }
90}
91
92#[derive(Clone, Debug, PartialEq)]
93pub struct RsiSeries {
94    pub rsi: Vec<Option<f64>>,
95    pub params: RsiParams,
96}
97
98impl RsiSeries {
99    pub fn last(&self) -> Option<f64> {
100        self.rsi.iter().rev().find_map(|x| *x)
101    }
102}
103
104#[derive(Clone, Debug)]
105pub struct RsiSolution {
106    series: RsiSeries,
107    closes: Vec<f64>,
108    formula: String,
109    symbolic_formula: String,
110}
111
112impl RsiSolution {
113    pub fn series(&self) -> &RsiSeries {
114        &self.series
115    }
116    pub fn formula(&self) -> &str {
117        &self.formula
118    }
119    pub fn symbolic_formula(&self) -> &str {
120        &self.symbolic_formula
121    }
122
123    pub fn print_table(&self) {
124        self.print_table_locale_opt(None, None);
125    }
126
127    pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
128        self.print_table_locale_opt(Some(locale), Some(precision));
129    }
130
131    fn print_table_locale_opt(
132        &self,
133        locale: Option<&num_format::Locale>,
134        precision: Option<usize>,
135    ) {
136        let columns = columns_with_strings(&[
137            ("period", "i", true),
138            ("close", "f", true),
139            ("rsi", "f", true),
140        ]);
141        let data = self
142            .closes
143            .iter()
144            .enumerate()
145            .map(|(i, c)| vec![i.to_string(), c.to_string(), opt_cell(self.series.rsi[i])])
146            .collect();
147        print_table_locale_opt(&columns, data, locale, precision);
148    }
149}
150
151/// Incremental Wilder RSI.
152#[derive(Clone, Debug, PartialEq)]
153pub struct RsiState {
154    params: RsiParams,
155    prev_close: Option<f64>,
156    avg_gain: Option<f64>,
157    avg_loss: Option<f64>,
158    /// Gains/losses buffer until seed length == period.
159    seed_gains: Vec<f64>,
160    seed_losses: Vec<f64>,
161    last: Option<f64>,
162    bars: usize,
163}
164
165impl RsiState {
166    pub fn new(params: RsiParams) -> FinanceResult<Self> {
167        PeriodLength::new(params.period)?;
168        Ok(Self {
169            params,
170            prev_close: None,
171            avg_gain: None,
172            avg_loss: None,
173            seed_gains: Vec::with_capacity(params.period),
174            seed_losses: Vec::with_capacity(params.period),
175            last: None,
176            bars: 0,
177        })
178    }
179
180    pub fn from_history(params: RsiParams, closes: &[f64]) -> FinanceResult<Self> {
181        let mut s = Self::new(params)?;
182        let _ = s.push_bars(closes)?;
183        Ok(s)
184    }
185
186    pub fn push(&mut self, close: f64) -> FinanceResult<Option<f64>> {
187        crate::util::error::require_finite("close", close)?;
188        self.bars += 1;
189        let period = self.params.period;
190        let out = if let Some(prev) = self.prev_close {
191            let ch = close - prev;
192            let gain = ch.max(0.0);
193            let loss = (-ch).max(0.0);
194            if self.avg_gain.is_none() {
195                self.seed_gains.push(gain);
196                self.seed_losses.push(loss);
197                if self.seed_gains.len() == period {
198                    let ag = self.seed_gains.iter().sum::<f64>() / period as f64;
199                    let al = self.seed_losses.iter().sum::<f64>() / period as f64;
200                    self.avg_gain = Some(ag);
201                    self.avg_loss = Some(al);
202                    self.last = Some(rsi_from_avgs(ag, al));
203                    self.last
204                } else {
205                    None
206                }
207            } else {
208                let ag = self.avg_gain.unwrap();
209                let al = self.avg_loss.unwrap();
210                let ag = (ag * (period as f64 - 1.0) + gain) / period as f64;
211                let al = (al * (period as f64 - 1.0) + loss) / period as f64;
212                self.avg_gain = Some(ag);
213                self.avg_loss = Some(al);
214                self.last = Some(rsi_from_avgs(ag, al));
215                self.last
216            }
217        } else {
218            None
219        };
220        self.prev_close = Some(close);
221        Ok(out)
222    }
223
224    pub fn push_bars(&mut self, closes: &[f64]) -> FinanceResult<Vec<Option<f64>>> {
225        let mut out = Vec::with_capacity(closes.len());
226        for &c in closes {
227            out.push(self.push(c)?);
228        }
229        Ok(out)
230    }
231
232    pub fn last(&self) -> Option<f64> {
233        self.last
234    }
235
236    pub fn reset(&mut self) {
237        self.prev_close = None;
238        self.avg_gain = None;
239        self.avg_loss = None;
240        self.seed_gains.clear();
241        self.seed_losses.clear();
242        self.last = None;
243        self.bars = 0;
244    }
245}
246
247fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
248    if avg_loss == 0.0 {
249        return if avg_gain == 0.0 { 50.0 } else { 100.0 };
250    }
251    let rs = avg_gain / avg_loss;
252    100.0 - 100.0 / (1.0 + rs)
253}
254
255pub fn rsi(closes: &[f64], params: RsiParams) -> FinanceResult<RsiSeries> {
256    ValidatedRsi::new(params)?.compute(closes)
257}
258
259fn rsi_validated(closes: &[f64], eng: ValidatedRsi) -> FinanceResult<RsiSeries> {
260    validate_series("close", closes)?;
261    let mut state = RsiState::new(eng.params)?;
262    let rsi = state.push_bars(closes)?;
263    Ok(RsiSeries {
264        rsi,
265        params: eng.params,
266    })
267}
268
269/// # Examples
270/// ```
271/// use finance_solution::stocks::ta::{rsi_solution, RsiParams};
272/// let closes: Vec<f64> = (0..30).map(|i| 100.0 + (i as f64) * 0.5).collect();
273/// let sol = rsi_solution(&closes, RsiParams::period_14()).unwrap();
274/// assert!(sol.series().last().unwrap() > 50.0); // rising path
275/// ```
276pub fn rsi_solution(closes: &[f64], params: RsiParams) -> FinanceResult<RsiSolution> {
277    let series = rsi(closes, params)?;
278    Ok(RsiSolution {
279        series,
280        closes: closes.to_vec(),
281        formula: format!(
282            "RSI({}) Wilder: 100 - 100/(1 + avg_gain/avg_loss)",
283            params.period
284        ),
285        symbolic_formula: "RSI = 100 - 100/(1+RS); RS = Wilder avg gain / avg loss".to_string(),
286    })
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn rising_path_high_rsi() {
295        let c: Vec<f64> = (0..40).map(|i| 100.0 + i as f64).collect();
296        let s = rsi(&c, RsiParams::period_14()).unwrap();
297        let last = s.last().unwrap();
298        assert!(last > 70.0, "rsi={last}");
299    }
300
301    #[test]
302    fn flat_is_50_after_warmup() {
303        let c = vec![100.0; 30];
304        let s = rsi(&c, RsiParams::period_14()).unwrap();
305        let last = s.last().unwrap();
306        assert!((last - 50.0).abs() < 1e-9);
307    }
308
309    #[test]
310    fn state_parity() {
311        let c: Vec<f64> = (0..50)
312            .map(|i| 100.0 + (i % 5) as f64 * 0.2 - 0.3)
313            .collect();
314        let batch = rsi(&c, RsiParams::period_14()).unwrap();
315        let st = RsiState::from_history(RsiParams::period_14(), &c).unwrap();
316        assert!((batch.last().unwrap() - st.last().unwrap()).abs() < 1e-9);
317    }
318
319    #[test]
320    fn falling_path_low_rsi() {
321        let c: Vec<f64> = (0..40).map(|i| 140.0 - i as f64).collect();
322        let last = rsi(&c, RsiParams::period_14()).unwrap().last().unwrap();
323        assert!(last < 30.0, "rsi={last}");
324    }
325
326    #[test]
327    fn warmup_none_before_period() {
328        let c: Vec<f64> = (0..20).map(|i| 100.0 + i as f64 * 0.1).collect();
329        let s = rsi(&c, RsiParams::period_14()).unwrap();
330        assert!(s.rsi[13].is_none());
331        assert!(s.rsi[14].is_some());
332    }
333
334    #[test]
335    fn empty_series_err() {
336        assert!(rsi(&[], RsiParams::period_14()).is_err());
337    }
338
339    #[test]
340    fn zero_period_err() {
341        assert!(RsiState::new(RsiParams::new(0)).is_err());
342    }
343
344    #[test]
345    fn reset_clears_last() {
346        let c: Vec<f64> = (0..30).map(|i| 100.0 + i as f64).collect();
347        let mut st = RsiState::from_history(RsiParams::period_14(), &c).unwrap();
348        assert!(st.last().is_some());
349        st.reset();
350        assert!(st.last().is_none());
351    }
352}