Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/devkit/src/analysis/rolling_window.rs
Original file line number Diff line number Diff line change
@@ -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<f64> {
if window == 0 || fees.len() < window {
return vec![];
}
fees.windows(window)
.map(|w| w.iter().sum::<f64>() / window as f64)
.collect()
}

/// Exponential moving average with configurable smoothing factor `alpha` (0 < alpha <= 1).
pub fn ema(fees: &[f64], alpha: f64) -> Vec<f64> {
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<f64> {
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::<f64>()
/ denom
})
.collect()
pub struct RollingWindow {
window: usize,
buf: std::collections::VecDeque<f64>,
Expand Down
25 changes: 25 additions & 0 deletions packages/devkit/src/cli/benchmark.rs
Original file line number Diff line number Diff line change
@@ -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]
);
}
}
}
86 changes: 86 additions & 0 deletions packages/devkit/tests/rolling_window.rs
Original file line number Diff line number Diff line change
@@ -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]);
}
Loading