fin_primitives/technical/
mod.rs1#[derive(Debug, Clone, PartialEq)]
12pub struct Ohlcv {
13 pub open: f64,
15 pub high: f64,
17 pub low: f64,
19 pub close: f64,
21 pub volume: f64,
23 pub timestamp_ms: u64,
25}
26
27pub 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
53pub 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 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
83pub 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 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 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
133pub 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 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 let signal_line = ema(&macd_line, signal);
159
160 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
170pub 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
202pub 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 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
242pub 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
270pub 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
307pub enum CandlePattern {
308 Doji,
310 Hammer,
312 InvertedHammer,
314 BullishEngulfing,
316 BearishEngulfing,
318 MorningStar,
320 EveningStar,
322 ThreeWhiteSoldiers,
324 ThreeBlackCrows,
326}
327
328pub 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 if range > 0.0 && body / range < 0.1 {
343 results.push((i, CandlePattern::Doji));
344 }
345
346 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 if upper_wick >= 2.0 * body && lower_wick <= 0.5 * body {
355 results.push((i, CandlePattern::InvertedHammer));
356 }
357 }
358
359 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 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 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 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 if b0_bearish && b1_small && curr_bullish && bar.close > (b0.open + b0.close) / 2.0 {
401 results.push((i, CandlePattern::MorningStar));
402 }
403
404 if b0_bullish && b1_small && curr_bearish && bar.close < (b0.open + b0.close) / 2.0 {
406 results.push((i, CandlePattern::EveningStar));
407 }
408
409 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 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#[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 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 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 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), bar(9.5, 13.5, 9.0, 13.0), ];
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), bar(11.0, 11.5, 9.0, 9.5), ];
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}