diff --git a/packages/devkit/src/analysis/rolling_window.rs b/packages/devkit/src/analysis/rolling_window.rs index 88244a1..85a685b 100644 --- a/packages/devkit/src/analysis/rolling_window.rs +++ b/packages/devkit/src/analysis/rolling_window.rs @@ -1,4 +1,47 @@ /// Maintains a rolling window of fee observations. +pub struct RollingWindow; + +impl RollingWindow { + /// Simple moving average over a slice of fees. + pub fn sma(fees: &[f64], window: usize) -> Vec { + if window == 0 || fees.len() < window { + return vec![]; + } + fees.windows(window) + .map(|w| w.iter().sum::() / window as f64) + .collect() + } + + /// Exponential moving average with configurable smoothing factor `alpha` (0 < alpha <= 1). + pub fn ema(fees: &[f64], alpha: f64) -> Vec { + if fees.is_empty() { + return vec![]; + } + let mut result = Vec::with_capacity(fees.len()); + let mut prev = fees[0]; + result.push(prev); + for &fee in &fees[1..] { + prev = alpha * fee + (1.0 - alpha) * prev; + result.push(prev); + } + result + } + + /// Weighted moving average — most recent values weighted highest. + pub fn wma(fees: &[f64], window: usize) -> Vec { + if window == 0 || fees.len() < window { + return vec![]; + } + let denom = (window * (window + 1) / 2) as f64; + fees.windows(window) + .map(|w| { + w.iter() + .enumerate() + .map(|(i, &v)| v * (i + 1) as f64) + .sum::() + / denom + }) + .collect() pub struct RollingWindow { window: usize, buf: std::collections::VecDeque, diff --git a/packages/devkit/src/cli/benchmark.rs b/packages/devkit/src/cli/benchmark.rs index 223ea5e..9756f1d 100644 --- a/packages/devkit/src/cli/benchmark.rs +++ b/packages/devkit/src/cli/benchmark.rs @@ -1,2 +1,27 @@ /// Runs benchmarks against the fee tracker pipeline. pub struct Benchmark; + +impl Benchmark { + /// Runs SMA, EMA, and WMA on spike data and prints a comparison table. + pub fn compare_spike(fees: &[f64], window: usize, alpha: f64) { + use crate::analysis::rolling_window::RollingWindow; + + let sma = RollingWindow::sma(fees, window); + let ema = RollingWindow::ema(fees, alpha); + let wma = RollingWindow::wma(fees, window); + + println!("{:<6} {:>12} {:>12} {:>12}", "idx", "SMA", "EMA", "WMA"); + let len = sma.len().min(ema.len()).min(wma.len()); + // EMA starts from index 0; SMA/WMA start from index (window-1) + let offset = window - 1; + for i in 0..len { + println!( + "{:<6} {:>12.4} {:>12.4} {:>12.4}", + i + offset, + sma[i], + ema[i + offset], + wma[i] + ); + } + } +} diff --git a/packages/devkit/tests/rolling_window.rs b/packages/devkit/tests/rolling_window.rs new file mode 100644 index 0000000..46b2592 --- /dev/null +++ b/packages/devkit/tests/rolling_window.rs @@ -0,0 +1,86 @@ +use stellar_devkit::analysis::rolling_window::RollingWindow; + +// ── SMA ────────────────────────────────────────────────────────────────────── + +#[test] +fn sma_basic() { + let fees = [1.0, 2.0, 3.0, 4.0, 5.0]; + let result = RollingWindow::sma(&fees, 3); + assert_eq!(result.len(), 3); + assert!((result[0] - 2.0).abs() < 1e-9); + assert!((result[1] - 3.0).abs() < 1e-9); + assert!((result[2] - 4.0).abs() < 1e-9); +} + +#[test] +fn sma_window_equals_len() { + let fees = [10.0, 20.0, 30.0]; + let result = RollingWindow::sma(&fees, 3); + assert_eq!(result.len(), 1); + assert!((result[0] - 20.0).abs() < 1e-9); +} + +#[test] +fn sma_window_larger_than_slice_returns_empty() { + assert!(RollingWindow::sma(&[1.0, 2.0], 5).is_empty()); +} + +// ── EMA ────────────────────────────────────────────────────────────────────── + +#[test] +fn ema_length_matches_input() { + let fees = [1.0, 2.0, 3.0, 4.0, 5.0]; + assert_eq!(RollingWindow::ema(&fees, 0.5).len(), fees.len()); +} + +#[test] +fn ema_alpha_one_equals_input() { + let fees = [10.0, 20.0, 30.0]; + let result = RollingWindow::ema(&fees, 1.0); + for (r, &f) in result.iter().zip(fees.iter()) { + assert!((r - f).abs() < 1e-9); + } +} + +#[test] +fn ema_known_sequence() { + // alpha=0.5, seed=10: 10, 0.5*20+0.5*10=15, 0.5*30+0.5*15=22.5 + let fees = [10.0, 20.0, 30.0]; + let result = RollingWindow::ema(&fees, 0.5); + assert!((result[0] - 10.0).abs() < 1e-9); + assert!((result[1] - 15.0).abs() < 1e-9); + assert!((result[2] - 22.5).abs() < 1e-9); +} + +#[test] +fn ema_empty_returns_empty() { + assert!(RollingWindow::ema(&[], 0.5).is_empty()); +} + +// ── WMA ────────────────────────────────────────────────────────────────────── + +#[test] +fn wma_basic() { + // window=3: weights 1,2,3 / denom=6 + // [1,2,3] -> (1+4+9)/6 = 14/6 ≈ 2.333 + // [2,3,4] -> (2+6+12)/6 = 20/6 ≈ 3.333 + let fees = [1.0, 2.0, 3.0, 4.0]; + let result = RollingWindow::wma(&fees, 3); + assert_eq!(result.len(), 2); + assert!((result[0] - 14.0 / 6.0).abs() < 1e-9); + assert!((result[1] - 20.0 / 6.0).abs() < 1e-9); +} + +#[test] +fn wma_window_larger_than_slice_returns_empty() { + assert!(RollingWindow::wma(&[1.0, 2.0], 5).is_empty()); +} + +#[test] +fn wma_most_recent_weighted_highest() { + // With a spike at the end, WMA should be higher than SMA + let fees = [100.0, 100.0, 1000.0]; + let wma = RollingWindow::wma(&fees, 3); + let sma = RollingWindow::sma(&fees, 3); + assert!(wma[0] > sma[0]); +}