//! V10 Block X — multi-horizon trailing trend feature (LdP-inspired).
//!
//! ## Origin and category correction
//!
//! López de Prado's "trend-scanning method" (*Machine Learning for Asset
//! Managers* 2020, §5.5) was defined as a **labeling** technique: given an
//! anchor bar `t`, scan candidate *forward* horizons `H = {L_1, ...}`, fit
//! OLS to `price[t+i]` for each L, and use `argmax |t_β(L)|` to construct
//! a regime-adaptive label. As a label generator it is forward-looking by
//! design — that is the entire point.
//!
//! Using the same scan as an **input feature** at bar `t` is a category
//! error: it injects `price[t+1 .. t+L]` into the feature row, which leaks
//! into any forward-horizon label whose horizon overlaps L (which, for our
//! 60-bar Phase 1a label, is always). "Purged walk-forward" only prevents
//! cross-fold contamination; it does NOT sterilize intra-bar feature/label
//! correlation.
//!
//! This module therefore implements the same multi-horizon t-statistic scan
//! but over a **trailing** window. The output is a trend/momentum feature
//! summarising regime conditions over `[t-L+1, t]` for each candidate L,
//! with the dominant horizon picked by `argmax |t_β(L)|`. Zero forward reads.
//!
//! ## Algorithm
//!
//! For each anchor bar t and candidate trailing horizons `H = {L_1, L_2, ...}`:
//!
//! 1. For each L ∈ H, fit OLS regression `price[t-L+1+i] = α + β·i` for `i ∈ [0, L)`
//! 2. Compute t-statistic of the slope: `t_β = β / SE(β)`
//! 3. Find `L* = argmax |t_β(L)|` — the dominant trailing-trend horizon
//!
//! Emit 5 features per anchor:
//! 1. `trend_direction` ∈ {-1, 0, +1} — sign of slope at `L*`
//! 2. `trend_strength` = `|t_β(L*)|` — confidence of the trailing trend (bounded)
//! 3. `optimal_horizon_norm` = `L* / max(H)` ∈ [0, 1] — characteristic timescale
//! 4. `second_best_strength` = `|t_β(L_2nd)|` — regime conflict indicator (bounded)
//! 5. `trend_slope` = `β` at `L*` — magnitude AND direction (signed)
//!
//! ## Numerical bound
//!
//! On near-linear trailing segments the residual variance approaches zero and
//! the raw `slope / SE(slope)` t-statistic diverges. We clamp the
//! perfect-fit sentinel to ±20 (≈ p < 1e-30 for the F-distribution — beyond
//! that, the magnitude is operationally meaningless and merely contaminates
//! downstream normalisers / corruption audits). All non-degenerate t-stats
//! are also clamped to ±20 to keep the feature dynamic range tight.
//!
//! ## Reference
//!
//! - López de Prado (2020), *Machine Learning for Asset Managers*, Cambridge UP,
//!   §5.5 "Trend-Scanning Method" — origin of the multi-horizon-t-stat idea
//!   (used there as a labeling method; this module repurposes the scan as a
//!   trailing feature).

/// Bound on |t-stat| emitted by the scanner. Beyond ±20 the precise value
/// is meaningless (p < 1e-30) and only widens the feature dynamic range.
const T_STAT_BOUND: f64 = 20.0;

/// V10 Block X — stateless trailing trend scanner.
///
/// Constructed once with a set of candidate horizons. Each call to `scan` is
/// independent — no rolling state — so the producer pipeline can call it
/// per-bar with a backward-looking price slice.
#[derive(Debug, Clone)]
pub struct TrendScanner {
    /// Candidate trailing horizons (in bars). Each horizon L fits OLS over
    /// the last L prices of the supplied slice. No forward reads.
    horizons: Vec<usize>,
}

impl TrendScanner {
    /// Construct with explicit horizons.
    pub fn new(horizons: Vec<usize>) -> Self {
        assert!(!horizons.is_empty(), "TrendScanner: at least one horizon required");
        Self { horizons }
    }

    /// Default v10 horizons: `{5, 10, 20, 30, 40}` — multi-timescale trailing
    /// trend probes. Backward-looking only.
    pub fn with_defaults() -> Self {
        Self::new(vec![5, 10, 20, 30, 40])
    }

    /// Compute the 5 trailing-trend features for the anchor bar.
    ///
    /// `trailing_prices` should be a backward window ending at bar `t` —
    /// `price[t-L_max+1 ..= t]` — spanning at least `max(horizons)` bars.
    /// For each horizon L the scanner uses the **last L** entries of the
    /// slice (`trailing_prices[len-L..]`). Horizons exceeding the slice
    /// length are silently skipped.
    ///
    /// Returns `[direction, strength, horizon_norm, second_best_strength, slope]`.
    /// All zeros if no horizon can be evaluated.
    pub fn scan(&self, trailing_prices: &[f64]) -> [f64; 5] {
        let mut best_abs_t = 0.0_f64;
        let mut best_slope = 0.0_f64;
        let mut best_horizon = 0_usize;
        let mut second_abs_t = 0.0_f64;

        let max_horizon = self.horizons.iter().copied().max().unwrap_or(0);
        let n_trailing = trailing_prices.len();

        for &l in &self.horizons {
            if n_trailing < l {
                continue;
            }
            let window = &trailing_prices[n_trailing - l..];
            let (slope, t_stat) = ols_slope_tstat(window);
            let abs_t = t_stat.abs();
            if abs_t > best_abs_t {
                second_abs_t = best_abs_t;
                best_abs_t = abs_t;
                best_slope = slope;
                best_horizon = l;
            } else if abs_t > second_abs_t {
                second_abs_t = abs_t;
            }
        }

        // Explicit zero-trend case: no horizon evaluable OR best_slope is
        // exactly 0. Rust's `f64::signum(0.0)` returns +1.0 (not 0), so we
        // can't rely on it for the "no trend" signal.
        let direction = if best_horizon == 0 || best_slope == 0.0 {
            0.0
        } else {
            best_slope.signum()
        };
        let horizon_norm = if max_horizon > 0 && best_horizon > 0 {
            best_horizon as f64 / max_horizon as f64
        } else {
            0.0
        };

        [direction, best_abs_t, horizon_norm, second_abs_t, best_slope]
    }
}

impl Default for TrendScanner {
    fn default() -> Self {
        Self::with_defaults()
    }
}

/// Compute OLS slope `β` and its bounded t-statistic over the price series
/// `y` against integer x = 0..n. Returns `(0, 0)` for degenerate cases
/// (< 3 points, zero variance in x). Perfect / near-perfect fits clamp the
/// t-statistic to ±`T_STAT_BOUND` (signed by slope direction).
fn ols_slope_tstat(y: &[f64]) -> (f64, f64) {
    let n = y.len();
    if n < 3 {
        return (0.0, 0.0);
    }
    let n_f = n as f64;
    let mean_x = (n_f - 1.0) / 2.0;
    let mean_y: f64 = y.iter().sum::<f64>() / n_f;

    let mut s_xy = 0.0_f64;
    let mut s_xx = 0.0_f64;
    for (i, &yi) in y.iter().enumerate() {
        let x_dev = i as f64 - mean_x;
        let y_dev = yi - mean_y;
        s_xy += x_dev * y_dev;
        s_xx += x_dev * x_dev;
    }
    if s_xx < 1e-12 {
        return (0.0, 0.0);
    }
    let slope = s_xy / s_xx;
    let intercept = mean_y - slope * mean_x;

    let mut ss_res = 0.0_f64;
    for (i, &yi) in y.iter().enumerate() {
        let y_hat = intercept + slope * (i as f64);
        let r = yi - y_hat;
        ss_res += r * r;
    }
    let dof = n_f - 2.0;
    if dof <= 0.0 || ss_res < 1e-12 {
        return (slope, T_STAT_BOUND.copysign(slope));
    }
    let mse = ss_res / dof;
    let se_slope = (mse / s_xx).sqrt();
    if se_slope < 1e-12 {
        return (slope, T_STAT_BOUND.copysign(slope));
    }
    let raw = slope / se_slope;
    (slope, raw.clamp(-T_STAT_BOUND, T_STAT_BOUND))
}

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

    #[test]
    fn test_scan_empty_returns_zeros() {
        let scanner = TrendScanner::with_defaults();
        assert_eq!(scanner.scan(&[]), [0.0; 5]);
    }

    #[test]
    fn test_scan_insufficient_bars_returns_zeros() {
        let scanner = TrendScanner::with_defaults();
        // Only 3 bars, all horizons >= 5
        let result = scanner.scan(&[100.0, 101.0, 102.0]);
        assert_eq!(result, [0.0; 5]);
    }

    #[test]
    fn test_scan_perfect_upward_trailing_trend_direction_positive() {
        let scanner = TrendScanner::with_defaults();
        // 40-bar linear ramp viewed as a trailing window: prices[i] = 100 + i*0.01.
        // The last L entries are a perfect linear sub-ramp for every L.
        let prices: Vec<f64> = (0..40).map(|i| 100.0 + (i as f64) * 0.01).collect();
        let [direction, strength, _, _, slope] = scanner.scan(&prices);
        assert_eq!(direction, 1.0, "upward ramp → direction = +1");
        assert!(slope > 0.0, "positive slope expected, got {slope}");
        // Perfect linear fit → t-stat clamped to T_STAT_BOUND
        assert!(
            (strength - T_STAT_BOUND).abs() < 1e-9,
            "perfect upward trailing trend → strength = T_STAT_BOUND ({T_STAT_BOUND}), got {strength}"
        );
    }

    #[test]
    fn test_scan_perfect_downward_trailing_trend_direction_negative() {
        let scanner = TrendScanner::with_defaults();
        let prices: Vec<f64> = (0..40).map(|i| 100.0 - (i as f64) * 0.01).collect();
        let [direction, _, _, _, slope] = scanner.scan(&prices);
        assert_eq!(direction, -1.0, "downward ramp → direction = -1");
        assert!(slope < 0.0, "negative slope expected, got {slope}");
    }

    #[test]
    fn test_scan_pure_noise_low_strength() {
        // 40 bars of bounded zero-mean noise → t-stat should be small.
        let scanner = TrendScanner::with_defaults();
        let prices: Vec<f64> = (0..40)
            .map(|i| 100.0 + ((i as f64) * 7.0).sin() * 0.01)
            .collect();
        let [_, strength, _, _, _] = scanner.scan(&prices);
        assert!(
            strength < 10.0,
            "noisy series should have moderate t-stat, got {strength}"
        );
        // Strength must always be within the bound (invariant, not observation).
        assert!(
            strength <= T_STAT_BOUND + 1e-9,
            "strength must never exceed T_STAT_BOUND ({T_STAT_BOUND}), got {strength}"
        );
    }

    #[test]
    fn test_scan_picks_short_horizon_for_recent_short_lived_trend() {
        // Trailing semantics: the trend lives at the END of the slice
        // (most-recent bars). Construct 35 bars of noise then 5 bars of
        // strong upward trend. Scanner should pick L=5 as best horizon.
        let scanner = TrendScanner::with_defaults();
        let mut prices = vec![0.0_f64; 40];
        for i in 0..35 {
            prices[i] = 100.0 + ((i as f64) * 11.0).cos() * 0.01;
        }
        for i in 35..40 {
            // Strong upward ramp over the last 5 bars
            prices[i] = 100.0 + ((i - 34) as f64) * 1.0;
        }
        let [direction, _, horizon_norm, _, _] = scanner.scan(&prices);
        assert_eq!(direction, 1.0);
        // L* should be 5 (small) → horizon_norm <= 0.25 (5 / max(20,30,40) = 0.125)
        assert!(
            horizon_norm <= 0.25 + 1e-9,
            "recent-short-trend → small horizon_norm, got {horizon_norm}"
        );
    }

    #[test]
    fn test_scan_uses_last_l_bars_not_first() {
        // Crucial invariant: scanner reads trailing_prices[len-L..], not [..L].
        // Construct a slice with opposite trends at the front and back, then
        // verify the scanner reports the BACK trend (most-recent bars).
        let scanner = TrendScanner::new(vec![5]); // Single L=5 horizon
        let mut prices = vec![0.0_f64; 10];
        // First 5 bars: strong DOWNWARD trend (should be ignored)
        for i in 0..5 {
            prices[i] = 100.0 - (i as f64) * 1.0;
        }
        // Last 5 bars: strong UPWARD trend (should be reported)
        for i in 5..10 {
            prices[i] = 50.0 + ((i - 4) as f64) * 1.0;
        }
        let [direction, _, _, _, slope] = scanner.scan(&prices);
        assert_eq!(direction, 1.0, "must read the LAST L bars (upward), not first");
        assert!(slope > 0.0, "trailing-slice slope must be positive, got {slope}");
    }

    #[test]
    fn test_ols_slope_known_value() {
        // y = [10, 12, 14, 16, 18, 20] over x = 0..6 → slope = 2 exactly
        let y = [10.0, 12.0, 14.0, 16.0, 18.0, 20.0];
        let (slope, t_stat) = ols_slope_tstat(&y);
        assert!((slope - 2.0).abs() < 1e-9, "expected slope=2, got {slope}");
        assert!(
            (t_stat.abs() - T_STAT_BOUND).abs() < 1e-9,
            "perfect linear fit → t-stat clamped to T_STAT_BOUND, got {t_stat}"
        );
    }

    #[test]
    fn test_ols_slope_constant_series_returns_zero() {
        let y = [50.0; 10];
        let (slope, t_stat) = ols_slope_tstat(&y);
        assert_eq!(slope, 0.0);
        // Zero variance in y → perfect-fit branch → t_stat sentinel via copysign(0) = 0
        assert!(t_stat.is_finite(), "constant series t-stat must be finite, got {t_stat}");
    }

    #[test]
    fn test_t_stat_bound_invariant_under_arbitrary_inputs() {
        // Bound invariant: no matter the input, |t_stat| ≤ T_STAT_BOUND.
        let scanner = TrendScanner::with_defaults();
        let mut prices = vec![0.0_f64; 40];
        // Mix of cases that historically blew the t-stat up:
        // - Near-perfect linear segment
        // - Tiny noise on top of a ramp
        for i in 0..40 {
            prices[i] = 100.0 + (i as f64) * 0.001 + ((i * 17) as f64).sin() * 1e-9;
        }
        let [_, strength, _, second_best, _] = scanner.scan(&prices);
        assert!(
            strength <= T_STAT_BOUND + 1e-9,
            "strength bound violated: {strength}"
        );
        assert!(
            second_best <= T_STAT_BOUND + 1e-9,
            "second_best bound violated: {second_best}"
        );
    }

    #[test]
    fn test_second_best_strength_reflects_conflict() {
        let scanner = TrendScanner::with_defaults();
        // 40-bar ramp + tiny noise: multiple trailing horizons fit very well,
        // so both `strength` and `second_best` should be positive.
        let prices: Vec<f64> = (0..40)
            .map(|i| 100.0 + (i as f64) * 0.01 + ((i as f64) * 13.0).sin() * 0.0001)
            .collect();
        let [_, strength, _, second_best, _] = scanner.scan(&prices);
        assert!(strength > 0.0);
        assert!(second_best > 0.0, "with multiple strong horizons, second-best > 0");
        assert!(second_best <= strength, "second-best must not exceed best");
        // Both bounded
        assert!(strength <= T_STAT_BOUND + 1e-9);
        assert!(second_best <= T_STAT_BOUND + 1e-9);
    }
}
