Skip to main content

fin_primitives/technical/
mod.rs

1//! Technical analysis indicators for OHLCV price series.
2//!
3//! All functions return `Vec<f64>` of the same length as the input.
4//! Values before the warm-up period are filled with `0.0`.
5
6// ─────────────────────────────────────────────────────────────────────────────
7// OHLCV bar
8// ─────────────────────────────────────────────────────────────────────────────
9
10/// A single OHLCV bar.
11#[derive(Debug, Clone, PartialEq)]
12pub struct Ohlcv {
13    /// Opening price.
14    pub open: f64,
15    /// High price.
16    pub high: f64,
17    /// Low price.
18    pub low: f64,
19    /// Closing price.
20    pub close: f64,
21    /// Traded volume.
22    pub volume: f64,
23    /// Bar open time in milliseconds since the Unix epoch.
24    pub timestamp_ms: u64,
25}
26
27// ─────────────────────────────────────────────────────────────────────────────
28// Simple Moving Average
29// ─────────────────────────────────────────────────────────────────────────────
30
31/// Simple moving average over `period` bars.
32///
33/// The first `period - 1` values are 0.0.
34pub fn sma(prices: &[f64], period: usize) -> Vec<f64> {
35    let n = prices.len();
36    if period == 0 || n == 0 {
37        return vec![0.0; n];
38    }
39    let mut out = vec![0.0; n];
40    let mut window_sum = 0.0;
41    for (i, &p) in prices.iter().enumerate() {
42        window_sum += p;
43        if i >= period {
44            window_sum -= prices[i - period];
45        }
46        if i + 1 >= period {
47            out[i] = window_sum / period as f64;
48        }
49    }
50    out
51}
52
53// ─────────────────────────────────────────────────────────────────────────────
54// Exponential Moving Average
55// ─────────────────────────────────────────────────────────────────────────────
56
57/// Exponential moving average with smoothing factor α = 2 / (period + 1).
58///
59/// The first `period - 1` values are 0.0; the seed value at index `period - 1`
60/// is the simple average of the first `period` prices.
61pub fn ema(prices: &[f64], period: usize) -> Vec<f64> {
62    let n = prices.len();
63    if period == 0 || n == 0 {
64        return vec![0.0; n];
65    }
66    let mut out = vec![0.0; n];
67    if period > n {
68        return out;
69    }
70    let alpha = 2.0 / (period as f64 + 1.0);
71    // Seed: SMA of first `period` prices.
72    let seed: f64 = prices[..period].iter().sum::<f64>() / period as f64;
73    out[period - 1] = seed;
74    let mut prev = seed;
75    for i in period..n {
76        let e = alpha * prices[i] + (1.0 - alpha) * prev;
77        out[i] = e;
78        prev = e;
79    }
80    out
81}
82
83// ─────────────────────────────────────────────────────────────────────────────
84// Relative Strength Index (Wilder)
85// ─────────────────────────────────────────────────────────────────────────────
86
87/// Wilder's RSI over `period` bars.
88///
89/// Uses Wilder's smoothed averages (equivalent to EMA with α = 1/period).
90/// The first `period` values are 0.0.
91pub fn rsi(prices: &[f64], period: usize) -> Vec<f64> {
92    let n = prices.len();
93    if period == 0 || n < 2 {
94        return vec![0.0; n];
95    }
96    let mut out = vec![0.0; n];
97
98    // Compute first-order differences.
99    let mut gains = vec![0.0; n];
100    let mut losses = vec![0.0; n];
101    for i in 1..n {
102        let diff = prices[i] - prices[i - 1];
103        if diff > 0.0 {
104            gains[i] = diff;
105        } else {
106            losses[i] = -diff;
107        }
108    }
109
110    if n <= period {
111        return out;
112    }
113
114    // Seed: simple average over first `period` differences (indices 1..=period).
115    let avg_gain_seed: f64 = gains[1..=period].iter().sum::<f64>() / period as f64;
116    let avg_loss_seed: f64 = losses[1..=period].iter().sum::<f64>() / period as f64;
117
118    let mut avg_gain = avg_gain_seed;
119    let mut avg_loss = avg_loss_seed;
120
121    let rs = if avg_loss == 0.0 { f64::INFINITY } else { avg_gain / avg_loss };
122    out[period] = 100.0 - 100.0 / (1.0 + rs);
123
124    for i in (period + 1)..n {
125        avg_gain = (avg_gain * (period as f64 - 1.0) + gains[i]) / period as f64;
126        avg_loss = (avg_loss * (period as f64 - 1.0) + losses[i]) / period as f64;
127        let rs = if avg_loss == 0.0 { f64::INFINITY } else { avg_gain / avg_loss };
128        out[i] = 100.0 - 100.0 / (1.0 + rs);
129    }
130    out
131}
132
133// ─────────────────────────────────────────────────────────────────────────────
134// MACD
135// ─────────────────────────────────────────────────────────────────────────────
136
137/// MACD indicator: (macd_line, signal_line, histogram).
138///
139/// `fast` / `slow` / `signal` are EMA periods.
140/// Values before warm-up are 0.0.
141pub fn macd(
142    prices: &[f64],
143    fast: usize,
144    slow: usize,
145    signal: usize,
146) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
147    let n = prices.len();
148    let ema_fast = ema(prices, fast);
149    let ema_slow = ema(prices, slow);
150
151    // MACD line = fast EMA − slow EMA (non-zero only where both have warmed up).
152    let mut macd_line = vec![0.0; n];
153    for i in (slow - 1).min(n - 1)..n {
154        macd_line[i] = ema_fast[i] - ema_slow[i];
155    }
156
157    // Signal line = EMA of MACD line (treat 0.0 prefix as if the series starts at slow-1).
158    let signal_line = ema(&macd_line, signal);
159
160    // Histogram = MACD − signal.
161    let histogram: Vec<f64> = macd_line
162        .iter()
163        .zip(signal_line.iter())
164        .map(|(&m, &s)| m - s)
165        .collect();
166
167    (macd_line, signal_line, histogram)
168}
169
170// ─────────────────────────────────────────────────────────────────────────────
171// Bollinger Bands
172// ─────────────────────────────────────────────────────────────────────────────
173
174/// Bollinger Bands: (upper, middle, lower).
175///
176/// Middle = SMA; upper/lower = middle ± `std_dev_mult` × rolling std dev.
177/// First `period - 1` values are 0.0 in all three bands.
178pub fn bollinger_bands(
179    prices: &[f64],
180    period: usize,
181    std_dev_mult: f64,
182) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
183    let n = prices.len();
184    if period == 0 || n == 0 {
185        return (vec![0.0; n], vec![0.0; n], vec![0.0; n]);
186    }
187    let middle = sma(prices, period);
188    let mut upper = vec![0.0; n];
189    let mut lower = vec![0.0; n];
190
191    for i in (period - 1)..n {
192        let window = &prices[(i + 1 - period)..=i];
193        let mean = middle[i];
194        let variance = window.iter().map(|&p| (p - mean).powi(2)).sum::<f64>() / period as f64;
195        let std_dev = variance.sqrt();
196        upper[i] = mean + std_dev_mult * std_dev;
197        lower[i] = mean - std_dev_mult * std_dev;
198    }
199    (upper, middle, lower)
200}
201
202// ─────────────────────────────────────────────────────────────────────────────
203// Average True Range
204// ─────────────────────────────────────────────────────────────────────────────
205
206/// Average True Range (Wilder's smoothing) over `period` bars.
207///
208/// True Range = max(H−L, |H−prev_C|, |L−prev_C|).
209/// The first `period` values are 0.0.
210pub fn atr(ohlcv: &[Ohlcv], period: usize) -> Vec<f64> {
211    let n = ohlcv.len();
212    if period == 0 || n < 2 {
213        return vec![0.0; n];
214    }
215    let mut out = vec![0.0; n];
216    let mut tr_vals = vec![0.0; n];
217    for i in 1..n {
218        let h = ohlcv[i].high;
219        let l = ohlcv[i].low;
220        let pc = ohlcv[i - 1].close;
221        tr_vals[i] = (h - l).max((h - pc).abs()).max((l - pc).abs());
222    }
223
224    if period > n {
225        return out;
226    }
227
228    // Seed: average of first `period` TR values (indices 1..=period).
229    let seed: f64 = tr_vals[1..=period.min(n - 1)].iter().sum::<f64>() / period as f64;
230    if period < n {
231        out[period] = seed;
232    }
233    let mut prev = seed;
234    for i in (period + 1)..n {
235        let a = (prev * (period as f64 - 1.0) + tr_vals[i]) / period as f64;
236        out[i] = a;
237        prev = a;
238    }
239    out
240}
241
242// ─────────────────────────────────────────────────────────────────────────────
243// On-Balance Volume
244// ─────────────────────────────────────────────────────────────────────────────
245
246/// On-Balance Volume.
247///
248/// Accumulates volume: +V when close > prev close, −V when close < prev close,
249/// unchanged otherwise.  The first bar carries its own volume as the seed.
250pub fn obv(ohlcv: &[Ohlcv]) -> Vec<f64> {
251    let n = ohlcv.len();
252    if n == 0 {
253        return vec![];
254    }
255    let mut out = vec![0.0; n];
256    out[0] = ohlcv[0].volume;
257    for i in 1..n {
258        let delta = if ohlcv[i].close > ohlcv[i - 1].close {
259            ohlcv[i].volume
260        } else if ohlcv[i].close < ohlcv[i - 1].close {
261            -ohlcv[i].volume
262        } else {
263            0.0
264        };
265        out[i] = out[i - 1] + delta;
266    }
267    out
268}
269
270// ─────────────────────────────────────────────────────────────────────────────
271// Stochastic Oscillator
272// ─────────────────────────────────────────────────────────────────────────────
273
274/// Stochastic oscillator: (%K, %D).
275///
276/// %K = (close − lowest_low) / (highest_high − lowest_low) × 100
277/// %D = SMA(`d_period`) of %K.
278///
279/// Values before warm-up are 0.0.
280pub fn stochastic(ohlcv: &[Ohlcv], k_period: usize, d_period: usize) -> (Vec<f64>, Vec<f64>) {
281    let n = ohlcv.len();
282    if k_period == 0 || n == 0 {
283        return (vec![0.0; n], vec![0.0; n]);
284    }
285    let mut k_vals = vec![0.0; n];
286    for i in (k_period - 1)..n {
287        let window = &ohlcv[(i + 1 - k_period)..=i];
288        let lowest_low = window.iter().map(|b| b.low).fold(f64::INFINITY, f64::min);
289        let highest_high = window.iter().map(|b| b.high).fold(f64::NEG_INFINITY, f64::max);
290        let range = highest_high - lowest_low;
291        k_vals[i] = if range == 0.0 {
292            50.0
293        } else {
294            (ohlcv[i].close - lowest_low) / range * 100.0
295        };
296    }
297    let d_vals = sma(&k_vals, d_period);
298    (k_vals, d_vals)
299}
300
301// ─────────────────────────────────────────────────────────────────────────────
302// Candlestick Patterns
303// ─────────────────────────────────────────────────────────────────────────────
304
305/// Named candlestick patterns.
306#[derive(Debug, Clone, PartialEq, Eq, Hash)]
307pub enum CandlePattern {
308    /// Open ≈ close; tiny body relative to range.
309    Doji,
310    /// Long lower shadow, small upper shadow, small body near top.
311    Hammer,
312    /// Long upper shadow, small lower shadow, small body near bottom.
313    InvertedHammer,
314    /// Bullish bar fully engulfs the prior bearish bar.
315    BullishEngulfing,
316    /// Bearish bar fully engulfs the prior bullish bar.
317    BearishEngulfing,
318    /// Three-bar bottom reversal: bearish, small doji/star, bullish.
319    MorningStar,
320    /// Three-bar top reversal: bullish, small doji/star, bearish.
321    EveningStar,
322    /// Three consecutive bullish bars with higher closes.
323    ThreeWhiteSoldiers,
324    /// Three consecutive bearish bars with lower closes.
325    ThreeBlackCrows,
326}
327
328/// Detect candlestick patterns in an OHLCV series.
329///
330/// Returns a list of `(bar_index, pattern)` pairs for every detected occurrence.
331/// A single bar may appear more than once if multiple patterns apply.
332pub fn detect_patterns(ohlcv: &[Ohlcv]) -> Vec<(usize, CandlePattern)> {
333    let n = ohlcv.len();
334    let mut results = Vec::new();
335
336    for i in 0..n {
337        let bar = &ohlcv[i];
338        let body = (bar.close - bar.open).abs();
339        let range = bar.high - bar.low;
340
341        // ── Doji ────────────────────────────────────────────────────────────
342        if range > 0.0 && body / range < 0.1 {
343            results.push((i, CandlePattern::Doji));
344        }
345
346        // ── Hammer ──────────────────────────────────────────────────────────
347        if body > 0.0 && range > 0.0 {
348            let upper_wick = bar.high - bar.close.max(bar.open);
349            let lower_wick = bar.close.min(bar.open) - bar.low;
350            if lower_wick >= 2.0 * body && upper_wick <= 0.5 * body {
351                results.push((i, CandlePattern::Hammer));
352            }
353            // ── InvertedHammer ──────────────────────────────────────────────
354            if upper_wick >= 2.0 * body && lower_wick <= 0.5 * body {
355                results.push((i, CandlePattern::InvertedHammer));
356            }
357        }
358
359        // ── Two-bar patterns ────────────────────────────────────────────────
360        if i >= 1 {
361            let prev = &ohlcv[i - 1];
362            let prev_bearish = prev.close < prev.open;
363            let prev_bullish = prev.close > prev.open;
364            let curr_bullish = bar.close > bar.open;
365            let curr_bearish = bar.close < bar.open;
366
367            // BullishEngulfing
368            if prev_bearish
369                && curr_bullish
370                && bar.open <= prev.close
371                && bar.close >= prev.open
372            {
373                results.push((i, CandlePattern::BullishEngulfing));
374            }
375
376            // BearishEngulfing
377            if prev_bullish
378                && curr_bearish
379                && bar.open >= prev.close
380                && bar.close <= prev.open
381            {
382                results.push((i, CandlePattern::BearishEngulfing));
383            }
384        }
385
386        // ── Three-bar patterns ───────────────────────────────────────────────
387        if i >= 2 {
388            let b0 = &ohlcv[i - 2];
389            let b1 = &ohlcv[i - 1];
390
391            let b0_bearish = b0.close < b0.open;
392            let b0_bullish = b0.close > b0.open;
393            let b1_range = b1.high - b1.low;
394            let b1_body = (b1.close - b1.open).abs();
395            let b1_small = b1_range > 0.0 && b1_body / b1_range < 0.3;
396            let curr_bullish = bar.close > bar.open;
397            let curr_bearish = bar.close < bar.open;
398
399            // MorningStar: bearish, small star, bullish
400            if b0_bearish && b1_small && curr_bullish && bar.close > (b0.open + b0.close) / 2.0 {
401                results.push((i, CandlePattern::MorningStar));
402            }
403
404            // EveningStar: bullish, small star, bearish
405            if b0_bullish && b1_small && curr_bearish && bar.close < (b0.open + b0.close) / 2.0 {
406                results.push((i, CandlePattern::EveningStar));
407            }
408
409            // ThreeWhiteSoldiers
410            if b0.close > b0.open
411                && b1.close > b1.open
412                && bar.close > bar.open
413                && b1.close > b0.close
414                && bar.close > b1.close
415            {
416                results.push((i, CandlePattern::ThreeWhiteSoldiers));
417            }
418
419            // ThreeBlackCrows
420            if b0.close < b0.open
421                && b1.close < b1.open
422                && bar.close < bar.open
423                && b1.close < b0.close
424                && bar.close < b1.close
425            {
426                results.push((i, CandlePattern::ThreeBlackCrows));
427            }
428        }
429    }
430
431    results
432}
433
434// ─────────────────────────────────────────────────────────────────────────────
435// Tests
436// ─────────────────────────────────────────────────────────────────────────────
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    fn bar(o: f64, h: f64, l: f64, c: f64) -> Ohlcv {
443        Ohlcv { open: o, high: h, low: l, close: c, volume: 1000.0, timestamp_ms: 0 }
444    }
445
446    #[test]
447    fn sma_known_series() {
448        let prices = vec![1.0, 2.0, 3.0, 4.0, 5.0];
449        let out = sma(&prices, 3);
450        assert_eq!(out[0], 0.0);
451        assert_eq!(out[1], 0.0);
452        assert!((out[2] - 2.0).abs() < 1e-10);
453        assert!((out[3] - 3.0).abs() < 1e-10);
454        assert!((out[4] - 4.0).abs() < 1e-10);
455    }
456
457    #[test]
458    fn ema_seed_equals_sma() {
459        let prices = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
460        let out_ema = ema(&prices, 3);
461        let out_sma = sma(&prices, 3);
462        // At the seed index (2) EMA should equal SMA.
463        assert!((out_ema[2] - out_sma[2]).abs() < 1e-10);
464    }
465
466    #[test]
467    fn rsi_in_range() {
468        let prices: Vec<f64> = (0..30).map(|i| 100.0 + (i as f64).sin() * 5.0).collect();
469        let out = rsi(&prices, 14);
470        for &v in out.iter().skip(14) {
471            assert!(v >= 0.0 && v <= 100.0, "RSI out of range: {v}");
472        }
473    }
474
475    #[test]
476    fn macd_signal_crossover_exists() {
477        // Steadily rising then falling prices should produce at least one crossover.
478        let mut prices: Vec<f64> = (0..50).map(|i| i as f64).collect();
479        prices.extend((0..50).map(|i| 49.0 - i as f64));
480        let (macd_line, signal_line, _hist) = macd(&prices, 12, 26, 9);
481        let crossovers = macd_line
482            .iter()
483            .zip(signal_line.iter())
484            .zip(macd_line.iter().skip(1).zip(signal_line.iter().skip(1)))
485            .filter(|((m0, s0), (m1, s1))| (m0 > s0) != (m1 > s1))
486            .count();
487        assert!(crossovers > 0, "expected at least one MACD/signal crossover");
488    }
489
490    #[test]
491    fn bollinger_width_positive() {
492        let prices: Vec<f64> = (0..30).map(|i| 100.0 + (i % 5) as f64).collect();
493        let (upper, middle, lower) = bollinger_bands(&prices, 10, 2.0);
494        for i in 9..30 {
495            assert!(upper[i] > lower[i], "upper <= lower at index {i}");
496            assert!((upper[i] + lower[i]) / 2.0 - middle[i] < 1e-9);
497        }
498    }
499
500    #[test]
501    fn doji_detection() {
502        // Perfect doji: open == close, has range.
503        let bars = vec![bar(10.0, 12.0, 8.0, 10.0)];
504        let patterns = detect_patterns(&bars);
505        let has_doji = patterns.iter().any(|(_, p)| *p == CandlePattern::Doji);
506        assert!(has_doji, "should detect doji");
507    }
508
509    #[test]
510    fn bullish_engulfing_detection() {
511        let bars = vec![
512            bar(12.0, 13.0, 10.0, 10.5), // bearish
513            bar(9.5, 13.5, 9.0, 13.0),   // bullish engulfing
514        ];
515        let patterns = detect_patterns(&bars);
516        let found = patterns.iter().any(|(_, p)| *p == CandlePattern::BullishEngulfing);
517        assert!(found, "should detect BullishEngulfing");
518    }
519
520    #[test]
521    fn atr_positive() {
522        let bars: Vec<Ohlcv> = (0..20)
523            .map(|i| Ohlcv {
524                open: 100.0,
525                high: 102.0 + i as f64 * 0.1,
526                low: 98.0 - i as f64 * 0.1,
527                close: 100.5,
528                volume: 500.0,
529                timestamp_ms: i * 1000,
530            })
531            .collect();
532        let out = atr(&bars, 14);
533        assert!(out[14] > 0.0, "ATR should be positive");
534    }
535
536    #[test]
537    fn obv_accumulates_correctly() {
538        let bars = vec![
539            bar(10.0, 11.0, 9.0, 10.0),
540            bar(10.0, 12.0, 9.5, 11.0), // close higher → +volume
541            bar(11.0, 11.5, 9.0, 9.5),  // close lower → -volume
542        ];
543        let out = obv(&bars);
544        assert_eq!(out[0], 1000.0);
545        assert_eq!(out[1], 2000.0);
546        assert_eq!(out[2], 1000.0);
547    }
548
549    #[test]
550    fn stochastic_in_range() {
551        let bars: Vec<Ohlcv> = (0..20)
552            .map(|i| Ohlcv {
553                open: 100.0,
554                high: 100.0 + (i % 5) as f64,
555                low: 99.0 - (i % 3) as f64,
556                close: 100.0 + (i % 4) as f64 * 0.5,
557                volume: 1000.0,
558                timestamp_ms: i as u64 * 1000,
559            })
560            .collect();
561        let (k, d) = stochastic(&bars, 5, 3);
562        for i in 4..20 {
563            assert!(k[i] >= 0.0 && k[i] <= 100.0, "%K out of range at {i}: {}", k[i]);
564        }
565        for i in 6..20 {
566            assert!(d[i] >= 0.0 && d[i] <= 100.0, "%D out of range at {i}: {}", d[i]);
567        }
568    }
569}