qta 2.56.2

Streaming technical analysis indicators for quantitative trading
Documentation
//! CTrends noise filter — merges short counter-trend moves.
//!
//! After the basic ZigZag detects pivots, CTrends applies a second pass
//! to remove "noise" pivots where a counter-move is too small relative
//! to the surrounding trend moves.

use crate::zigzag::types::{Pivot, PivotKind};

/// Configuration for the CTrends noise filter.
#[derive(Debug, Clone)]
pub struct CTrendsConfig {
    /// Minimum counter-move ratio relative to the preceding trend move.
    /// Default: 0.25.
    pub min_retrace_ratio: f64,
    /// Minimum bars a counter-move must span. Default: 2.
    pub min_counter_bars: usize,
}

impl Default for CTrendsConfig {
    fn default() -> Self {
        Self {
            min_retrace_ratio: 0.25,
            min_counter_bars: 2,
        }
    }
}

/// Apply CTrends noise filtering to confirmed pivots.
///
/// When a counter-move (p1→p2) is too small, merges the triplet (p0, p1, p2)
/// into a single pivot by keeping the better extreme of p0 and p2, dropping p1.
/// Repeats until stable.
pub fn filter_ctrends(pivots: &[Pivot], config: &CTrendsConfig) -> Vec<Pivot> {
    if pivots.len() < 4 {
        return pivots.to_vec();
    }

    let mut work = pivots.to_vec();
    let mut changed = true;

    while changed {
        changed = false;
        let mut result: Vec<Pivot> = Vec::with_capacity(work.len());
        let mut i = 0;

        while i < work.len() {
            if i + 2 < work.len() {
                let p0 = &work[i];
                let p1 = &work[i + 1];
                let p2 = &work[i + 2];

                let main_move = (p1.price - p0.price).abs();
                let counter_move = (p2.price - p1.price).abs();
                let counter_bars = p2.bar_index.saturating_sub(p1.bar_index);

                let should_merge = if main_move == 0 {
                    false
                } else {
                    let ratio = counter_move as f64 / main_move as f64;
                    ratio < config.min_retrace_ratio || counter_bars < config.min_counter_bars
                };

                if should_merge {
                    // Merge: keep the better extreme of p0 and p2, drop p1.
                    let merged = if p0.kind == PivotKind::Low {
                        if p0.price <= p2.price { *p0 } else { *p2 }
                    } else if p0.price >= p2.price {
                        *p0
                    } else {
                        *p2
                    };
                    result.push(merged);
                    i += 3; // consumed p0, p1, p2
                    changed = true;
                    continue;
                }
            }

            result.push(work[i]);
            i += 1;
        }

        work = result;
    }

    debug_assert!(verify_alternation(&work));
    work
}

fn verify_alternation(pivots: &[Pivot]) -> bool {
    pivots.windows(2).all(|w| w[0].kind != w[1].kind)
}

/// Check if a counter-move qualifies as noise.
pub fn is_noise_counter_move(
    trend_move_size: i64,
    counter_move_size: i64,
    counter_bars: usize,
    config: &CTrendsConfig,
) -> bool {
    if trend_move_size == 0 {
        return false;
    }
    let ratio = counter_move_size as f64 / trend_move_size as f64;
    ratio < config.min_retrace_ratio || counter_bars < config.min_counter_bars
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::zigzag::types::ConfirmationStatus;

    fn make_pivot(index: usize, price: i64, kind: PivotKind) -> Pivot {
        Pivot {
            bar_index: index,
            timestamp_us: (index as i64) * 1_000_000,
            price,
            kind,
            status: ConfirmationStatus::Confirmed {
                confirmed_at_bar: index + 3,
                generation: index as u64,
            },
        }
    }

    #[test]
    fn test_no_filtering_needed() {
        let pivots = vec![
            make_pivot(0, 100, PivotKind::Low),
            make_pivot(10, 200, PivotKind::High),
            make_pivot(20, 150, PivotKind::Low),
            make_pivot(30, 250, PivotKind::High),
        ];
        let filtered = filter_ctrends(&pivots, &CTrendsConfig::default());
        assert_eq!(filtered.len(), 4);
    }

    #[test]
    fn test_filter_small_counter_move() {
        let pivots = vec![
            make_pivot(0, 100, PivotKind::Low),
            make_pivot(10, 200, PivotKind::High),
            make_pivot(12, 195, PivotKind::Low),
            make_pivot(20, 250, PivotKind::High),
        ];
        let filtered = filter_ctrends(&pivots, &CTrendsConfig::default());
        assert!(filtered.len() < 4);
        assert!(verify_alternation(&filtered));
    }

    #[test]
    fn test_too_few_pivots() {
        let pivots = vec![
            make_pivot(0, 100, PivotKind::Low),
            make_pivot(10, 200, PivotKind::High),
            make_pivot(20, 150, PivotKind::Low),
        ];
        let filtered = filter_ctrends(&pivots, &CTrendsConfig::default());
        assert_eq!(filtered.len(), 3);
    }

    #[test]
    fn test_is_noise() {
        let config = CTrendsConfig::default();
        assert!(is_noise_counter_move(100, 5, 5, &config));
        assert!(!is_noise_counter_move(100, 30, 5, &config));
        assert!(is_noise_counter_move(100, 30, 1, &config));
    }
}