//! Fold-by-fold performance degradation tracking.
//!
//! [`DegradationTracker`] collects per-fold performance metrics and computes a
//! [`DegradationReport`] with linear regression slope of Sharpe ratios across
//! folds, p-value, coefficient of variation, recency-weighted Sharpe, and an
//! estimated half-life.

use serde::{Deserialize, Serialize};

// ---------------------------------------------------------------------------
// FoldMetrics
// ---------------------------------------------------------------------------

/// Performance metrics for a single walk-forward fold.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FoldMetrics {
    /// Zero-based fold index.
    pub fold_index: usize,
    /// Annualised Sharpe ratio for this fold.
    pub sharpe: f64,
    /// Calmar ratio for this fold.
    pub calmar: f64,
    /// Win rate (fraction of profitable trades).
    pub win_rate: f64,
    /// Maximum drawdown (positive value, e.g. 0.15 = 15%).
    pub max_drawdown: f64,
    /// Number of trades executed in this fold.
    pub trade_count: usize,
}

// ---------------------------------------------------------------------------
// DegradationReport
// ---------------------------------------------------------------------------

/// Summary of performance decay across walk-forward folds.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DegradationReport {
    /// OLS slope of Sharpe vs fold index.  Negative ⇒ decaying performance.
    pub sharpe_slope: f64,
    /// Two-tailed p-value for the slope (H0: slope = 0).
    pub slope_pvalue: f64,
    /// Coefficient of variation of per-fold Sharpe ratios.
    pub coefficient_of_variation: f64,
    /// Exponentially recency-weighted average Sharpe (lambda = 0.3).
    pub recency_weighted_sharpe: f64,
    /// Estimated number of folds until Sharpe reaches zero at the current
    /// decay rate.  `None` if slope >= 0 (no decay) or the last Sharpe <= 0.
    pub half_life_folds: Option<f64>,
    /// The raw fold metrics used to compute this report.
    pub fold_metrics: Vec<FoldMetrics>,
}

// ---------------------------------------------------------------------------
// DegradationTracker
// ---------------------------------------------------------------------------

/// Collects per-fold metrics and computes a degradation report.
#[derive(Debug, Default)]
pub struct DegradationTracker {
    folds: Vec<FoldMetrics>,
}

impl DegradationTracker {
    /// Create a new, empty tracker.
    pub fn new() -> Self {
        Self { folds: Vec::new() }
    }

    /// Record metrics for the next fold.
    pub fn add_fold(&mut self, metrics: FoldMetrics) {
        self.folds.push(metrics);
    }

    /// Number of folds recorded so far.
    pub fn num_folds(&self) -> usize {
        self.folds.len()
    }

    /// Compute the degradation report from all recorded folds.
    ///
    /// Requires at least 2 folds.  With fewer, slopes and p-values are set
    /// to 0.0 / 1.0 respectively.
    pub fn compute(&self) -> DegradationReport {
        let n = self.folds.len();
        let sharpes: Vec<f64> = self.folds.iter().map(|f| f.sharpe).collect();

        if n < 2 {
            return DegradationReport {
                sharpe_slope: 0.0,
                slope_pvalue: 1.0,
                coefficient_of_variation: 0.0,
                recency_weighted_sharpe: sharpes.first().copied().unwrap_or(0.0),
                half_life_folds: None,
                fold_metrics: self.folds.clone(),
            };
        }

        // --- Linear regression: y = sharpe, x = fold_index ---
        let (slope, intercept) = linear_regression(&sharpes);

        // --- P-value via t-test ---
        let slope_pvalue = slope_p_value(&sharpes, slope, intercept);

        // --- Coefficient of variation ---
        let coefficient_of_variation = coeff_of_variation(&sharpes);

        // --- Recency-weighted Sharpe (exponential decay, lambda = 0.3) ---
        let recency_weighted_sharpe = recency_weighted_mean(&sharpes, 0.3);

        // --- Half-life estimate ---
        let half_life_folds = if slope < 0.0 {
            // Extrapolate from last fold: how many more folds until Sharpe = 0?
            let last_sharpe = sharpes.last().copied().unwrap_or(0.0);
            (last_sharpe > 0.0).then(|| last_sharpe / (-slope))
        } else {
            None
        };

        DegradationReport {
            sharpe_slope: slope,
            slope_pvalue,
            coefficient_of_variation,
            recency_weighted_sharpe,
            half_life_folds,
            fold_metrics: self.folds.clone(),
        }
    }
}

// ---------------------------------------------------------------------------
// Statistical helpers
// ---------------------------------------------------------------------------

/// Ordinary least-squares linear regression of `ys` against `x = 0, 1, 2, ...`.
///
/// Returns `(slope, intercept)`.
fn linear_regression(ys: &[f64]) -> (f64, f64) {
    let n = ys.len() as f64;
    if n < 2.0 {
        return (0.0, ys.first().copied().unwrap_or(0.0));
    }

    let x_mean = (n - 1.0) / 2.0;
    let y_mean = ys.iter().sum::<f64>() / n;

    let mut numerator = 0.0_f64;
    let mut denominator = 0.0_f64;

    for (i, y) in ys.iter().enumerate() {
        let x = i as f64;
        numerator += (x - x_mean) * (y - y_mean);
        denominator += (x - x_mean) * (x - x_mean);
    }

    if denominator.abs() < 1e-30 {
        return (0.0, y_mean);
    }

    let slope = numerator / denominator;
    let intercept = y_mean - slope * x_mean;
    (slope, intercept)
}

/// Two-tailed p-value for the regression slope via a t-test.
///
/// Uses the Abramowitz & Stegun rational approximation for the normal CDF
/// when degrees of freedom >= 30, and a simple t-to-normal approximation
/// for smaller samples.
fn slope_p_value(ys: &[f64], slope: f64, intercept: f64) -> f64 {
    let n = ys.len();
    if n < 3 {
        return 1.0;
    }

    let n_f = n as f64;
    let df = n_f - 2.0;

    // Residual sum of squares
    let rss: f64 = ys
        .iter()
        .enumerate()
        .map(|(i, &y)| {
            let predicted = intercept + slope * (i as f64);
            (y - predicted).powi(2)
        })
        .sum();

    let mse = rss / df;
    if mse < 1e-30 {
        // Perfect fit → slope is exactly determined
        if slope.abs() < 1e-30 {
            return 1.0;
        }
        return 0.0;
    }

    // Sum of squared deviations of x from its mean
    let x_mean = (n_f - 1.0) / 2.0;
    let ss_x: f64 = (0..n).map(|i| (i as f64 - x_mean).powi(2)).sum();

    if ss_x < 1e-30 {
        return 1.0;
    }

    let se_slope = (mse / ss_x).sqrt();
    let t_stat = slope / se_slope;

    // Convert t-statistic to p-value using normal approximation
    // (adequate for our use case; exact t-distribution needs more machinery)
    let p = 2.0 * (1.0 - normal_cdf_approx(t_stat.abs()));
    p.clamp(0.0, 1.0)
}

/// Abramowitz & Stegun approximation to the standard normal CDF.
///
/// Maximum error < 7.5e-8.  Formula 26.2.17 from Handbook of Mathematical
/// Functions.
fn normal_cdf_approx(x: f64) -> f64 {
    if x < -8.0 {
        return 0.0;
    }
    if x > 8.0 {
        return 1.0;
    }

    let a1 = 0.254829592_f64;
    let a2 = -0.284496736_f64;
    let a3 = 1.421413741_f64;
    let a4 = -1.453152027_f64;
    let a5 = 1.061405429_f64;
    let p = 0.3275911_f64;

    let sign = if x >= 0.0 { 1.0 } else { -1.0 };
    let abs_x = x.abs();
    let t = 1.0 / (1.0 + p * abs_x);
    let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-abs_x * abs_x / 2.0).exp();

    0.5 * (1.0 + sign * y)
}

/// Coefficient of variation (std / |mean|).
fn coeff_of_variation(values: &[f64]) -> f64 {
    let n = values.len() as f64;
    if n < 1.0 {
        return 0.0;
    }

    let mean = values.iter().sum::<f64>() / n;
    if mean.abs() < 1e-30 {
        return 0.0;
    }

    let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
    variance.sqrt() / mean.abs()
}

/// Exponentially recency-weighted mean.
///
/// Weight for fold `i` (0-indexed) is `exp(lambda * i)`, so later folds
/// receive higher weight.
fn recency_weighted_mean(values: &[f64], lambda: f64) -> f64 {
    if values.is_empty() {
        return 0.0;
    }

    let mut weight_sum = 0.0_f64;
    let mut weighted_sum = 0.0_f64;

    for (i, &v) in values.iter().enumerate() {
        let w = (lambda * i as f64).exp();
        weighted_sum += w * v;
        weight_sum += w;
    }

    if weight_sum.abs() < 1e-30 {
        return 0.0;
    }

    weighted_sum / weight_sum
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    /// Helper: create a tracker with linearly declining Sharpe ratios.
    fn make_declining_tracker(n: usize, start: f64, slope: f64) -> DegradationTracker {
        let mut tracker = DegradationTracker::new();
        for i in 0..n {
            tracker.add_fold(FoldMetrics {
                fold_index: i,
                sharpe: start + slope * i as f64,
                calmar: 1.0,
                win_rate: 0.55,
                max_drawdown: 0.10,
                trade_count: 100,
            });
        }
        tracker
    }

    #[test]
    fn test_negative_slope_for_declining_sharpe() {
        let tracker = make_declining_tracker(10, 2.0, -0.15);
        let report = tracker.compute();

        assert!(
            report.sharpe_slope < 0.0,
            "Expected negative slope, got {}",
            report.sharpe_slope,
        );
        // The slope should be approximately -0.15
        assert!(
            (report.sharpe_slope - (-0.15)).abs() < 1e-10,
            "Expected slope ~-0.15, got {}",
            report.sharpe_slope,
        );
    }

    #[test]
    fn test_pvalue_significant_for_strong_trend() {
        let tracker = make_declining_tracker(20, 2.0, -0.1);
        let report = tracker.compute();

        // With a perfectly linear decline across 20 folds, p-value should be
        // essentially 0 (the fit is exact).
        assert!(
            report.slope_pvalue < 0.05,
            "Expected p-value < 0.05, got {}",
            report.slope_pvalue,
        );
    }

    #[test]
    fn test_half_life_estimated_correctly() {
        // Start=2.0, slope=-0.2/fold → after 10 folds, Sharpe=0.0
        // Last Sharpe at fold 9: 2.0 + (-0.2)*9 = 0.2
        // Half-life = 0.2 / 0.2 = 1.0 folds remaining
        let tracker = make_declining_tracker(10, 2.0, -0.2);
        let report = tracker.compute();

        assert!(report.half_life_folds.is_some());
        let hl = report.half_life_folds.unwrap_or(f64::NAN);
        assert!(
            (hl - 1.0).abs() < 1e-10,
            "Expected half_life ~1.0, got {}",
            hl,
        );
    }

    #[test]
    fn test_recency_weighting_favours_later_folds() {
        // Two folds: fold 0 has Sharpe=0.0, fold 1 has Sharpe=2.0
        // Recency weighting (lambda=0.3) should produce a weighted mean > 1.0
        let mut tracker = DegradationTracker::new();
        tracker.add_fold(FoldMetrics {
            fold_index: 0,
            sharpe: 0.0,
            calmar: 0.5,
            win_rate: 0.45,
            max_drawdown: 0.20,
            trade_count: 50,
        });
        tracker.add_fold(FoldMetrics {
            fold_index: 1,
            sharpe: 2.0,
            calmar: 1.5,
            win_rate: 0.60,
            max_drawdown: 0.08,
            trade_count: 80,
        });

        let report = tracker.compute();
        assert!(
            report.recency_weighted_sharpe > 1.0,
            "Expected recency-weighted Sharpe > 1.0, got {}",
            report.recency_weighted_sharpe,
        );
        // Simple mean would be 1.0; recency weighting should push above 1.0
        assert!(
            report.recency_weighted_sharpe < 2.0,
            "Expected recency-weighted Sharpe < 2.0, got {}",
            report.recency_weighted_sharpe,
        );
    }
}
