Skip to main content

portfolio_analyzer/
indicators.rs

1/// Technical indicators: Bollinger Bands, Keltner Channels, AO, EMA, SMF derivative
2
3#[derive(Debug, Clone)]
4pub struct OHLCV {
5    pub open: f64,
6    pub high: f64,
7    pub low: f64,
8    pub close: f64,
9    pub volume: f64,
10}
11
12// ── Utilities ──────────────────────────────────────────────────────────────────
13pub fn sma(data: &[f64], length: usize) -> Vec<f64> {
14    let n = data.len();
15    let mut out = vec![f64::NAN; n];
16    for i in (length - 1)..n {
17        let s: f64 = data[i + 1 - length..=i].iter().sum();
18        out[i] = s / length as f64;
19    }
20    out
21}
22
23pub fn ema(data: &[f64], span: usize) -> Vec<f64> {
24    let n = data.len();
25    let mut out = vec![f64::NAN; n];
26    let alpha = 2.0 / (span as f64 + 1.0);
27    let mut prev = f64::NAN;
28    for i in 0..n {
29        if data[i].is_nan() {
30            out[i] = prev;
31        } else if prev.is_nan() {
32            prev = data[i];
33            out[i] = data[i];
34        } else {
35            prev = alpha * data[i] + (1.0 - alpha) * prev;
36            out[i] = prev;
37        }
38    }
39    out
40}
41
42pub fn rolling_std(data: &[f64], length: usize) -> Vec<f64> {
43    let n = data.len();
44    let mut out = vec![f64::NAN; n];
45    for i in (length - 1)..n {
46        let window = &data[i + 1 - length..=i];
47        let mean = window.iter().sum::<f64>() / length as f64;
48        let var = window.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / length as f64;
49        out[i] = var.sqrt();
50    }
51    out
52}
53
54fn true_range(bars: &[OHLCV]) -> Vec<f64> {
55    let n = bars.len();
56    let mut tr = vec![0.0f64; n];
57    for i in 0..n {
58        let hl = bars[i].high - bars[i].low;
59        if i == 0 {
60            tr[i] = hl;
61        } else {
62            let hc = (bars[i].high - bars[i - 1].close).abs();
63            let lc = (bars[i].low - bars[i - 1].close).abs();
64            tr[i] = hl.max(hc).max(lc);
65        }
66    }
67    tr
68}
69
70// ── Bollinger Bands ────────────────────────────────────────────────────────────
71pub struct BB {
72    pub upper: Vec<f64>,
73    pub lower: Vec<f64>,
74    pub mid: Vec<f64>,
75}
76
77pub fn bollinger_bands(close: &[f64], length: usize, num_std: f64) -> BB {
78    let mid = sma(close, length);
79    let std = rolling_std(close, length);
80    let n = close.len();
81    let mut upper = vec![f64::NAN; n];
82    let mut lower = vec![f64::NAN; n];
83    for i in 0..n {
84        if !mid[i].is_nan() && !std[i].is_nan() {
85            upper[i] = mid[i] + num_std * std[i];
86            lower[i] = mid[i] - num_std * std[i];
87        }
88    }
89    BB { upper, lower, mid }
90}
91
92// ── Keltner Channels ──────────────────────────────────────────────────────────
93pub struct KC {
94    pub upper: Vec<f64>,
95    pub lower: Vec<f64>,
96    pub mid: Vec<f64>,
97}
98
99pub fn keltner_channels(bars: &[OHLCV], length: usize, factor: f64) -> KC {
100    let close: Vec<f64> = bars.iter().map(|b| b.close).collect();
101    let mid = sma(&close, length);
102    let tr = true_range(bars);
103    let atr = sma(&tr, length);
104    let n = bars.len();
105    let mut upper = vec![f64::NAN; n];
106    let mut lower = vec![f64::NAN; n];
107    for i in 0..n {
108        if !mid[i].is_nan() && !atr[i].is_nan() {
109            upper[i] = mid[i] + factor * atr[i];
110            lower[i] = mid[i] - factor * atr[i];
111        }
112    }
113    KC { upper, lower, mid }
114}
115
116// ── Compression (SqueezePRO) ──────────────────────────────────────────────────
117pub fn compute_compression(bb: &BB, kc_high: &KC, kc_mid: &KC, kc_low: &KC) -> Vec<u8> {
118    let n = bb.upper.len();
119    let mut comp = vec![0u8; n];
120    for (i, state) in comp.iter_mut().enumerate() {
121        let bu = bb.upper[i];
122        if bu.is_nan() {
123            continue;
124        }
125        // The paper and the chart/scanner ThinkScripts define compression from
126        // the upper-band relation.  With equal SMA lengths the lower relation
127        // is algebraically redundant, but checking it made the Rust result
128        // diverge whenever channel centers or warm-up availability differed.
129        if bu <= kc_high.upper[i] {
130            *state = 3;
131        } else if bu <= kc_mid.upper[i] {
132            *state = 2;
133        } else if bu <= kc_low.upper[i] {
134            *state = 1;
135        }
136    }
137    comp
138}
139
140// ── Awesome Oscillator ────────────────────────────────────────────────────────
141pub fn awesome_oscillator(bars: &[OHLCV]) -> Vec<f64> {
142    let hl2: Vec<f64> = bars.iter().map(|b| (b.high + b.low) / 2.0).collect();
143    let fast = sma(&hl2, 5);
144    let slow = sma(&hl2, 34);
145    fast.iter()
146        .zip(slow.iter())
147        .map(|(f, s)| {
148            if f.is_nan() || s.is_nan() {
149                f64::NAN
150            } else {
151                f - s
152            }
153        })
154        .collect()
155}
156
157// ── SMF (Auto-Covariance Weighted) Derivative ────────────────────────────────
158pub fn smf_derivative(series: &[f64], window: usize) -> Vec<f64> {
159    let n = series.len();
160    if n == 0 || window == 0 {
161        return Vec::new();
162    }
163
164    // ThinkScript first builds a rolling deviation series, then takes rolling
165    // averages of dev[t] * dev[t-lag].  Re-centering one current window and
166    // correlating within it is not equivalent because historical dev values
167    // were computed with their own historical rolling means.
168    let mean = sma(series, window);
169    let dev = series
170        .iter()
171        .zip(mean.iter())
172        .map(|(value, mean)| value - mean)
173        .collect::<Vec<_>>();
174
175    let covariance = |lag: usize| {
176        let products = (0..n)
177            .map(|i| {
178                if i < lag {
179                    f64::NAN
180                } else {
181                    dev[i] * dev[i - lag]
182                }
183            })
184            .collect::<Vec<_>>();
185        sma(&products, window)
186    };
187
188    let covariances = [
189        covariance(0),
190        covariance(1),
191        covariance(2),
192        covariance(3),
193        covariance(4),
194    ];
195    let mut result = vec![f64::NAN; n];
196    for i in 4..n {
197        let weights = [
198            covariances[0][i],
199            covariances[1][i],
200            covariances[2][i],
201            covariances[3][i],
202            covariances[4][i],
203        ];
204        if weights.iter().any(|weight| !weight.is_finite()) {
205            continue;
206        }
207        let sum_cov = weights.iter().sum::<f64>();
208        result[i] = if sum_cov == 0.0 {
209            // `series` is already dp/dE8/dE21. ThinkScript falls back to that
210            // first difference, not to a second difference.
211            series[i]
212        } else {
213            weights
214                .iter()
215                .enumerate()
216                .map(|(lag, weight)| weight * series[i - lag])
217                .sum::<f64>()
218                / sum_cov.abs()
219        };
220    }
221    result
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    fn bands(upper: Vec<f64>, lower: Vec<f64>) -> BB {
229        let n = upper.len();
230        BB {
231            upper,
232            lower,
233            mid: vec![0.0; n],
234        }
235    }
236
237    fn channels(upper: Vec<f64>, lower: Vec<f64>) -> KC {
238        let n = upper.len();
239        KC {
240            upper,
241            lower,
242            mid: vec![0.0; n],
243        }
244    }
245
246    #[test]
247    fn compression_matches_thinkscript_upper_band_definition() {
248        let bb = bands(vec![9.0, 12.0, 17.0, 25.0], vec![-100.0; 4]);
249        let high = channels(vec![10.0; 4], vec![100.0; 4]);
250        let mid = channels(vec![15.0; 4], vec![100.0; 4]);
251        let low = channels(vec![20.0; 4], vec![100.0; 4]);
252
253        assert_eq!(
254            compute_compression(&bb, &high, &mid, &low),
255            vec![3, 2, 1, 0]
256        );
257    }
258
259    #[test]
260    fn zero_covariance_smf_falls_back_to_the_input_first_difference() {
261        let differences = vec![2.0; 80];
262        let derivative = smf_derivative(&differences, 21);
263
264        assert_eq!(derivative[79], 2.0);
265    }
266
267    #[test]
268    fn smf_uses_historical_rolling_deviations_like_thinkscript() {
269        let series = (0..96)
270            .map(|i| (i as f64 * 0.37).sin() + i as f64 * 0.013)
271            .collect::<Vec<_>>();
272        let actual = smf_derivative(&series, 21);
273
274        // Golden values were calculated from the literal ThinkScript order:
275        // rolling mean -> dev -> rolling Average(dev * dev[lag]) -> SMF.
276        let expected = [
277            (72, 1.729_684_523_073_122),
278            (84, 0.392_341_408_550_435_14),
279            (95, 1.127_093_313_500_873_4),
280        ];
281        for (index, expected) in expected {
282            assert!((actual[index] - expected).abs() < 1e-12);
283        }
284    }
285}