Skip to main content

indicator_math/
lib.rs

1// ============================================================
2// lib.rs — Pure Rust Technical Indicators Library
3// ============================================================
4
5// ------------------------------------------------------------
6// Modules
7// ------------------------------------------------------------
8
9pub mod analysis_generator;
10pub mod indicators;
11
12// Re-exports for convenience
13pub use analysis_generator::{
14    lookup_series_code, AnalysisGenerator, AnalysisOptions, AnalysisSummary, BbPosition,
15    FullAnalysis,
16};
17pub use indicators::{
18    adx, atr, bollinger_bands, bollinger_bands_with_multiplier, choppiness_index, rsi, AdxResult,
19    BollingerBands,
20};
21
22// ------------------------------------------------------------
23// Structs
24// ------------------------------------------------------------
25
26#[derive(Debug, Clone, Copy)]
27pub struct Candle {
28    pub time: u64,
29    pub open: f64,
30    pub high: f64,
31    pub low: f64,
32    pub close: f64,
33}
34
35#[derive(Debug, Clone, Copy)]
36pub struct ValueAtTime {
37    pub time: u64,
38    pub value: f64,
39}
40
41#[derive(Debug, Clone)]
42pub struct EmaAnalysis {
43    pub time_candle: u64,
44    pub index: usize,
45    pub color_candle: String,
46    pub next_color_candle: String,
47
48    // Short EMA
49    pub ema_short_value: f64,
50    pub ema_short_slope_value: f64,
51    pub ema_short_slope_direction: String,
52    pub is_ema_short_turn_type: String, // TurnUp, TurnDown
53    pub ema_short_cut_position: String, // 1, 2, B1, B2, B3, 3, 4
54
55    // Medium EMA
56    pub ema_medium_value: f64,
57    pub ema_medium_slope_direction: String,
58
59    // Long EMA
60    pub ema_long_value: f64,
61    pub ema_long_slope_direction: String,
62
63    // Relationships
64    pub ema_above: String,      // Short vs Medium (ShortAbove, MediumAbove)
65    pub ema_long_above: String, // Medium vs Long (MediumAbove, LongAbove)
66
67    // MACD-like Diffs
68    pub macd_12: f64, // abs(short - medium)
69    pub macd_23: f64, // abs(medium - long)
70
71    // Previous Values
72    pub previous_ema_short_value: f64,
73    pub previous_ema_medium_value: f64,
74    pub previous_ema_long_value: f64,
75    pub previous_macd_12: f64,
76    pub previous_macd_23: f64,
77
78    // Convergence/Divergence
79    pub ema_convergence_type: String, // divergence, convergence, neutral (Short vs Medium)
80    pub ema_long_convergence_type: String, // divergence, convergence, neutral (Medium vs Long)
81
82    // Trends / Crossovers (Short vs Medium)
83    pub ema_cut_short_type: String, // UpTrend (Short crosses Medium Up), DownTrend (Short crosses Medium Down), None
84    pub candles_since_short_cut: usize,
85
86    // Trends / Crossovers (Medium vs Long)
87    pub ema_cut_long_type: String, // UpTrend (Golden Cross), DownTrend (Death Cross), None
88    pub candles_since_ema_cut: usize,
89
90    // Extra context
91    pub previous_color_back1: String,
92    pub previous_color_back3: String,
93}
94
95// ------------------------------------------------------------
96// MA Type Enum
97// ------------------------------------------------------------
98
99#[derive(Debug, Clone, Copy)]
100pub enum MaType {
101    EMA,
102    HMA,
103    WMA,
104    SMA,
105    EHMA,
106}
107
108#[derive(Debug, Clone, Copy)]
109pub enum CutStrategy {
110    ShortCut, // Use ema_cut_short_type + ema_short_slope_direction
111    LongCut,  // Use ema_cut_long_type + ema_medium_slope_direction
112}
113
114// ============================================================
115// Helper Functions
116// ============================================================
117
118fn extract_close(candles: &[Candle]) -> Vec<f64> {
119    candles.iter().map(|c| c.close).collect()
120}
121
122fn wrap_output(candles: &[Candle], values: Vec<f64>) -> Vec<ValueAtTime> {
123    candles
124        .iter()
125        .zip(values.iter())
126        .map(|(c, v)| ValueAtTime {
127            time: c.time,
128            value: *v,
129        })
130        .collect()
131}
132
133// ============================================================
134// Indicators (SMA, EMA, WMA, HMA, EHMA)
135// ============================================================
136
137pub fn sma(candles: &[Candle], period: usize) -> Vec<ValueAtTime> {
138    let prices = extract_close(candles);
139    let mut out = vec![f64::NAN; prices.len()];
140
141    if period == 0 || prices.len() < period {
142        return wrap_output(candles, out);
143    }
144
145    for i in period - 1..prices.len() {
146        let sum: f64 = prices[i - period + 1..=i].iter().sum();
147        out[i] = sum / period as f64;
148    }
149
150    wrap_output(candles, out)
151}
152
153pub fn ema(candles: &[Candle], period: usize) -> Vec<ValueAtTime> {
154    let prices = extract_close(candles);
155    let mut out = vec![f64::NAN; prices.len()];
156
157    if period == 0 || prices.is_empty() {
158        return wrap_output(candles, out);
159    }
160
161    let k = 2.0 / (period as f64 + 1.0);
162    // Determine the first point to start calculating (simple SMA seed or just first price)
163    // Matching typical EMA: if < period, usually NAN or seeded by SMA.
164    // The previous implementation used SMA at period-1.
165    // Let's stick to standard behavior: first 'period' points are NAN/buildup,
166    // BUT common web chart libs often start earlier or use simple price.
167    // Re-using previous logic:
168
169    // Existing logic was:
170    // if i < period - 1 => NAN
171    // i == period - 1 => SMA
172    // i > period - 1 => EMA
173
174    let mut prev = 0.0;
175
176    for i in 0..prices.len() {
177        if i < period - 1 {
178            out[i] = f64::NAN;
179        } else if i == period - 1 {
180            let sma_val: f64 = prices[0..period].iter().sum::<f64>() / period as f64;
181            out[i] = sma_val;
182            prev = sma_val;
183        } else {
184            prev = prices[i] * k + prev * (1.0 - k);
185            out[i] = prev;
186        }
187    }
188
189    wrap_output(candles, out)
190}
191
192pub fn wma(candles: &[Candle], period: usize) -> Vec<ValueAtTime> {
193    let prices = extract_close(candles);
194    let mut out = vec![f64::NAN; prices.len()];
195
196    if period == 0 || prices.len() < period {
197        return wrap_output(candles, out);
198    }
199
200    let denom = (period * (period + 1) / 2) as f64;
201
202    for i in period - 1..prices.len() {
203        let mut sum = 0.0;
204        for j in 0..period {
205            sum += prices[i - j] * (period - j) as f64;
206        }
207        out[i] = sum / denom;
208    }
209
210    wrap_output(candles, out)
211}
212
213fn wma_values(values: &[f64], period: usize) -> Vec<f64> {
214    let mut out = vec![f64::NAN; values.len()];
215    if period == 0 || values.len() < period {
216        return out;
217    }
218
219    let denom = (period * (period + 1) / 2) as f64;
220
221    for i in period - 1..values.len() {
222        let mut sum = 0.0;
223        for j in 0..period {
224            sum += values[i - j] * (period - j) as f64;
225        }
226        out[i] = sum / denom;
227    }
228
229    out
230}
231
232pub fn hma(candles: &[Candle], period: usize) -> Vec<ValueAtTime> {
233    if period < 2 {
234        return wrap_output(candles, vec![f64::NAN; candles.len()]);
235    }
236
237    let prices = extract_close(candles);
238    let half = period / 2;
239    let sqrt_n = (period as f64).sqrt().round() as usize;
240
241    let w1 = wma_values(&prices, half);
242    let w2 = wma_values(&prices, period);
243
244    // 2 * WMA(n/2) - WMA(n)
245    let diff: Vec<f64> = w1.iter().zip(w2.iter()).map(|(a, b)| 2.0 * a - b).collect();
246    let h = wma_values(&diff, sqrt_n);
247
248    wrap_output(candles, h)
249}
250
251pub fn ehma(candles: &[Candle], period: usize) -> Vec<ValueAtTime> {
252    // Note: Previous implementation used EMA(period) vs EMA(period)... wait.
253    // Standard EHMA or similar might be 2*EMA(n/2) - EMA(n).
254    // Let's check `indicator.js`:
255    // calculateEHMA uses: 2 * emaHalf - emaFull, then ema(sqrt) of that.
256
257    // Re-implementing correctly based on JS logic:
258    let ema_full = ema(candles, period);
259    let ema_half = ema(candles, period / 2);
260
261    let raw: Vec<Candle> = candles
262        .iter()
263        .enumerate()
264        .map(|(i, c)| {
265            let val_full = ema_full[i].value;
266            let val_half = ema_half[i].value;
267            // If either is NAN, result is NAN
268            let res = if val_full.is_nan() || val_half.is_nan() {
269                f64::NAN
270            } else {
271                2.0 * val_half - val_full
272            };
273            Candle {
274                time: c.time,
275                open: c.open,
276                high: c.high,
277                low: c.low,
278                close: res,
279            }
280        })
281        .collect();
282
283    let sqrt_n = (period as f64).sqrt().round() as usize;
284    ema(&raw, sqrt_n)
285}
286
287// ============================================================
288// Logic Helpers
289// ============================================================
290
291// Removed unused slope function
292
293fn slope_direction(v: f64) -> String {
294    if v > 0.0001 {
295        "Up".to_string()
296    } else if v < -0.0001 {
297        "Down".to_string()
298    } else {
299        "Flat".to_string()
300    }
301}
302
303fn turn_type(prev_diff: f64, curr_diff: f64) -> String {
304    let prev_dir = if prev_diff > 0.0001 {
305        "Up"
306    } else if prev_diff < -0.0001 {
307        "Down"
308    } else {
309        "Flat"
310    };
311    let curr_dir = if curr_diff > 0.0001 {
312        "Up"
313    } else if curr_diff < -0.0001 {
314        "Down"
315    } else {
316        "Flat"
317    };
318
319    if curr_dir == "Up" && prev_dir == "Down" {
320        "TurnUp".to_string()
321    } else if curr_dir == "Down" && prev_dir == "Up" {
322        "TurnDown".to_string()
323    } else {
324        "None".to_string()
325    }
326}
327
328fn get_ema_cut_position(c: &Candle, v: f64) -> String {
329    if v.is_nan() {
330        return "Unknown".to_string();
331    }
332
333    let body_top = c.close.max(c.open);
334    let body_bottom = c.close.min(c.open);
335
336    if v > c.high {
337        return "1".to_string(); // Above Upper Wick
338    }
339    if v >= body_top {
340        return "2".to_string(); // Upper Wick Area
341    }
342    if v >= body_bottom {
343        // Inside Body
344        let height = body_top - body_bottom;
345        if height == 0.0 {
346            return "B2".to_string();
347        } // Doji
348
349        let ratio = (v - body_bottom) / height;
350        if ratio >= 0.66 {
351            return "B1".to_string();
352        } else if ratio >= 0.33 {
353            return "B2".to_string();
354        } else {
355            return "B3".to_string();
356        }
357    }
358    if v >= c.low {
359        return "3".to_string(); // Lower Wick aArea
360    }
361
362    "4".to_string() // Below Low
363}
364
365fn get_color(open: f64, close: f64) -> String {
366    if close > open {
367        "Green".to_string()
368    } else if close < open {
369        "Red".to_string()
370    } else {
371        "Equal".to_string()
372    }
373}
374
375fn calculate_ma(candles: &[Candle], period: usize, ma_type: MaType) -> Vec<ValueAtTime> {
376    match ma_type {
377        MaType::EMA => ema(candles, period),
378        MaType::HMA => hma(candles, period),
379        MaType::WMA => wma(candles, period),
380        MaType::SMA => sma(candles, period),
381        MaType::EHMA => ehma(candles, period),
382    }
383}
384
385// ============================================================
386// Main Analysis Function
387// ============================================================
388
389/// Generate Analysis Data
390/// Mimics the logic of generateAnalysisData() in indicator.js
391pub fn generate_analysis_data(
392    candles: &[Candle],
393    short_p: usize,
394    medium_p: usize,
395    long_p: usize,
396    short_type: MaType,
397    medium_type: MaType,
398    long_type: MaType,
399) -> Vec<EmaAnalysis> {
400    let ma_short = calculate_ma(candles, short_p, short_type);
401    let ma_medium = calculate_ma(candles, medium_p, medium_type);
402    let ma_long = calculate_ma(candles, long_p, long_type);
403
404    let mut out = Vec::new();
405
406    let mut last_ema_cut_short_index: Option<usize> = None;
407    let mut last_ema_cut_long_index: Option<usize> = None;
408
409    for i in 0..candles.len() {
410        let c = &candles[i];
411        let next_c = if i < candles.len() - 1 {
412            Some(&candles[i + 1])
413        } else {
414            None
415        };
416
417        // Basic Candle Info
418        let color_candle = get_color(c.open, c.close);
419        let next_color_candle = if let Some(nc) = next_c {
420            get_color(nc.open, nc.close)
421        } else {
422            "Unknown".to_string()
423        };
424
425        // Values
426        let short_val = ma_short[i].value;
427        let medium_val = ma_medium[i].value;
428        let long_val = ma_long[i].value;
429
430        // Previous Values (Index i-1)
431        let (prev_short, prev_medium, prev_long) = if i > 0 {
432            (
433                ma_short[i - 1].value,
434                ma_medium[i - 1].value,
435                ma_long[i - 1].value,
436            )
437        } else {
438            (f64::NAN, f64::NAN, f64::NAN)
439        };
440
441        // Slopes & Directions (Short)
442        let short_diff = if !short_val.is_nan() && !prev_short.is_nan() {
443            short_val - prev_short
444        } else {
445            0.0
446        };
447        let short_slope_dir = slope_direction(short_diff);
448
449        // Turn Type (Short)
450        let mut short_turn = "None".to_string();
451        if i >= 2 {
452            let val_i2 = ma_short[i - 2].value; // i-2
453            let val_i1 = prev_short; // i-1
454                                     // if we have valid history
455            if !val_i2.is_nan() && !val_i1.is_nan() && !short_val.is_nan() {
456                let prev_diff = val_i1 - val_i2;
457                let curr_diff = short_val - val_i1;
458                short_turn = turn_type(prev_diff, curr_diff);
459            }
460        }
461
462        // Medium Direction
463        let medium_diff = if !medium_val.is_nan() && !prev_medium.is_nan() {
464            medium_val - prev_medium
465        } else {
466            0.0
467        };
468        let medium_slope_dir = slope_direction(medium_diff);
469
470        // Long Direction
471        let long_diff = if !long_val.is_nan() && !prev_long.is_nan() {
472            long_val - prev_long
473        } else {
474            0.0
475        };
476        let long_slope_dir = slope_direction(long_diff);
477
478        // EMA Cuts / Relationships
479        let ema_above = if !short_val.is_nan() && !medium_val.is_nan() {
480            if short_val > medium_val {
481                "ShortAbove".to_string()
482            } else {
483                "MediumAbove".to_string()
484            }
485        } else {
486            "Unknown".to_string()
487        };
488
489        let ema_long_above = if !medium_val.is_nan() && !long_val.is_nan() {
490            if medium_val > long_val {
491                "MediumAbove".to_string()
492            } else {
493                "LongAbove".to_string()
494            }
495        } else {
496            "Unknown".to_string()
497        };
498
499        // MACD Values
500        let macd_12 = if !short_val.is_nan() && !medium_val.is_nan() {
501            (short_val - medium_val).abs()
502        } else {
503            f64::NAN
504        };
505        let macd_23 = if !medium_val.is_nan() && !long_val.is_nan() {
506            (medium_val - long_val).abs()
507        } else {
508            f64::NAN
509        };
510
511        // Previous MACD
512        let prev_macd_12 = if !prev_short.is_nan() && !prev_medium.is_nan() {
513            (prev_short - prev_medium).abs()
514        } else {
515            f64::NAN
516        };
517        let prev_macd_23 = if !prev_medium.is_nan() && !prev_long.is_nan() {
518            (prev_medium - prev_long).abs()
519        } else {
520            f64::NAN
521        };
522
523        // Convergence Types
524        let mut ema_convergence_type = "Neutral".to_string();
525        if !macd_12.is_nan() && !prev_macd_12.is_nan() {
526            if macd_12 > prev_macd_12 {
527                ema_convergence_type = "divergence".to_string();
528            } else if macd_12 < prev_macd_12 {
529                ema_convergence_type = "convergence".to_string();
530            }
531        }
532
533        let mut ema_long_convergence_type = "Neutral".to_string();
534        if !macd_23.is_nan() && !prev_macd_23.is_nan() {
535            if macd_23 > prev_macd_23 {
536                ema_long_convergence_type = "divergence".to_string();
537            } else if macd_23 < prev_macd_23 {
538                ema_long_convergence_type = "convergence".to_string();
539            }
540        }
541
542        // EMA Cut Short Type (Short vs Medium Cross)
543        let mut ema_cut_short_type = "None".to_string();
544        if i > 0
545            && !short_val.is_nan()
546            && !medium_val.is_nan()
547            && !prev_short.is_nan()
548            && !prev_medium.is_nan()
549        {
550            let curr_short_above = short_val > medium_val;
551            let prev_short_above = prev_short > prev_medium;
552
553            if curr_short_above != prev_short_above {
554                if curr_short_above {
555                    ema_cut_short_type = "UpTrend".to_string();
556                } else {
557                    ema_cut_short_type = "DownTrend".to_string();
558                }
559            }
560        }
561
562        if ema_cut_short_type != "None" {
563            last_ema_cut_short_index = Some(i);
564        }
565
566        let candles_since_short_cut = if let Some(idx) = last_ema_cut_short_index {
567            i - idx
568        } else {
569            0
570        };
571
572        // EMA Cut Long Type (Medium vs Long Cross Analysis)
573        let mut ema_cut_long_type = "None".to_string();
574        // Needs history
575        if i > 0
576            && !medium_val.is_nan()
577            && !long_val.is_nan()
578            && !prev_medium.is_nan()
579            && !prev_long.is_nan()
580        {
581            let curr_medium_above = medium_val > long_val;
582            let prev_medium_above = prev_medium > prev_long;
583
584            if curr_medium_above != prev_medium_above {
585                if curr_medium_above {
586                    ema_cut_long_type = "UpTrend".to_string(); // Golden Cross
587                } else {
588                    ema_cut_long_type = "DownTrend".to_string(); // Death Cross
589                }
590            }
591        }
592
593        if ema_cut_long_type != "None" {
594            last_ema_cut_long_index = Some(i);
595        }
596
597        let candles_since_ema_cut = if let Some(idx) = last_ema_cut_long_index {
598            i - idx
599        } else {
600            0
601        };
602
603        // Cut Position
604        let cut_pos = get_ema_cut_position(c, short_val);
605
606        // History Colors
607        let prev_color_1 = if i >= 1 {
608            get_color(candles[i - 1].open, candles[i - 1].close)
609        } else {
610            "Unknown".to_string()
611        };
612        let prev_color_3 = if i >= 3 {
613            get_color(candles[i - 3].open, candles[i - 3].close)
614        } else {
615            "Unknown".to_string()
616        };
617
618        out.push(EmaAnalysis {
619            time_candle: c.time,
620            index: i,
621            color_candle,
622            next_color_candle,
623
624            ema_short_value: short_val,
625            ema_short_slope_value: short_diff,
626            ema_short_slope_direction: short_slope_dir,
627            is_ema_short_turn_type: short_turn,
628            ema_short_cut_position: cut_pos,
629
630            ema_medium_value: medium_val,
631            ema_medium_slope_direction: medium_slope_dir,
632
633            ema_long_value: long_val,
634            ema_long_slope_direction: long_slope_dir,
635
636            ema_above,
637            ema_long_above,
638
639            macd_12,
640            macd_23,
641
642            previous_ema_short_value: prev_short,
643            previous_ema_medium_value: prev_medium,
644            previous_ema_long_value: prev_long,
645            previous_macd_12: prev_macd_12,
646            previous_macd_23: prev_macd_23,
647
648            ema_convergence_type,
649            ema_long_convergence_type,
650
651            ema_cut_short_type,
652            candles_since_short_cut,
653
654            ema_cut_long_type,
655            candles_since_ema_cut,
656
657            previous_color_back1: prev_color_1,
658            previous_color_back3: prev_color_3,
659        });
660    }
661
662    out
663}
664
665// ============================================================
666// Action Logic
667// ============================================================
668
669pub fn get_action_by_simple(results: &[EmaAnalysis], index: usize) -> &'static str {
670    if let Some(analysis) = results.get(index) {
671        match analysis.ema_above.as_str() {
672            "ShortAbove" => "call",
673            "MediumAbove" => "put", // Adjusted: if Medium > Short, usually PUT in this simple logic
674            _ => "hold",
675        }
676    } else {
677        "none"
678    }
679}
680
681pub fn get_action_by_cut_type(
682    results: &[EmaAnalysis],
683    index: usize,
684    use_cut_type: CutStrategy,
685) -> &'static str {
686    if let Some(analysis) = results.get(index) {
687        match use_cut_type {
688            CutStrategy::ShortCut => {
689                // Use ema_cut_short_type + ema_short_slope_direction
690                let trend = analysis.ema_cut_short_type.as_str();
691                let slope = analysis.ema_short_slope_direction.as_str();
692
693                if trend == "UpTrend" && slope == "Up" {
694                    return "call";
695                }
696                if trend == "DownTrend" && slope == "Down" {
697                    return "put";
698                }
699                "hold"
700            }
701            CutStrategy::LongCut => {
702                // Use ema_cut_long_type + ema_medium_slope_direction
703                let trend = analysis.ema_cut_long_type.as_str();
704                let slope = analysis.ema_medium_slope_direction.as_str();
705
706                if trend == "UpTrend" && slope == "Up" {
707                    return "call";
708                }
709                if trend == "DownTrend" && slope == "Down" {
710                    return "put";
711                }
712                "hold"
713            }
714        }
715    } else {
716        "none"
717    }
718}