use crate::error::{ForecastError, Result};
#[derive(Debug, Clone)]
pub struct HamiltonDecomposition {
pub cycle: Vec<f64>,
pub trend: Vec<f64>,
pub h: usize,
pub p: usize,
pub r_squared: f64,
pub offset: usize,
}
pub fn hamilton_filter(series: &[f64], h: usize, p: usize) -> Result<HamiltonDecomposition> {
if h == 0 {
return Err(ForecastError::InvalidParameter(
"h (horizon) must be >= 1".to_string(),
));
}
if p == 0 {
return Err(ForecastError::InvalidParameter(
"p (number of lags) must be >= 1".to_string(),
));
}
let n = series.len();
let offset = h + p - 1;
let min_obs = offset + 1; if n < min_obs {
return Err(ForecastError::InsufficientData {
needed: min_obs,
got: n,
hint: Some(format!(
"Hamilton filter with h={h}, p={p} requires at least h+p = {} observations",
min_obs
)),
});
}
let n_obs = n - offset;
let k = p + 1;
let mut x = vec![0.0; n_obs * k]; let mut y = vec![0.0; n_obs];
for i in 0..n_obs {
let t = offset + i;
y[i] = series[t];
x[i] = 1.0;
for j in 0..p {
x[(j + 1) * n_obs + i] = series[t - h - j];
}
}
let beta = qr_least_squares(n_obs, k, &x, &y)?;
let mut trend = vec![0.0; n_obs];
let mut cycle = vec![0.0; n_obs];
for i in 0..n_obs {
let mut fitted = 0.0;
for col in 0..k {
fitted += beta[col] * x[col * n_obs + i];
}
trend[i] = fitted;
cycle[i] = y[i] - fitted;
}
let y_mean = y.iter().sum::<f64>() / n_obs as f64;
let ss_tot: f64 = y.iter().map(|&yi| (yi - y_mean).powi(2)).sum();
let ss_res: f64 = cycle.iter().map(|&e| e.powi(2)).sum();
let r_squared = if ss_tot > 0.0 {
1.0 - ss_res / ss_tot
} else {
1.0
};
Ok(HamiltonDecomposition {
cycle,
trend,
h,
p,
r_squared,
offset,
})
}
pub fn hamilton_quarterly(series: &[f64]) -> Result<HamiltonDecomposition> {
hamilton_filter(series, 8, 4)
}
pub fn hamilton_monthly(series: &[f64]) -> Result<HamiltonDecomposition> {
hamilton_filter(series, 24, 4)
}
pub fn hamilton_annual(series: &[f64]) -> Result<HamiltonDecomposition> {
hamilton_filter(series, 2, 4)
}
fn qr_least_squares(n: usize, k: usize, x: &[f64], y: &[f64]) -> Result<Vec<f64>> {
let mut q = x.to_vec(); let mut rhs = y.to_vec();
let mut col_norms: Vec<f64> = (0..k)
.map(|j| {
let mut s = 0.0;
for i in 0..n {
s += q[j * n + i] * q[j * n + i];
}
s
})
.collect();
let mut perm: Vec<usize> = (0..k).collect();
let max_col_norm = col_norms.iter().cloned().fold(0.0_f64, f64::max).sqrt();
let tol = 1e-12 * (n as f64).sqrt() * max_col_norm;
let mut rank = k;
for step in 0..k {
let mut best_col = step;
let mut best_norm = col_norms[step];
for j in (step + 1)..k {
if col_norms[j] > best_norm {
best_norm = col_norms[j];
best_col = j;
}
}
if best_col != step {
for i in 0..n {
q.swap(step * n + i, best_col * n + i);
}
col_norms.swap(step, best_col);
perm.swap(step, best_col);
}
let mut norm_sq = 0.0;
for i in step..n {
norm_sq += q[step * n + i] * q[step * n + i];
}
let norm_val = norm_sq.sqrt();
if norm_val < tol {
rank = step;
break;
}
let alpha = if q[step * n + step] >= 0.0 {
-norm_val
} else {
norm_val
};
q[step * n + step] -= alpha;
let v_norm_sq = {
let mut s = 0.0;
for i in step..n {
s += q[step * n + i] * q[step * n + i];
}
s
};
if v_norm_sq < 1e-30 {
rank = step;
break;
}
let tau = 2.0 / v_norm_sq;
for j in (step + 1)..k {
let mut dot = 0.0;
for i in step..n {
dot += q[step * n + i] * q[j * n + i];
}
let factor = tau * dot;
for i in step..n {
q[j * n + i] -= factor * q[step * n + i];
}
}
{
let mut dot = 0.0;
for i in step..n {
dot += q[step * n + i] * rhs[i];
}
let factor = tau * dot;
for i in step..n {
rhs[i] -= factor * q[step * n + i];
}
}
q[step * n + step] = alpha;
for j in (step + 1)..k {
col_norms[j] -= q[j * n + step] * q[j * n + step];
if col_norms[j] < 0.0 {
col_norms[j] = 0.0;
}
}
}
if rank == 0 {
return Err(ForecastError::ComputationError(
"Hamilton filter: design matrix has no non-zero columns".to_string(),
));
}
let mut beta_piv = vec![0.0; k];
for i in (0..rank).rev() {
let mut sum = rhs[i];
for j in (i + 1)..rank {
sum -= q[j * n + i] * beta_piv[j];
}
beta_piv[i] = sum / q[i * n + i];
}
let mut beta = vec![0.0; k];
for j in 0..k {
beta[perm[j]] = beta_piv[j];
}
Ok(beta)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pure_linear_trend_cycle_near_zero() {
let n = 200;
let series: Vec<f64> = (0..n).map(|i| 2.0 + 0.5 * i as f64).collect();
let decomp = hamilton_filter(&series, 8, 4).unwrap();
let max_cycle = decomp.cycle.iter().map(|c| c.abs()).fold(0.0_f64, f64::max);
assert!(
max_cycle < 1e-6,
"max cycle = {max_cycle}, expected near zero for linear trend"
);
for (i, &t) in decomp.trend.iter().enumerate() {
let expected = series[decomp.offset + i];
assert!(
(t - expected).abs() < 1e-6,
"trend[{i}] = {t}, expected {expected}"
);
}
}
#[test]
fn trend_plus_irregular_cycle() {
let n = 400;
let series: Vec<f64> = (0..n)
.map(|i| {
let t = i as f64;
let trend = 0.3 * t;
let cycle = 3.0 * (t * 0.7).sin()
+ 2.0 * (t * 1.1).cos()
+ 1.5 * (t * 2.3).sin()
+ 1.0 * (t * 3.7).cos()
+ 0.8 * (t * 5.1).sin();
trend + cycle
})
.collect();
let decomp = hamilton_filter(&series, 8, 4).unwrap();
let cycle_var: f64 = {
let mean = decomp.cycle.iter().sum::<f64>() / decomp.cycle.len() as f64;
decomp.cycle.iter().map(|c| (c - mean).powi(2)).sum::<f64>() / decomp.cycle.len() as f64
};
assert!(
cycle_var > 0.5,
"cycle variance = {cycle_var}, expected > 0.5 for irregular component"
);
assert!(
decomp.r_squared < 1.0,
"R-squared = {}, expected < 1.0",
decomp.r_squared
);
}
#[test]
fn r_squared_high_for_trend_dominated() {
let n = 200;
let series: Vec<f64> = (0..n)
.map(|i| {
let t = i as f64;
10.0 + 2.0 * t + 0.001 * (t * 1.7).sin()
})
.collect();
let decomp = hamilton_filter(&series, 8, 4).unwrap();
assert!(
decomp.r_squared > 0.999,
"R-squared = {}, expected > 0.999",
decomp.r_squared
);
}
#[test]
fn offset_equals_h_plus_p_minus_1() {
let series: Vec<f64> = (0..100).map(|i| i as f64).collect();
let d1 = hamilton_filter(&series, 8, 4).unwrap();
assert_eq!(d1.offset, 8 + 4 - 1);
assert_eq!(d1.offset, 11);
let d2 = hamilton_filter(&series, 24, 4).unwrap();
assert_eq!(d2.offset, 24 + 4 - 1);
assert_eq!(d2.offset, 27);
let d3 = hamilton_filter(&series, 2, 4).unwrap();
assert_eq!(d3.offset, 2 + 4 - 1);
assert_eq!(d3.offset, 5);
}
#[test]
fn h_zero_errors() {
let series = vec![1.0; 50];
let err = hamilton_filter(&series, 0, 4).unwrap_err();
assert!(matches!(err, ForecastError::InvalidParameter(_)));
}
#[test]
fn p_zero_errors() {
let series = vec![1.0; 50];
let err = hamilton_filter(&series, 8, 0).unwrap_err();
assert!(matches!(err, ForecastError::InvalidParameter(_)));
}
#[test]
fn series_too_short_errors() {
let series = vec![1.0; 11];
let err = hamilton_filter(&series, 8, 4).unwrap_err();
assert!(matches!(
err,
ForecastError::InsufficientData {
needed: 12,
got: 11,
..
}
));
}
#[test]
fn output_lengths_correct() {
let n = 150;
let series: Vec<f64> = (0..n).map(|i| i as f64 * 0.1).collect();
let decomp = hamilton_filter(&series, 8, 4).unwrap();
let expected_len = n - decomp.offset;
assert_eq!(decomp.trend.len(), expected_len);
assert_eq!(decomp.cycle.len(), expected_len);
}
#[test]
fn convenience_quarterly() {
let series: Vec<f64> = (0..100).map(|i| i as f64).collect();
let decomp = hamilton_quarterly(&series).unwrap();
assert_eq!(decomp.h, 8);
assert_eq!(decomp.p, 4);
}
#[test]
fn convenience_monthly() {
let series: Vec<f64> = (0..100).map(|i| i as f64).collect();
let decomp = hamilton_monthly(&series).unwrap();
assert_eq!(decomp.h, 24);
assert_eq!(decomp.p, 4);
}
#[test]
fn convenience_annual() {
let series: Vec<f64> = (0..100).map(|i| i as f64).collect();
let decomp = hamilton_annual(&series).unwrap();
assert_eq!(decomp.h, 2);
assert_eq!(decomp.p, 4);
}
#[test]
fn trend_plus_cycle_equals_original() {
let n = 200;
let series: Vec<f64> = (0..n)
.map(|i| {
let t = i as f64;
3.0 + 0.7 * t + 4.0 * (t * 0.2).sin()
})
.collect();
let decomp = hamilton_filter(&series, 8, 4).unwrap();
for i in 0..decomp.trend.len() {
let reconstructed = decomp.trend[i] + decomp.cycle[i];
let original = series[decomp.offset + i];
assert!(
(reconstructed - original).abs() < 1e-10,
"reconstruction mismatch at {i}: {reconstructed} vs {original}"
);
}
}
}