quantwave-core 0.5.0

A high-performance, Polars-native technical analysis library for Rust.
Documentation
//! ML Feature Validation Harness for quantwave-gw7s (under epic quantwave-4ps).
//!
//! This is the critical verification infrastructure that closes the loop on
//! quantwave-4ub research findings. It lives ONLY in quantwave-core/tests/ per
//! project convention (root-level tests/ prohibited).
//!
//! WHAT IS TESTED:
//! - Feature stability across regimes and market conditions (synthetic with known shifts).
//! - Proptest parity + no-lookahead (causality) guards: streaming Next path must be
//!   consistent with fresh replay on prefixes (batch simulation via sequential feed).
//! - Regime-conditional behavior (Hurst persistence, CyberCycle signals, etc. behave
//!   as expected in trending / mean-reverting / high-vol regimes).
//! - Warmup NaN handling and numerical stability (approx tolerances).
//!
//! Synthetic generators produce data with KNOWN regime shifts for deterministic
//! assertions. "Real data samples" are represented by realistic noisy processes
//! (volatility clustering, drift changes) that mimic observed market regimes.
//!
//! All tests use proptest for generative coverage + fixed regression cases.
//! Must pass via: `cargo nextest run -p quantwave-core --test test_ml_feature_validation`
//!
//! Sources (recorded per AGENTS.md):
//! - Core extractors: quantwave-core/src/features/{cyber_cycle,hurst,trendflex,instantaneous_trendline,regime}.rs
//! - Wrapped indicators: quantwave-core/src/indicators/*.rs (Ehlers citations in each)
//! - Regime concepts: quantwave-core/src/regimes/mod.rs + submodules + analytics.rs
//! - Research: quantwave-4ub notes (via feature module docs)
//! - No external gold_standard/*.json added (derived features; no published scalar vectors for these exact composites).
//!
//! 2026-05-31 IST: Initial harness authored as part of quantwave-gw7s.

use approx::assert_relative_eq;
use proptest::prelude::*;
use quantwave_core::features::*;
use quantwave_core::regimes::MarketRegime;
use quantwave_core::traits::Next;

/// Generate synthetic price series with explicit regime shifts + ground-truth labels.
/// Segments: [trending (persistent), mean-reverting, high-vol random-walk, steady low-vol].
/// Returns (prices, regime_labels) where labels are human-readable for assertions.
fn generate_synthetic_with_regime_shifts(n: usize) -> (Vec<f64>, Vec<&'static str>) {
    let mut prices = Vec::with_capacity(n);
    let mut labels = Vec::with_capacity(n);
    let mut price = 100.0;
    let mut rng = 0.42f64; // deterministic "rand" for reproducibility in tests

    // Segment 1: Trending (strong positive drift, low noise) ~ H>0.5
    let n1 = n / 4;
    for i in 0..n1 {
        rng = (rng * 6364136223846793005u64 as f64 + 1.0).fract();
        let noise = (rng - 0.5) * 0.4;
        price += 0.25 + noise;
        prices.push(price);
        labels.push("trending");
    }

    // Segment 2: Mean-reverting (AR(1) toward local mean, pulls back)
    let n2 = n / 4;
    let mut local_mean = price;
    for _ in 0..n2 {
        rng = (rng * 6364136223846793005u64 as f64 + 1.0).fract();
        let noise = (rng - 0.5) * 1.2;
        price = 0.7 * price + 0.3 * local_mean + noise;
        prices.push(price);
        labels.push("mean_reverting");
        if (prices.len() - n1) % 8 == 0 {
            local_mean = price; // slow mean shift
        }
    }

    // Segment 3: High-volatility / crisis-like (large noise, little drift)
    let n3 = n / 4;
    for _ in 0..n3 {
        rng = (rng * 6364136223846793005u64 as f64 + 1.0).fract();
        let noise = (rng - 0.5) * 4.5;
        price += noise * 0.1; // small drift + huge vol
        prices.push(price);
        labels.push("high_vol");
    }

    // Segment 4: Steady / low-vol random walk around level
    let n4 = n - prices.len();
    for _ in 0..n4 {
        rng = (rng * 6364136223846793005u64 as f64 + 1.0).fract();
        let noise = (rng - 0.5) * 0.6;
        price += noise * 0.05;
        prices.push(price);
        labels.push("steady");
    }

    (prices, labels)
}

/// Simple helper: feed a fresh extractor on a prefix and return last output (for causality checks).
fn replay_to<T: Next<f64> + Clone>(mut ext: T, data: &[f64], up_to: usize) -> T::Output {
    let mut out = ext.next(data[0]);
    for &x in &data[1..=up_to] {
        out = ext.next(x);
    }
    out
}

#[test]
fn test_regime_to_features_basic() {
    let f = regime_to_features(MarketRegime::Bull);
    assert_eq!(f.regime_vector[0], 1.0);
    assert_eq!(f.regime_label, MarketRegime::Bull);

    let f2 = regime_to_features(MarketRegime::Crisis);
    assert_eq!(f2.regime_vector[2], 1.0);
}

proptest! {
    #![proptest_config(ProptestConfig::with_cases(32))]

    #[test]
    fn test_cyber_cycle_feature_parity_no_lookahead(
        inputs in prop::collection::vec(80.0..120.0, 40..120)
    ) {
        let mut extractor = CyberCycleFeatureExtractor::new(14);
        let mut streamed: Vec<CyberCycleFeatures> = Vec::new();
        for &x in &inputs {
            streamed.push(extractor.next(x));
        }

        // No-lookahead / causality guard: fresh extractor on prefix must match streamed[t]
        let check_points = [8usize, inputs.len() / 3, inputs.len() / 2, inputs.len() - 1];
        for &t in &check_points {
            if t >= inputs.len() { continue; }
            let fresh = CyberCycleFeatureExtractor::new(14);
            let replay = replay_to(fresh, &inputs, t);

            if !streamed[t].cycle.is_nan() && !replay.cycle.is_nan() {
                assert_relative_eq!(streamed[t].cycle, replay.cycle, epsilon = 1e-9);
            }
            if !streamed[t].trigger.is_nan() && !replay.trigger.is_nan() {
                assert_relative_eq!(streamed[t].trigger, replay.trigger, epsilon = 1e-9);
            }
            // momentum and signal are derived deterministically from state
            assert_relative_eq!(streamed[t].cycle_momentum, replay.cycle_momentum, epsilon = 1e-9);
            assert_relative_eq!(streamed[t].trigger_signal, replay.trigger_signal, epsilon = 1e-9);
        }
    }

    #[test]
    fn test_hurst_feature_parity_no_lookahead(
        inputs in prop::collection::vec(90.0..110.0, 50..150)
    ) {
        let mut extractor = HurstFeatureExtractor::new(20);
        let mut streamed: Vec<HurstFeatures> = Vec::new();
        for &x in &inputs {
            streamed.push(extractor.next(x));
        }

        let check_points = [19usize, inputs.len() / 2, inputs.len() - 1];
        for &t in &check_points {
            if t >= inputs.len() { continue; }
            let fresh = HurstFeatureExtractor::new(20);
            let replay = replay_to(fresh, &inputs, t);

            if !streamed[t].persistence.is_nan() && !replay.persistence.is_nan() {
                assert_relative_eq!(streamed[t].persistence, replay.persistence, epsilon = 5e-8);
            }
            assert_eq!(streamed[t].regime_label, replay.regime_label);
        }
    }

    #[test]
    fn test_trendflex_feature_parity_no_lookahead(
        inputs in prop::collection::vec(95.0..105.0, 40..100)
    ) {
        let mut extractor = TrendflexFeatureExtractor::new(20);
        let mut streamed: Vec<TrendflexFeatures> = Vec::new();
        for &x in &inputs {
            streamed.push(extractor.next(x));
        }

        let check_points = [10usize, inputs.len() / 2, inputs.len() - 1];
        for &t in &check_points {
            if t >= inputs.len() { continue; }
            let fresh = TrendflexFeatureExtractor::new(20);
            let replay = replay_to(fresh, &inputs, t);
            if !streamed[t].trendflex.is_nan() && !replay.trendflex.is_nan() {
                assert_relative_eq!(streamed[t].trendflex, replay.trendflex, epsilon = 1e-9);
            }
        }
    }

    #[test]
    fn test_instantaneous_trendline_feature_parity_no_lookahead(
        inputs in prop::collection::vec(100.0..110.0, 60..120)
    ) {
        let mut extractor = InstantaneousTrendlineFeatureExtractor::new();
        let mut streamed: Vec<InstantaneousTrendlineFeatures> = Vec::new();
        for &x in &inputs {
            streamed.push(extractor.next(x));
        }

        let check_points = [20usize, inputs.len() / 2, inputs.len() - 1];
        for &t in &check_points {
            if t >= inputs.len() { continue; }
            let fresh = InstantaneousTrendlineFeatureExtractor::new();
            let replay = replay_to(fresh, &inputs, t);
            if !streamed[t].trend.is_nan() && !replay.trend.is_nan() {
                assert_relative_eq!(streamed[t].trend, replay.trend, epsilon = 1e-8);
            }
            assert_relative_eq!(streamed[t].strength, replay.strength, epsilon = 1e-12);
        }
    }
}

/// Regime-conditional + stability tests (fixed seeds, deterministic expectations).
#[test]
fn test_feature_regime_conditional_and_stability() {
    let (prices, labels) = generate_synthetic_with_regime_shifts(180);

    // Hurst extractor: trending segment should show higher persistence than mean-reverting
    let mut h_ext = HurstFeatureExtractor::new(16);
    let mut hurst_trending = Vec::new();
    let mut hurst_mr = Vec::new();
    let mut hurst_highvol = Vec::new();

    for (i, &p) in prices.iter().enumerate() {
        let f = h_ext.next(p);
        if !f.persistence.is_nan() {
            match labels[i] {
                "trending" => hurst_trending.push(f.persistence),
                "mean_reverting" => hurst_mr.push(f.persistence),
                "high_vol" => hurst_highvol.push(f.persistence),
                _ => {}
            }
        }
    }

    if !hurst_trending.is_empty() && !hurst_mr.is_empty() {
        let mean_trend: f64 = hurst_trending.iter().sum::<f64>() / hurst_trending.len() as f64;
        let mean_mr: f64 = hurst_mr.iter().sum::<f64>() / hurst_mr.len() as f64;
        // In this generator, trending should be directionally more persistent
        assert!(mean_trend > mean_mr - 0.08, "Hurst trending mean {} not > mean-rev {}", mean_trend, mean_mr);
    }

    // CyberCycle on oscillatory-ish segments should produce non-trivial cycle amplitude after warmup
    let mut cc_ext = CyberCycleFeatureExtractor::new(12);
    let mut cycle_vals: Vec<f64> = Vec::new();
    for (i, &p) in prices.iter().enumerate() {
        let f = cc_ext.next(p);
        if labels[i] == "mean_reverting" && !f.cycle.is_nan() {
            cycle_vals.push(f.cycle.abs());
        }
    }
    if cycle_vals.len() > 10 {
        let max_cycle = cycle_vals.iter().cloned().fold(0.0f64, f64::max);
        assert!(max_cycle > 0.1, "CyberCycle produced near-zero amplitude in mean-rev regime");
    }

    // Stability: in "steady" regime, features should not explode in variance
    let mut tf_ext = TrendflexFeatureExtractor::new(18);
    let mut steady_tf: Vec<f64> = Vec::new();
    for (i, &p) in prices.iter().enumerate() {
        let f = tf_ext.next(p);
        if labels[i] == "steady" && !f.trendflex.is_nan() {
            steady_tf.push(f.trendflex);
        }
    }
    if steady_tf.len() > 5 {
        let mean: f64 = steady_tf.iter().sum::<f64>() / steady_tf.len() as f64;
        let var: f64 = steady_tf.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / steady_tf.len() as f64;
        assert!(var < 25.0, "Trendflex variance too high ({}) in steady regime - stability failure", var);
    }
}

/// End-to-end matrix build smoke (used as template for notebook).
#[test]
fn test_feature_matrix_build_smoke() {
    let (prices, _labels) = generate_synthetic_with_regime_shifts(60);

    let mut cc = CyberCycleFeatureExtractor::new(10);
    let mut hu = HurstFeatureExtractor::new(12);
    let mut tf = TrendflexFeatureExtractor::new(10);
    let mut it = InstantaneousTrendlineFeatureExtractor::new();

    let mut matrix_rows = 0usize;
    for &p in &prices {
        let _c = cc.next(p);
        let _h = hu.next(p);
        let _t = tf.next(p);
        let _i = it.next(p);
        matrix_rows += 1;
    }
    assert!(matrix_rows == 60);
    // After sufficient bars, at least some non-NaN values must appear
    // (implicit via the other tests; this just exercises the full matrix path)
}

#[cfg(test)]
mod real_data_like_samples {
    use super::*;

    /// "Real data sample" proxy: a short realistic snippet with vol cluster + mild trend.
    /// (Hard-coded; represents a 2-week slice of noisy equity-like prices.)
    #[test]
    fn test_features_on_realistic_sample() {
        let realistic: Vec<f64> = vec![
            100.0, 100.8, 99.7, 101.2, 100.5, 102.1, 101.8, 99.9, 103.4, 102.7,
            104.1, 103.0, 101.5, 105.2, 104.8, 106.3, 105.9, 104.2, 107.1, 106.5,
        ];

        let mut h = HurstFeatureExtractor::new(8);
        let mut last_pers = 0.0;
        for &p in &realistic {
            let f = h.next(p);
            if !f.persistence.is_nan() {
                last_pers = f.persistence;
            }
        }
        // Realistic slice is short; just ensure it runs and produces finite (or NaN handled)
        assert!(last_pers.is_finite() || last_pers.is_nan());
    }
}