Skip to main content

portfolio_analyzer/sd2/
primitives.rs

1//! # Primitives: the small measuring tools
2//!
3//! Everything here is a plain rolling calculation over a price series.  Each
4//! function returns one value per input bar, and every value at bar `t` uses
5//! only bars `<= t` (no peeking).  Warm-up bars are `NaN`.
6//!
7//! If you have never met these before, the one-line versions are:
8//!
9//! | Tool | One line |
10//! | --- | --- |
11//! | ATR | "How far does price usually travel in one bar?" |
12//! | Standard deviation | "How spread out have closes been?" |
13//! | Linear regression slope | "If I drew the best straight line through the last N closes, how steep is it?" |
14//! | RSI | "Of the last N bars' movement, how much was up?" (0-100) |
15//! | MACD histogram | "Is the fast trend pulling away from the slow trend, and is that speeding up?" |
16
17pub use crate::indicators::{ema, rolling_std, sma, OHLCV};
18
19/// True range: the biggest of (high-low), |high-prev close|, |low-prev close|.
20pub fn true_range(bars: &[OHLCV]) -> Vec<f64> {
21    (0..bars.len())
22        .map(|i| {
23            let b = &bars[i];
24            if i == 0 {
25                b.high - b.low
26            } else {
27                let pc = bars[i - 1].close;
28                (b.high - b.low).max((b.high - pc).abs()).max((b.low - pc).abs())
29            }
30        })
31        .collect()
32}
33
34/// Average true range as a simple average (matches ThinkScript's Keltner
35/// channel with `averageType = SIMPLE`).
36pub fn atr(bars: &[OHLCV], length: usize) -> Vec<f64> {
37    sma(&true_range(bars), length)
38}
39
40/// Rolling maximum / minimum over `length` bars.
41pub fn highest(data: &[f64], length: usize) -> Vec<f64> {
42    rolling(data, length, f64::max, f64::NEG_INFINITY)
43}
44pub fn lowest(data: &[f64], length: usize) -> Vec<f64> {
45    rolling(data, length, f64::min, f64::INFINITY)
46}
47fn rolling(data: &[f64], length: usize, f: fn(f64, f64) -> f64, init: f64) -> Vec<f64> {
48    let mut out = vec![f64::NAN; data.len()];
49    for i in (length.saturating_sub(1))..data.len() {
50        out[i] = data[i + 1 - length..=i].iter().copied().fold(init, f);
51    }
52    out
53}
54
55/// Slope-adjusted endpoint of a least-squares line through the last
56/// `length` values (ThinkScript `Inertia`, a.k.a. `LinearRegCurve`).
57pub fn linreg_value(data: &[f64], length: usize) -> Vec<f64> {
58    let mut out = vec![f64::NAN; data.len()];
59    let n = length as f64;
60    let sx = (0..length).map(|x| x as f64).sum::<f64>();
61    let sxx = (0..length).map(|x| (x * x) as f64).sum::<f64>();
62    for i in (length - 1)..data.len() {
63        let w = &data[i + 1 - length..=i];
64        if w.iter().any(|v| !v.is_finite()) {
65            continue;
66        }
67        let sy: f64 = w.iter().sum();
68        let sxy: f64 = w.iter().enumerate().map(|(x, y)| x as f64 * y).sum();
69        let slope = (n * sxy - sx * sy) / (n * sxx - sx * sx);
70        let intercept = (sy - slope * sx) / n;
71        out[i] = intercept + slope * (n - 1.0);
72    }
73    out
74}
75
76/// Wilder RSI.
77pub fn rsi(close: &[f64], length: usize) -> Vec<f64> {
78    let n = close.len();
79    let mut out = vec![f64::NAN; n];
80    let (mut up, mut down) = (0.0, 0.0);
81    let a = 1.0 / length as f64;
82    for i in 1..n {
83        let d = close[i] - close[i - 1];
84        let (u, dn) = (d.max(0.0), (-d).max(0.0));
85        if i <= length {
86            up += u / length as f64;
87            down += dn / length as f64;
88        } else {
89            up = up * (1.0 - a) + u * a;
90            down = down * (1.0 - a) + dn * a;
91        }
92        if i >= length {
93            out[i] = if up + down == 0.0 { 50.0 } else { 100.0 * up / (up + down) };
94        }
95    }
96    out
97}
98
99/// MACD histogram (12, 26, 9).
100pub fn macd_hist(close: &[f64]) -> Vec<f64> {
101    let line: Vec<f64> = ema(close, 12).iter().zip(ema(close, 26)).map(|(a, b)| a - b).collect();
102    let signal = ema(&line, 9);
103    line.iter().zip(signal).map(|(l, s)| l - s).collect()
104}
105
106/// `x[t] - x[t-k]`, `NaN` for the first `k` bars.
107pub fn diff(data: &[f64], k: usize) -> Vec<f64> {
108    (0..data.len())
109        .map(|i| if i >= k { data[i] - data[i - k] } else { f64::NAN })
110        .collect()
111}
112
113/// Divide element-wise, `NaN` where the denominator is not positive.
114pub fn ratio(num: &[f64], den: &[f64]) -> Vec<f64> {
115    num.iter()
116        .zip(den)
117        .map(|(a, b)| if *b > 0.0 { a / b } else { f64::NAN })
118        .collect()
119}
120
121/// A gentle squashing function: `tanh(x / scale)`, so a feature measured in
122/// any units ends up in `(-1, 1)` and one wild bar cannot dominate a blend.
123pub fn squash(x: f64, scale: f64) -> f64 {
124    if x.is_finite() {
125        (x / scale).tanh()
126    } else {
127        0.0
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn linreg_of_a_straight_line_is_its_endpoint() {
137        let line: Vec<f64> = (0..30).map(|x| 2.0 * x as f64 + 1.0).collect();
138        let v = linreg_value(&line, 20);
139        assert!((v[29] - 59.0).abs() < 1e-9);
140    }
141
142    #[test]
143    fn rsi_of_a_rising_series_is_100() {
144        let up: Vec<f64> = (0..40).map(|x| x as f64).collect();
145        assert_eq!(rsi(&up, 14)[39], 100.0);
146    }
147}