quant-indicators 0.7.0

Pure indicator math library for trading — MA, RSI, Bollinger, MACD, ATR, HRP
Documentation
use super::*;

// =============================================================================
// Helper: deterministic pseudo-random
// =============================================================================

struct SimpleLcg {
    state: u64,
}

impl SimpleLcg {
    fn new(seed: u64) -> Self {
        Self { state: seed }
    }

    fn next_u64(&mut self) -> u64 {
        self.state = self.state.wrapping_mul(6364136223846793005).wrapping_add(1);
        self.state
    }

    fn next_normal(&mut self) -> f64 {
        let u1 = (self.next_u64() as f64) / (u64::MAX as f64);
        let u2 = (self.next_u64() as f64) / (u64::MAX as f64);
        let u1 = u1.max(1e-15);
        (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
    }

    fn normal_series(&mut self, n: usize, scale: f64) -> Vec<f64> {
        (0..n).map(|_| self.next_normal() * scale).collect()
    }
}

fn make_correlated_pair(n: usize, corr: f64, seed: u64) -> (Vec<f64>, Vec<f64>) {
    let mut rng = SimpleLcg::new(seed);
    let base: Vec<f64> = rng.normal_series(n, 0.01);
    let noise_weight = (1.0 - corr * corr).sqrt();
    let mut rng2 = SimpleLcg::new(seed + 9999);
    let correlated: Vec<f64> = base
        .iter()
        .map(|&b| corr * b + noise_weight * rng2.next_normal() * 0.01)
        .collect();
    (base, correlated)
}

// =============================================================================
// Statistical helpers
// =============================================================================

#[test]
fn mean_of_simple_series() {
    assert!((mean(&[1.0, 2.0, 3.0]) - 2.0).abs() < 1e-10);
}

#[test]
fn mean_of_single_value() {
    assert!((mean(&[5.0]) - 5.0).abs() < 1e-10);
}

#[test]
fn variance_of_simple_series() {
    let data = [1.0, 2.0, 3.0];
    let m = mean(&data);
    assert!((variance(&data, m) - 1.0).abs() < 1e-10);
}

#[test]
fn variance_of_constant_is_zero() {
    let data = [3.0, 3.0, 3.0, 3.0];
    let m = mean(&data);
    assert!(variance(&data, m) < 1e-20);
}

// =============================================================================
// Distance matrix
// =============================================================================

#[test]
fn distance_from_perfect_correlation_is_zero() {
    let corr = vec![vec![1.0, 1.0], vec![1.0, 1.0]];
    let dist = distance_matrix(&corr);
    assert!(dist[0][1].abs() < 1e-10);
}

#[test]
fn distance_from_zero_correlation() {
    let corr = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
    let dist = distance_matrix(&corr);
    assert!((dist[0][1] - 0.5_f64.sqrt()).abs() < 1e-10);
}

#[test]
fn distance_from_negative_correlation() {
    let corr = vec![vec![1.0, -1.0], vec![-1.0, 1.0]];
    let dist = distance_matrix(&corr);
    assert!((dist[0][1] - 1.0).abs() < 1e-10);
}

#[test]
fn distance_matrix_is_symmetric() {
    let corr = vec![
        vec![1.0, 0.5, -0.3],
        vec![0.5, 1.0, 0.1],
        vec![-0.3, 0.1, 1.0],
    ];
    let dist = distance_matrix(&corr);
    for (i, row_i) in dist.iter().enumerate() {
        for (j, &val) in row_i.iter().enumerate() {
            assert!(
                (val - dist[j][i]).abs() < 1e-10,
                "dist[{}][{}] != dist[{}][{}]",
                i,
                j,
                j,
                i
            );
        }
    }
}

// =============================================================================
// Correlation matrix
// =============================================================================

#[test]
fn correlation_of_identical_series_is_one() {
    let returns = vec![0.01, -0.02, 0.015, -0.005, 0.02];
    let series: Vec<&[f64]> = vec![&returns, &returns];
    let means: Vec<f64> = series.iter().map(|s| mean(s)).collect();
    let vars: Vec<f64> = series
        .iter()
        .enumerate()
        .map(|(i, s)| variance(s, means[i]))
        .collect();
    let corr = correlation_matrix(&series, &means, &vars);
    assert!((corr[0][1] - 1.0).abs() < 1e-10);
}

#[test]
fn correlation_diagonal_is_one() {
    let mut rng = SimpleLcg::new(42);
    let series_a = rng.normal_series(100, 0.01);
    let series_b = rng.normal_series(100, 0.01);
    let series: Vec<&[f64]> = vec![&series_a, &series_b];
    let means: Vec<f64> = series.iter().map(|s| mean(s)).collect();
    let vars: Vec<f64> = series
        .iter()
        .enumerate()
        .map(|(i, s)| variance(s, means[i]))
        .collect();
    let corr = correlation_matrix(&series, &means, &vars);
    assert!((corr[0][0] - 1.0).abs() < 1e-10);
    assert!((corr[1][1] - 1.0).abs() < 1e-10);
}

#[test]
fn high_correlation_detected() {
    let (base, correlated) = make_correlated_pair(252, 0.9, 42);
    let series: Vec<&[f64]> = vec![&base, &correlated];
    let means: Vec<f64> = series.iter().map(|s| mean(s)).collect();
    let vars: Vec<f64> = series
        .iter()
        .enumerate()
        .map(|(i, s)| variance(s, means[i]))
        .collect();
    let corr = correlation_matrix(&series, &means, &vars);
    // Sample correlation should be high (>0.7)
    assert!(
        corr[0][1] > 0.7,
        "Expected high correlation, got {}",
        corr[0][1]
    );
}

// =============================================================================
// Dendrogram
// =============================================================================

#[test]
fn single_leg_dendrogram() {
    let tree = build_dendrogram(&[vec![0.0]], 1);
    assert!(matches!(tree, ClusterNode::Leaf(0)));
}

#[test]
fn two_legs_merge_into_branch() {
    let dist = vec![vec![0.0, 0.5], vec![0.5, 0.0]];
    let tree = build_dendrogram(&dist, 2);
    match tree {
        ClusterNode::Branch { .. } => {}
        ClusterNode::Leaf(_) => panic!("Expected branch for 2 legs"),
    }
}

#[test]
fn closest_pair_merged_first() {
    // Legs 0 and 1 are close (0.1), leg 2 is far (0.9)
    let dist = vec![
        vec![0.0, 0.1, 0.9],
        vec![0.1, 0.0, 0.8],
        vec![0.9, 0.8, 0.0],
    ];
    let tree = build_dendrogram(&dist, 3);
    // Root should have {0,1} on one side and {2} on the other
    match tree {
        ClusterNode::Branch {
            ref left,
            ref right,
        } => {
            let left_leaves = match left.as_ref() {
                ClusterNode::Branch {
                    left: ll,
                    right: lr,
                } => {
                    let mut v = Vec::new();
                    if let ClusterNode::Leaf(i) = ll.as_ref() {
                        v.push(*i);
                    }
                    if let ClusterNode::Leaf(i) = lr.as_ref() {
                        v.push(*i);
                    }
                    v
                }
                ClusterNode::Leaf(i) => vec![*i],
            };
            let right_leaves = match right.as_ref() {
                ClusterNode::Branch {
                    left: rl,
                    right: rr,
                } => {
                    let mut v = Vec::new();
                    if let ClusterNode::Leaf(i) = rl.as_ref() {
                        v.push(*i);
                    }
                    if let ClusterNode::Leaf(i) = rr.as_ref() {
                        v.push(*i);
                    }
                    v
                }
                ClusterNode::Leaf(i) => vec![*i],
            };
            // One side should have {0,1}, the other {2}
            let has_pair = (left_leaves == vec![0, 1] && right_leaves == vec![2])
                || (left_leaves == vec![2] && right_leaves == vec![0, 1]);
            assert!(
                has_pair,
                "Expected {{0,1}} and {{2}}, got {:?} and {:?}",
                left_leaves, right_leaves
            );
        }
        _ => panic!("Expected branch at root"),
    }
}

// =============================================================================
// Recursive bisection
// =============================================================================

#[test]
fn equal_variance_bisection_gives_equal_weight() {
    // Two leaves with equal variance under a branch
    let tree = ClusterNode::Branch {
        left: Box::new(ClusterNode::Leaf(0)),
        right: Box::new(ClusterNode::Leaf(1)),
    };
    let variances = [1.0, 1.0];
    let mut weights = [0.0; 2];
    recursive_bisect(&tree, &variances, 1.0, &mut weights);
    assert!((weights[0] - 0.5).abs() < 1e-10);
    assert!((weights[1] - 0.5).abs() < 1e-10);
}

#[test]
fn higher_variance_gets_less_weight() {
    let tree = ClusterNode::Branch {
        left: Box::new(ClusterNode::Leaf(0)),
        right: Box::new(ClusterNode::Leaf(1)),
    };
    let variances = [1.0, 3.0]; // right has 3x variance
    let mut weights = [0.0; 2];
    recursive_bisect(&tree, &variances, 1.0, &mut weights);
    // Left (lower variance) should get more weight
    assert!(
        weights[0] > weights[1],
        "Lower variance leg should get more weight: {} vs {}",
        weights[0],
        weights[1]
    );
    // Weights should sum to 1
    assert!(((weights[0] + weights[1]) - 1.0).abs() < 1e-10);
}

#[test]
fn bisection_preserves_total_weight() {
    let tree = ClusterNode::Branch {
        left: Box::new(ClusterNode::Branch {
            left: Box::new(ClusterNode::Leaf(0)),
            right: Box::new(ClusterNode::Leaf(1)),
        }),
        right: Box::new(ClusterNode::Leaf(2)),
    };
    let variances = [1.0, 2.0, 1.5];
    let mut weights = [0.0; 3];
    recursive_bisect(&tree, &variances, 1.0, &mut weights);
    let total: f64 = weights.iter().sum();
    assert!(
        (total - 1.0).abs() < 1e-10,
        "Weights should sum to 1.0, got {}",
        total
    );
}

// =============================================================================
// Decimal conversion
// =============================================================================

#[test]
fn decimal_from_f64_normal_value() {
    assert_eq!(
        decimal_from_f64(0.5),
        Ok(rust_decimal::Decimal::new(500000, 6))
    );
}

#[test]
fn decimal_from_f64_rejects_nan() {
    assert!(decimal_from_f64(f64::NAN).is_err());
}

#[test]
fn decimal_from_f64_rejects_infinity() {
    assert!(decimal_from_f64(f64::INFINITY).is_err());
}