From 22a45d2430b7d2bcc328508dd28cf70e8d37c2dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Tue, 9 Jun 2026 12:27:51 +0200 Subject: [PATCH 01/19] increase test surface - series: len, is_empty --- tests/series/mod.rs | 3 +++ tests/series/test_is_empty.rs | 30 ++++++++++++++++++++++++++++++ tests/series/test_len.rs | 17 +++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 tests/series/test_is_empty.rs create mode 100644 tests/series/test_len.rs diff --git a/tests/series/mod.rs b/tests/series/mod.rs index 3dbab30..32e1257 100644 --- a/tests/series/mod.rs +++ b/tests/series/mod.rs @@ -1,2 +1,5 @@ +mod test_is_empty; +mod test_len; +mod test_mean; mod test_new_instance; mod test_pct_change; diff --git a/tests/series/test_is_empty.rs b/tests/series/test_is_empty.rs new file mode 100644 index 0000000..0a44d3b --- /dev/null +++ b/tests/series/test_is_empty.rs @@ -0,0 +1,30 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_non_empty_time_series_object__when_is_len__returns_false() { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![100.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![100.0, 110.0, 121.0]).unwrap(); + + // When + let result_sut_1: bool = sut_1.is_empty(); + let result_sut_2: bool = sut_2.is_empty(); + + // Then + assert!(result_sut_1 == false); + assert!(result_sut_2 == false); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_empty_time_series_object__when_is_len__returns_true() { + // Given + let sut: TimeSeries = TimeSeries::new(vec![], vec![]).unwrap(); + + // When + let result: bool = sut.is_empty(); + + // Then + assert!(result == true); +} diff --git a/tests/series/test_len.rs b/tests/series/test_len.rs new file mode 100644 index 0000000..c4b2e9d --- /dev/null +++ b/tests/series/test_len.rs @@ -0,0 +1,17 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series_object__when__ask_for_len__returns_it_correctly() { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![100.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![100.0, 110.0, 121.0]).unwrap(); + + // When + let result_sut_1: usize = sut_1.len(); + let result_sut_2: usize = sut_2.len(); + + // Then + assert!(result_sut_1 == 1); + assert!(result_sut_2 == 3); +} From 9aa032ba872f25b810c669ae70e8df58cc45158e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 19 Jun 2026 14:52:07 +0200 Subject: [PATCH 02/19] add lots of new 'desired features' - lots of boilerplates - iplement a few of them - add files for new tests --- crates/errors/types.rs | 15 +- crates/series/time_series.rs | 206 ++++++++++++++++++ tests/series/mod.rs | 20 ++ tests/series/test_all_quantiles.rs | 13 ++ tests/series/test_autocorrelation_function.rs | 14 ++ tests/series/test_average_true_range.rs | 14 ++ tests/series/test_borillenger_bads.rs | 13 ++ tests/series/test_crossover_signal.rs | 14 ++ tests/series/test_cumulative_return.rs | 14 ++ tests/series/test_dickey_fuller_test.rs | 14 ++ tests/series/test_excess_kurtosis.rs | 13 ++ .../series/test_exponential_moving_average.rs | 14 ++ tests/series/test_iqr.rs | 13 ++ tests/series/test_jacque_bera_test.rs | 13 ++ tests/series/test_log_return.rs | 13 ++ tests/series/test_mean.rs | 20 ++ tests/series/test_moving_average.rs | 13 ++ .../test_partial_autocorrelation_function.rs | 14 ++ tests/series/test_quantile.rs | 75 +++++++ .../series/test_rolling_standard_deviation.rs | 14 ++ tests/series/test_simple_return.rs | 13 ++ tests/series/test_skewness.rs | 13 ++ tests/series/test_std_deviation.rs | 21 ++ tests/series/test_true_range.rs | 13 ++ 24 files changed, 597 insertions(+), 2 deletions(-) create mode 100644 tests/series/test_all_quantiles.rs create mode 100644 tests/series/test_autocorrelation_function.rs create mode 100644 tests/series/test_average_true_range.rs create mode 100644 tests/series/test_borillenger_bads.rs create mode 100644 tests/series/test_crossover_signal.rs create mode 100644 tests/series/test_cumulative_return.rs create mode 100644 tests/series/test_dickey_fuller_test.rs create mode 100644 tests/series/test_excess_kurtosis.rs create mode 100644 tests/series/test_exponential_moving_average.rs create mode 100644 tests/series/test_iqr.rs create mode 100644 tests/series/test_jacque_bera_test.rs create mode 100644 tests/series/test_log_return.rs create mode 100644 tests/series/test_mean.rs create mode 100644 tests/series/test_moving_average.rs create mode 100644 tests/series/test_partial_autocorrelation_function.rs create mode 100644 tests/series/test_quantile.rs create mode 100644 tests/series/test_rolling_standard_deviation.rs create mode 100644 tests/series/test_simple_return.rs create mode 100644 tests/series/test_skewness.rs create mode 100644 tests/series/test_std_deviation.rs create mode 100644 tests/series/test_true_range.rs diff --git a/crates/errors/types.rs b/crates/errors/types.rs index 8a62dcd..d3dc6a4 100644 --- a/crates/errors/types.rs +++ b/crates/errors/types.rs @@ -6,7 +6,10 @@ pub enum TemporalSeriesError { /// `index` and `values` have different lengths. /// /// Both must have the same length to form a valid [`TimeSeries`](crate::series::TimeSeries). - LengthMismatch { index_len: usize, values_len: usize }, + LengthMismatch { + index_len: usize, + values_len: usize, + }, /// The series contains no elements. EmptySeries, @@ -15,7 +18,10 @@ pub enum TemporalSeriesError { /// /// A window of size `window` requires at least `window` observations, /// but the series only has `series_len`. - InvalidWindow { window: usize, series_len: usize }, + InvalidWindow { + window: usize, + series_len: usize, + }, /// A filesystem or IO operation failed. /// @@ -27,6 +33,8 @@ pub enum TemporalSeriesError { /// /// The message includes the line number and the offending value. ParseError(String), + + ParameterRangeError(String), } impl fmt::Display for TemporalSeriesError { @@ -56,6 +64,9 @@ impl fmt::Display for TemporalSeriesError { TemporalSeriesError::ParseError(msg) => { write!(f, "parse error: {msg}") } + TemporalSeriesError::ParameterRangeError(msg) => { + write!(f, "parameter out of valid range: {msg}") + } } } } diff --git a/crates/series/time_series.rs b/crates/series/time_series.rs index 7d06c44..eea10a7 100644 --- a/crates/series/time_series.rs +++ b/crates/series/time_series.rs @@ -94,6 +94,212 @@ impl TimeSeries { self.values.is_empty() } + /// Returns the value of the mean estimator. + /// + /// TODO: check that this formula is working properly + /// $$ \hat{\mu} = \frac{1}{n} \sum^{n}_{i=0} x_i$$ + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let sut_1: TimeSeries = TimeSeries::new(vec![1, 2, 3, 4], vec![0.0, 0.0, 0.0, 0.0]).unwrap(); + /// assert!(sut_1.mean() == 0.0); + /// + /// let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 2.0, 3.0, 4.0]).unwrap(); + /// assert!(sut_2.mean() == 2.5); + /// ``` + pub fn mean(&self) -> f64 { + let total_sum: f64 = self.values.iter().sum(); + let len_casted: f64 = self.len() as f64; + + total_sum / len_casted + } + + // With Bessel's correction + // Compute the std stimator + // Lets assume that is working since all tests are passing + pub fn std_deviation(&self) -> f64 { + let estimated_mean: f64 = self.mean(); + let len_casted: f64 = self.len() as f64; + if len_casted == 1.0 { + return 0.0; + } else { + let bessels_correction_factor: f64 = 1.0 / (len_casted - 1.0); + let mut summatory: f64 = 0.0; + + for element in &self.values { + summatory += (element - estimated_mean).powi(2); + } + return bessels_correction_factor * summatory; + } + } + + /// Returns the p-th quantile of the series using linear interpolation. + /// + /// Equivalent to numpy's `np.quantile(arr, p, method='linear')`. + /// + /// # Errors + /// + /// - [`TemporalSeriesError::ParameterRangeError`] if `p` is outside `[0.0, 1.0]`. + /// - [`TemporalSeriesError::EmptySeries`] if the series has no non-NaN values. + pub fn quantile(&self, p: f32) -> Result { + if p < 0.0 || p > 1.0 { + return Err(TemporalSeriesError::ParameterRangeError(format!( + "p must be in [0.0, 1.0], got {p}" + ))); + } + + let mut sorted: Vec = self.values.iter().copied().filter(|v| !v.is_nan()).collect(); + if sorted.is_empty() { + return Err(TemporalSeriesError::EmptySeries); + } + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); + + let n = sorted.len(); + let h = p as f64 * (n - 1) as f64; + let lo = h.floor() as usize; + let hi = h.ceil() as usize; + let frac = h - lo as f64; + + Ok(sorted[lo] + frac * (sorted[hi] - sorted[lo])) + } + + /// TODO: create an interface for this object or similar -> change rust's approach to thsi problem + /// This will only call quantile function a bunch of times... + #[allow(dead_code)] + pub fn all_quantiles(&self) -> Vec { + vec![0.0] + } + + /// TODO: create an interface for this object or similar -> change rust's approach to thsi problem + /// Returns Inter Quantile Range + #[allow(dead_code)] + pub fn iqr(&self) -> Vec { + vec![0.0] + } + + /// TODO: use the formula for computing this! + #[allow(dead_code)] + pub fn rimple_return(&self) -> f64 { + 0.0 + } + + /// TODO: use the formula for computing this! + /// TODO: Check how to work with logs on RUST + #[allow(dead_code)] + pub fn log_return(&self) -> f64 { + 0.0 + } + + /// TODO: use the formula for computing this! + #[allow(dead_code)] + pub fn cumulative_return(&self) -> f64 { + 0.0 + } + + /// TODO: look for the correct type... probably return a time series? + #[allow(dead_code)] + #[allow(unused_variables)] + pub fn moving_average(&self, n: u64) -> f64 { + 0.0 + } + + /// TODO: use the formula for computing this! + #[allow(dead_code)] + pub fn exponential_moving_average(&self) -> f64 { + 0.0 + } + + /// TODO: use the formula for computing this! + #[allow(dead_code)] + pub fn crossover_signal(&self) -> f64 { + 0.0 + } + + // VOLATILITY ------------------------------------------------------------- + /// TODO: use the formula for computing this! + #[allow(dead_code)] + pub fn rolling_standard_derivation(&self) -> f64 { + 0.0 + } + + /// TODO: check how to compute this! + #[allow(dead_code)] + pub fn true_range(&self) -> f64 { + 0.0 + } + + /// TODO: check how to compute this! + #[allow(dead_code)] + pub fn average_true_range(&self) -> f64 { + 0.0 + } + + /// TODO: check how we can return both bands... what is the most rustonean way to do it? + #[allow(dead_code)] + pub fn borillenger_bands(&self) -> f64 { + 0.0 + } + + // AUTOCORRELATION -------------------------------------------------------- + /// TODO: check how to compute this + #[allow(dead_code)] + pub fn autocorrelation_function(&self) -> f64 { + 0.0 + } + + /// TODO: check how to compute this + #[allow(dead_code)] + pub fn partial_autocorrelation_function(&self) -> f64 { + 0.0 + } + + // STATIONARITY ----------------------------------------------------------- + + /// TODO: add test for this function in this same file + /// CHECK the formula for computing this statistic + #[allow(dead_code)] + fn stationary_dickey_fuller_statistics(&self) -> f64 { + 0.0 + } + + /// TODO: add test for this function in this same file + /// CHECK the formula for computing this statistic + #[allow(dead_code)] + #[allow(unused_variables)] + pub fn stationary_dickey_fuller_test(&self, alpha: f32) -> bool { + true + } + + // DISTRIBUTION ANALYSIS -------------------------------------------------- + + /// TODO: add test for this function in this same file + /// TODO: CHECK the formula for computing this statistic + #[allow(dead_code)] + pub fn skewness(&self) -> f64 { + 0.0 + } + + /// TODO: CHECK the formula for computing this statistic + #[allow(dead_code)] + pub fn excess_kurtosis(&self) -> f64 { + 0.0 + } + + /// TODO: add test for this function in this same file + /// TODO: look for this formula + #[allow(dead_code)] + fn jacque_bera_statistics(&self) -> f64 { + 0.0 + } + + /// TODO: check this test implementation + #[allow(dead_code)] + #[allow(unused_variables)] + pub fn jacque_bera_test(&self, alpha: f32) -> bool { + true + } + /// Shifts the series forward by `periods` positions. /// /// Values are moved to the right by `periods` steps. The first `periods` diff --git a/tests/series/mod.rs b/tests/series/mod.rs index 32e1257..71c509f 100644 --- a/tests/series/mod.rs +++ b/tests/series/mod.rs @@ -1,5 +1,25 @@ +mod test_all_quantiles; +mod test_autocorrelation_function; +mod test_average_true_range; +mod test_borillenger_bads; +mod test_crossover_signal; +mod test_cumulative_return; +mod test_dickey_fuller_test; +mod test_excess_kurtosis; +mod test_exponential_moving_average; +mod test_iqr; mod test_is_empty; +mod test_jacque_bera_test; mod test_len; +mod test_log_return; mod test_mean; +mod test_moving_average; mod test_new_instance; +mod test_partial_autocorrelation_function; mod test_pct_change; +mod test_quantile; +mod test_rolling_standard_deviation; +mod test_simple_return; +mod test_skewness; +mod test_std_deviation; +mod test_true_range; diff --git a/tests/series/test_all_quantiles.rs b/tests/series/test_all_quantiles.rs new file mode 100644 index 0000000..842024c --- /dev/null +++ b/tests/series/test_all_quantiles.rs @@ -0,0 +1,13 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series_object__when_compute_all_quantiles__then_returns_it_correctly() { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + // When + + // Then +} diff --git a/tests/series/test_autocorrelation_function.rs b/tests/series/test_autocorrelation_function.rs new file mode 100644 index 0000000..9fcc8c8 --- /dev/null +++ b/tests/series/test_autocorrelation_function.rs @@ -0,0 +1,14 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series_object__when_compute_autocorrelation_function__then_returns_it_correctly() + { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + // When + + // Then +} diff --git a/tests/series/test_average_true_range.rs b/tests/series/test_average_true_range.rs new file mode 100644 index 0000000..77ccd7e --- /dev/null +++ b/tests/series/test_average_true_range.rs @@ -0,0 +1,14 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series_object__when_compute_average_true_range__then_returns_it_correctly() + { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + // When + + // Then +} diff --git a/tests/series/test_borillenger_bads.rs b/tests/series/test_borillenger_bads.rs new file mode 100644 index 0000000..86650bb --- /dev/null +++ b/tests/series/test_borillenger_bads.rs @@ -0,0 +1,13 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series_object__when_compute_borilleng_bands__then_returns_it_correctly() { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + // When + + // Then +} diff --git a/tests/series/test_crossover_signal.rs b/tests/series/test_crossover_signal.rs new file mode 100644 index 0000000..dd4cd14 --- /dev/null +++ b/tests/series/test_crossover_signal.rs @@ -0,0 +1,14 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series_object__when_compute_crossover_signal__then_returns_it_correctly() +{ + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + // When + + // Then +} diff --git a/tests/series/test_cumulative_return.rs b/tests/series/test_cumulative_return.rs new file mode 100644 index 0000000..c69493f --- /dev/null +++ b/tests/series/test_cumulative_return.rs @@ -0,0 +1,14 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series_object__when_compute_cumulative_returns__then_returns_it_correctly() + { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + // When + + // Then +} diff --git a/tests/series/test_dickey_fuller_test.rs b/tests/series/test_dickey_fuller_test.rs new file mode 100644 index 0000000..6ef340a --- /dev/null +++ b/tests/series/test_dickey_fuller_test.rs @@ -0,0 +1,14 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series_object__when_compute_dickey_fuller_test__then_returns_it_correctly() + { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + // When + + // Then +} diff --git a/tests/series/test_excess_kurtosis.rs b/tests/series/test_excess_kurtosis.rs new file mode 100644 index 0000000..8538e4f --- /dev/null +++ b/tests/series/test_excess_kurtosis.rs @@ -0,0 +1,13 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series_object__when_compute_excess_kurtosis__then_returns_it_correctly() { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + // When + + // Then +} diff --git a/tests/series/test_exponential_moving_average.rs b/tests/series/test_exponential_moving_average.rs new file mode 100644 index 0000000..9025bc0 --- /dev/null +++ b/tests/series/test_exponential_moving_average.rs @@ -0,0 +1,14 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series_object__when_compute_exponential_moving_average__then_returns_it_correctly() + { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + // When + + // Then +} diff --git a/tests/series/test_iqr.rs b/tests/series/test_iqr.rs new file mode 100644 index 0000000..f3391d6 --- /dev/null +++ b/tests/series/test_iqr.rs @@ -0,0 +1,13 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series_object__when_compute_iqr__then_returns_it_correctly() { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + // When + + // Then +} diff --git a/tests/series/test_jacque_bera_test.rs b/tests/series/test_jacque_bera_test.rs new file mode 100644 index 0000000..f1da0d9 --- /dev/null +++ b/tests/series/test_jacque_bera_test.rs @@ -0,0 +1,13 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series_object__when_compute_jacque_bera__then_returns_it_correctly() { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + // When + + // Then +} diff --git a/tests/series/test_log_return.rs b/tests/series/test_log_return.rs new file mode 100644 index 0000000..f94f047 --- /dev/null +++ b/tests/series/test_log_return.rs @@ -0,0 +1,13 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series_object__when_compute_log_returns__then_returns_it_correctly() { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + // When + + // Then +} diff --git a/tests/series/test_mean.rs b/tests/series/test_mean.rs new file mode 100644 index 0000000..501d299 --- /dev/null +++ b/tests/series/test_mean.rs @@ -0,0 +1,20 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series_object__when_compute_mean__then_computes_correctly() { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + let sut_3: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![1.0, 2.0, 3.0]).unwrap(); + + // When + let result_sut_1: f64 = sut_1.mean(); + let result_sut_2: f64 = sut_2.mean(); + let result_sut_3: f64 = sut_3.mean(); + + // Then + assert!(result_sut_1 == 0.0); + assert!(result_sut_2 == 0.0); + assert!(result_sut_3 == 2.0); +} diff --git a/tests/series/test_moving_average.rs b/tests/series/test_moving_average.rs new file mode 100644 index 0000000..5de513c --- /dev/null +++ b/tests/series/test_moving_average.rs @@ -0,0 +1,13 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series_object__when_compute_moving_average__then_returns_it_correctly() { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + // When + + // Then +} diff --git a/tests/series/test_partial_autocorrelation_function.rs b/tests/series/test_partial_autocorrelation_function.rs new file mode 100644 index 0000000..3b417c4 --- /dev/null +++ b/tests/series/test_partial_autocorrelation_function.rs @@ -0,0 +1,14 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series_object__when_compute_partial_autocorrelation_function__then_returns_it_correctly() + { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + // When + + // Then +} diff --git a/tests/series/test_quantile.rs b/tests/series/test_quantile.rs new file mode 100644 index 0000000..2b3a987 --- /dev/null +++ b/tests/series/test_quantile.rs @@ -0,0 +1,75 @@ +use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; + +#[test] +#[allow(non_snake_case)] +fn test__given_all_zeros__when_compute_quantile__then_returns_zero() { + let sut_1 = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2 = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + for p in [0.25, 0.50, 0.75, 0.95, 0.99] { + assert_eq!(sut_1.quantile(p).unwrap(), 0.0); + assert_eq!(sut_2.quantile(p).unwrap(), 0.0); + } +} + +#[test] +#[allow(non_snake_case)] +fn test__given_ordered_series__when_compute_quantile__then_returns_correct_value() { + // [1.0, 2.0, 3.0, 4.0, 5.0] — n=5, indices 0..4 + // Linear interpolation: h = p * (n-1) + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + + assert_eq!(ts.quantile(0.0).unwrap(), 1.0); // h=0.0 → sorted[0] + assert_eq!(ts.quantile(0.25).unwrap(), 2.0); // h=1.0 → sorted[1] + assert_eq!(ts.quantile(0.5).unwrap(), 3.0); // h=2.0 → sorted[2] + assert_eq!(ts.quantile(0.75).unwrap(), 4.0); // h=3.0 → sorted[3] + assert_eq!(ts.quantile(1.0).unwrap(), 5.0); // h=4.0 → sorted[4] +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series__when_compute_median__then_interpolates_correctly() { + // Even-length series: [1.0, 2.0, 3.0, 4.0] — n=4 + // p=0.5 → h=1.5 → lo=1, hi=2, frac=0.5 → 2.0 + 0.5*(3.0-2.0) = 2.5 + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 2.0, 3.0, 4.0]).unwrap(); + assert!((ts.quantile(0.5).unwrap() - 2.5).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_unordered_series__when_compute_quantile__then_sorts_before_computing() { + // Same values as the ordered test but shuffled — result must be identical. + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![5.0, 3.0, 1.0, 4.0, 2.0]).unwrap(); + + assert_eq!(ts.quantile(0.0).unwrap(), 1.0); + assert_eq!(ts.quantile(0.25).unwrap(), 2.0); + assert_eq!(ts.quantile(0.5).unwrap(), 3.0); + assert_eq!(ts.quantile(0.75).unwrap(), 4.0); + assert_eq!(ts.quantile(1.0).unwrap(), 5.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_p_boundary_values__when_compute_quantile__then_returns_ok() { + let ts = TimeSeries::new(vec![1, 2, 3], vec![1.0, 2.0, 3.0]).unwrap(); + + assert!(ts.quantile(0.0).is_ok()); + assert!(ts.quantile(1.0).is_ok()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_invalid_p__when_compute_quantile__then_returns_parameter_range_error() { + let sut_1 = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2 = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + let result_1_below = sut_1.quantile(-0.1); + let result_1_above = sut_1.quantile(1.1); + let result_2_below = sut_2.quantile(-0.1); + let result_2_above = sut_2.quantile(1.1); + + assert!(matches!(result_1_below, Err(TemporalSeriesError::ParameterRangeError(_)))); + assert!(matches!(result_1_above, Err(TemporalSeriesError::ParameterRangeError(_)))); + assert!(matches!(result_2_below, Err(TemporalSeriesError::ParameterRangeError(_)))); + assert!(matches!(result_2_above, Err(TemporalSeriesError::ParameterRangeError(_)))); +} diff --git a/tests/series/test_rolling_standard_deviation.rs b/tests/series/test_rolling_standard_deviation.rs new file mode 100644 index 0000000..9cb91bf --- /dev/null +++ b/tests/series/test_rolling_standard_deviation.rs @@ -0,0 +1,14 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series_object__when_compute_rolling_standard_deviation__then_returns_it_correctly() + { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + // When + + // Then +} diff --git a/tests/series/test_simple_return.rs b/tests/series/test_simple_return.rs new file mode 100644 index 0000000..b49cf4a --- /dev/null +++ b/tests/series/test_simple_return.rs @@ -0,0 +1,13 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series_object__when_compute_simple_return__then_returns_it_correctly() { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + // When + + // Then +} diff --git a/tests/series/test_skewness.rs b/tests/series/test_skewness.rs new file mode 100644 index 0000000..e2eb237 --- /dev/null +++ b/tests/series/test_skewness.rs @@ -0,0 +1,13 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series_object__when_compute_skewness__then_returns_it_correctly() { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + // When + + // Then +} diff --git a/tests/series/test_std_deviation.rs b/tests/series/test_std_deviation.rs new file mode 100644 index 0000000..e96407b --- /dev/null +++ b/tests/series/test_std_deviation.rs @@ -0,0 +1,21 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series_object__when_compute_std__then_computes_it_correctly() { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + // TODO: compute this by hand for a few examples and add more suts to the test suite + //let sut_3: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![1.0, 2.0, 3.0]).unwrap(); + + // When + let result_sut_1: f64 = sut_1.std_deviation(); + let result_sut_2: f64 = sut_2.std_deviation(); + //let result_sut_3: f64 = sut_3.std_deviation(); + + // Then + assert!(result_sut_1 == 0.0); + assert!(result_sut_2 == 0.0); + //assert!(result_sut_3 == 0.0); +} diff --git a/tests/series/test_true_range.rs b/tests/series/test_true_range.rs new file mode 100644 index 0000000..c174891 --- /dev/null +++ b/tests/series/test_true_range.rs @@ -0,0 +1,13 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series_object__when_compute_true_range__then_returns_it_correctly() { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + // When + + // Then +} From f1269953d3d4d5a6f7ea69df60483f74bcda838a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 19 Jun 2026 15:39:14 +0200 Subject: [PATCH 03/19] refactor test to follow style of already implementd ones --- tests/series/test_quantile.rs | 140 +++++++++++++++++++++++----------- 1 file changed, 96 insertions(+), 44 deletions(-) diff --git a/tests/series/test_quantile.rs b/tests/series/test_quantile.rs index 2b3a987..ef24978 100644 --- a/tests/series/test_quantile.rs +++ b/tests/series/test_quantile.rs @@ -2,74 +2,126 @@ use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; #[test] #[allow(non_snake_case)] -fn test__given_all_zeros__when_compute_quantile__then_returns_zero() { - let sut_1 = TimeSeries::new(vec![1], vec![0.0]).unwrap(); - let sut_2 = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); - - for p in [0.25, 0.50, 0.75, 0.95, 0.99] { - assert_eq!(sut_1.quantile(p).unwrap(), 0.0); - assert_eq!(sut_2.quantile(p).unwrap(), 0.0); - } +fn test__given_all_zeros_series__when_compute_quantile__then_returns_zero() { + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + // When + let result_sut_1_p25: Result = sut_1.quantile(0.25); + let result_sut_1_p50: Result = sut_1.quantile(0.50); + let result_sut_1_p75: Result = sut_1.quantile(0.75); + + let result_sut_2_p25: Result = sut_2.quantile(0.25); + let result_sut_2_p50: Result = sut_2.quantile(0.50); + let result_sut_2_p75: Result = sut_2.quantile(0.75); + + // Then + assert!(result_sut_1_p25.unwrap() == 0.0); + assert!(result_sut_1_p50.unwrap() == 0.0); + assert!(result_sut_1_p75.unwrap() == 0.0); + + assert!(result_sut_2_p25.unwrap() == 0.0); + assert!(result_sut_2_p50.unwrap() == 0.0); + assert!(result_sut_2_p75.unwrap() == 0.0); } #[test] #[allow(non_snake_case)] fn test__given_ordered_series__when_compute_quantile__then_returns_correct_value() { - // [1.0, 2.0, 3.0, 4.0, 5.0] — n=5, indices 0..4 + // Given + // [1.0, 2.0, 3.0, 4.0, 5.0], n=5 // Linear interpolation: h = p * (n-1) - let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + // p=0.0 -> h=0.0 -> sorted[0] = 1.0 + // p=0.25 -> h=1.0 -> sorted[1] = 2.0 + // p=0.5 -> h=2.0 -> sorted[2] = 3.0 + // p=0.75 -> h=3.0 -> sorted[3] = 4.0 + // p=1.0 -> h=4.0 -> sorted[4] = 5.0 + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + + // When + let result_p0: Result = sut.quantile(0.0); + let result_p25: Result = sut.quantile(0.25); + let result_p50: Result = sut.quantile(0.5); + let result_p75: Result = sut.quantile(0.75); + let result_p100: Result = sut.quantile(1.0); - assert_eq!(ts.quantile(0.0).unwrap(), 1.0); // h=0.0 → sorted[0] - assert_eq!(ts.quantile(0.25).unwrap(), 2.0); // h=1.0 → sorted[1] - assert_eq!(ts.quantile(0.5).unwrap(), 3.0); // h=2.0 → sorted[2] - assert_eq!(ts.quantile(0.75).unwrap(), 4.0); // h=3.0 → sorted[3] - assert_eq!(ts.quantile(1.0).unwrap(), 5.0); // h=4.0 → sorted[4] + // Then + assert!(result_p0.unwrap() == 1.0); + assert!(result_p25.unwrap() == 2.0); + assert!(result_p50.unwrap() == 3.0); + assert!(result_p75.unwrap() == 4.0); + assert!(result_p100.unwrap() == 5.0); } #[test] #[allow(non_snake_case)] -fn test__given_series__when_compute_median__then_interpolates_correctly() { - // Even-length series: [1.0, 2.0, 3.0, 4.0] — n=4 - // p=0.5 → h=1.5 → lo=1, hi=2, frac=0.5 → 2.0 + 0.5*(3.0-2.0) = 2.5 - let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 2.0, 3.0, 4.0]).unwrap(); - assert!((ts.quantile(0.5).unwrap() - 2.5).abs() < 1e-9); +fn test__given_even_length_series__when_compute_median__then_interpolates_correctly() { + // Given + // [1.0, 2.0, 3.0, 4.0], n=4 + // p=0.5 -> h=1.5 -> lo=1, hi=2, frac=0.5 -> 2.0 + 0.5*(3.0-2.0) = 2.5 + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 2.0, 3.0, 4.0]).unwrap(); + + // When + let result_p50: Result = sut.quantile(0.5); + + // Then + assert!((result_p50.unwrap() - 2.5).abs() < 1e-9); } #[test] #[allow(non_snake_case)] fn test__given_unordered_series__when_compute_quantile__then_sorts_before_computing() { - // Same values as the ordered test but shuffled — result must be identical. - let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![5.0, 3.0, 1.0, 4.0, 2.0]).unwrap(); - - assert_eq!(ts.quantile(0.0).unwrap(), 1.0); - assert_eq!(ts.quantile(0.25).unwrap(), 2.0); - assert_eq!(ts.quantile(0.5).unwrap(), 3.0); - assert_eq!(ts.quantile(0.75).unwrap(), 4.0); - assert_eq!(ts.quantile(1.0).unwrap(), 5.0); + // Given + // Same values as ordered test but shuffled — result must be identical + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![5.0, 3.0, 1.0, 4.0, 2.0]).unwrap(); + + // When + let result_p0: Result = sut.quantile(0.0); + let result_p25: Result = sut.quantile(0.25); + let result_p50: Result = sut.quantile(0.5); + let result_p75: Result = sut.quantile(0.75); + let result_p100: Result = sut.quantile(1.0); + + // Then + assert!(result_p0.unwrap() == 1.0); + assert!(result_p25.unwrap() == 2.0); + assert!(result_p50.unwrap() == 3.0); + assert!(result_p75.unwrap() == 4.0); + assert!(result_p100.unwrap() == 5.0); } #[test] #[allow(non_snake_case)] -fn test__given_valid_p_boundary_values__when_compute_quantile__then_returns_ok() { - let ts = TimeSeries::new(vec![1, 2, 3], vec![1.0, 2.0, 3.0]).unwrap(); +fn test__given_boundary_p_values__when_compute_quantile__then_returns_ok() { + // Given + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![1.0, 2.0, 3.0]).unwrap(); - assert!(ts.quantile(0.0).is_ok()); - assert!(ts.quantile(1.0).is_ok()); + // When + let result_p0: Result = sut.quantile(0.0); + let result_p100: Result = sut.quantile(1.0); + + // Then + assert!(result_p0.is_ok()); + assert!(result_p100.is_ok()); } #[test] #[allow(non_snake_case)] fn test__given_invalid_p__when_compute_quantile__then_returns_parameter_range_error() { - let sut_1 = TimeSeries::new(vec![1], vec![0.0]).unwrap(); - let sut_2 = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); - - let result_1_below = sut_1.quantile(-0.1); - let result_1_above = sut_1.quantile(1.1); - let result_2_below = sut_2.quantile(-0.1); - let result_2_above = sut_2.quantile(1.1); - - assert!(matches!(result_1_below, Err(TemporalSeriesError::ParameterRangeError(_)))); - assert!(matches!(result_1_above, Err(TemporalSeriesError::ParameterRangeError(_)))); - assert!(matches!(result_2_below, Err(TemporalSeriesError::ParameterRangeError(_)))); - assert!(matches!(result_2_above, Err(TemporalSeriesError::ParameterRangeError(_)))); + // Given + let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); + let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + + // When + let result_sut_1_below_0: Result = sut_1.quantile(-0.1); + let result_sut_1_above_1: Result = sut_1.quantile(1.1); + let result_sut_2_below_0: Result = sut_2.quantile(-0.1); + let result_sut_2_above_1: Result = sut_2.quantile(1.1); + + // Then + assert!(matches!(result_sut_1_below_0, Err(TemporalSeriesError::ParameterRangeError(_)))); + assert!(matches!(result_sut_1_above_1, Err(TemporalSeriesError::ParameterRangeError(_)))); + assert!(matches!(result_sut_2_below_0, Err(TemporalSeriesError::ParameterRangeError(_)))); + assert!(matches!(result_sut_2_above_1, Err(TemporalSeriesError::ParameterRangeError(_)))); } From a1992dd6c013278dbf84546591ddc2bbc3ca2413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 19 Jun 2026 15:45:11 +0200 Subject: [PATCH 04/19] implement iqr and extend quantile documentation - add unittests for iqr --- crates/series/time_series.rs | 71 +++++++++++++++++++++++++++++++++--- tests/series/test_iqr.rs | 69 ++++++++++++++++++++++++++++++++++- 2 files changed, 132 insertions(+), 8 deletions(-) diff --git a/crates/series/time_series.rs b/crates/series/time_series.rs index eea10a7..b643d1a 100644 --- a/crates/series/time_series.rs +++ b/crates/series/time_series.rs @@ -136,12 +136,54 @@ impl TimeSeries { /// Returns the p-th quantile of the series using linear interpolation. /// - /// Equivalent to numpy's `np.quantile(arr, p, method='linear')`. + /// Computes the value below which a fraction `p` of observations fall. + /// The virtual index is `h = p * (n - 1)`; the result interpolates linearly + /// between `sorted[floor(h)]` and `sorted[ceil(h)]`. + /// + /// This matches `numpy.quantile(arr, p, method='linear')`. /// /// # Errors /// /// - [`TemporalSeriesError::ParameterRangeError`] if `p` is outside `[0.0, 1.0]`. /// - [`TemporalSeriesError::EmptySeries`] if the series has no non-NaN values. + /// + /// # Examples + /// + /// Exact quantiles on an odd-length series: + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + /// + /// assert_eq!(ts.quantile(0.0).unwrap(), 1.0); + /// assert_eq!(ts.quantile(0.25).unwrap(), 2.0); + /// assert_eq!(ts.quantile(0.5).unwrap(), 3.0); + /// assert_eq!(ts.quantile(0.75).unwrap(), 4.0); + /// assert_eq!(ts.quantile(1.0).unwrap(), 5.0); + /// ``` + /// + /// Interpolated median on an even-length series: + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 2.0, 3.0, 4.0]).unwrap(); + /// + /// // h = 0.5 * 3 = 1.5 -> 2.0 + 0.5 * (3.0 - 2.0) = 2.5 + /// assert!((ts.quantile(0.5).unwrap() - 2.5).abs() < 1e-9); + /// ``` + /// + /// Out-of-range `p` returns an error: + /// + /// ```rust + /// use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; + /// + /// let ts = TimeSeries::new(vec![1, 2, 3], vec![1.0, 2.0, 3.0]).unwrap(); + /// + /// assert!(matches!(ts.quantile(-0.1), Err(TemporalSeriesError::ParameterRangeError(_)))); + /// assert!(matches!(ts.quantile(1.1), Err(TemporalSeriesError::ParameterRangeError(_)))); + /// ``` pub fn quantile(&self, p: f32) -> Result { if p < 0.0 || p > 1.0 { return Err(TemporalSeriesError::ParameterRangeError(format!( @@ -171,11 +213,28 @@ impl TimeSeries { vec![0.0] } - /// TODO: create an interface for this object or similar -> change rust's approach to thsi problem - /// Returns Inter Quantile Range - #[allow(dead_code)] - pub fn iqr(&self) -> Vec { - vec![0.0] + /// Returns the Interquartile Range (IQR) of the series. + /// + /// IQR = Q3 − Q1 = `quantile(0.75)` − `quantile(0.25)`. + /// + /// # Errors + /// + /// - [`TemporalSeriesError::EmptySeries`] if the series has no non-NaN values. + /// + /// # Examples + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + /// + /// // Q1 = 2.0, Q3 = 4.0, IQR = 2.0 + /// assert_eq!(ts.iqr().unwrap(), 2.0); + /// ``` + pub fn iqr(&self) -> Result { + let q1: f64 = self.quantile(0.25)?; + let q3: f64 = self.quantile(0.75)?; + Ok(q3 - q1) } /// TODO: use the formula for computing this! diff --git a/tests/series/test_iqr.rs b/tests/series/test_iqr.rs index f3391d6..2c5d390 100644 --- a/tests/series/test_iqr.rs +++ b/tests/series/test_iqr.rs @@ -1,13 +1,78 @@ -use temporalseries::series::TimeSeries; +use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; #[test] #[allow(non_snake_case)] -fn test__given_valid_time_series_object__when_compute_iqr__then_returns_it_correctly() { +fn test__given_all_zeros_series__when_compute_iqr__then_returns_zero() { // Given let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); // When + let result_sut_1: Result = sut_1.iqr(); + let result_sut_2: Result = sut_2.iqr(); // Then + assert!(result_sut_1.unwrap() == 0.0); + assert!(result_sut_2.unwrap() == 0.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_odd_length_series__when_compute_iqr__then_returns_correct_value() { + // Given + // [1.0, 2.0, 3.0, 4.0, 5.0], n=5 + // Q1: h = 0.25 * 4 = 1.0 -> sorted[1] = 2.0 + // Q3: h = 0.75 * 4 = 3.0 -> sorted[3] = 4.0 + // IQR = 4.0 - 2.0 = 2.0 + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + + // When + let result: Result = sut.iqr(); + + // Then + assert!(result.unwrap() == 2.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_even_length_series__when_compute_iqr__then_interpolates_correctly() { + // Given + // [1.0, 2.0, 3.0, 4.0], n=4 + // Q1: h = 0.25 * 3 = 0.75 -> 1.0 + 0.75*(2.0-1.0) = 1.75 + // Q3: h = 0.75 * 3 = 2.25 -> 3.0 + 0.25*(4.0-3.0) = 3.25 + // IQR = 3.25 - 1.75 = 1.5 + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 2.0, 3.0, 4.0]).unwrap(); + + // When + let result: Result = sut.iqr(); + + // Then + assert!((result.unwrap() - 1.5).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_unordered_series__when_compute_iqr__then_returns_correct_value() { + // Given + // Same values as the ordered test but shuffled — result must be identical + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![5.0, 3.0, 1.0, 4.0, 2.0]).unwrap(); + + // When + let result: Result = sut.iqr(); + + // Then + assert!(result.unwrap() == 2.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_empty_series__when_compute_iqr__then_returns_empty_series_error() { + // Given + let sut: TimeSeries = TimeSeries::new(vec![], vec![]).unwrap(); + + // When + let result: Result = sut.iqr(); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::EmptySeries))); } From 9d2d043a0348edb2bbd2aafbcb31d1a6b1dcdc4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 19 Jun 2026 15:51:48 +0200 Subject: [PATCH 05/19] add required elements to render LaTeX equations in docs --- .cargo/config.toml | 2 ++ crates/series/time_series.rs | 11 ++++++++--- docs/katex-header.html | 9 +++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 docs/katex-header.html diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..f7f8448 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +rustdocflags = ["--html-in-header", "docs/katex-header.html"] diff --git a/crates/series/time_series.rs b/crates/series/time_series.rs index b643d1a..5bffc2d 100644 --- a/crates/series/time_series.rs +++ b/crates/series/time_series.rs @@ -94,10 +94,15 @@ impl TimeSeries { self.values.is_empty() } - /// Returns the value of the mean estimator. + /// Returns the arithmetic mean of the series. /// - /// TODO: check that this formula is working properly - /// $$ \hat{\mu} = \frac{1}{n} \sum^{n}_{i=0} x_i$$ + /// # Formula + /// + /// $$\hat{\mu} = \frac{1}{n} \sum_{i=1}^{n} x_i$$ + /// + /// where `n` is the number of observations. + /// + /// # Examples /// /// ```rust /// use temporalseries::series::TimeSeries; diff --git a/docs/katex-header.html b/docs/katex-header.html new file mode 100644 index 0000000..5db3606 --- /dev/null +++ b/docs/katex-header.html @@ -0,0 +1,9 @@ + + + From ab3eaf32359f31cac7c3b2a2d6bac02a6ebee770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 19 Jun 2026 15:52:42 +0200 Subject: [PATCH 06/19] format code --- crates/series/time_series.rs | 7 +++++- tests/series/test_iqr.rs | 6 +++-- tests/series/test_quantile.rs | 44 +++++++++++++++++++++++------------ 3 files changed, 39 insertions(+), 18 deletions(-) diff --git a/crates/series/time_series.rs b/crates/series/time_series.rs index 5bffc2d..4381c3f 100644 --- a/crates/series/time_series.rs +++ b/crates/series/time_series.rs @@ -196,7 +196,12 @@ impl TimeSeries { ))); } - let mut sorted: Vec = self.values.iter().copied().filter(|v| !v.is_nan()).collect(); + let mut sorted: Vec = self + .values + .iter() + .copied() + .filter(|v| !v.is_nan()) + .collect(); if sorted.is_empty() { return Err(TemporalSeriesError::EmptySeries); } diff --git a/tests/series/test_iqr.rs b/tests/series/test_iqr.rs index 2c5d390..8caf634 100644 --- a/tests/series/test_iqr.rs +++ b/tests/series/test_iqr.rs @@ -24,7 +24,8 @@ fn test__given_odd_length_series__when_compute_iqr__then_returns_correct_value() // Q1: h = 0.25 * 4 = 1.0 -> sorted[1] = 2.0 // Q3: h = 0.75 * 4 = 3.0 -> sorted[3] = 4.0 // IQR = 4.0 - 2.0 = 2.0 - let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); // When let result: Result = sut.iqr(); @@ -55,7 +56,8 @@ fn test__given_even_length_series__when_compute_iqr__then_interpolates_correctly fn test__given_unordered_series__when_compute_iqr__then_returns_correct_value() { // Given // Same values as the ordered test but shuffled — result must be identical - let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![5.0, 3.0, 1.0, 4.0, 2.0]).unwrap(); + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4, 5], vec![5.0, 3.0, 1.0, 4.0, 2.0]).unwrap(); // When let result: Result = sut.iqr(); diff --git a/tests/series/test_quantile.rs b/tests/series/test_quantile.rs index ef24978..ee56af6 100644 --- a/tests/series/test_quantile.rs +++ b/tests/series/test_quantile.rs @@ -37,13 +37,14 @@ fn test__given_ordered_series__when_compute_quantile__then_returns_correct_value // p=0.5 -> h=2.0 -> sorted[2] = 3.0 // p=0.75 -> h=3.0 -> sorted[3] = 4.0 // p=1.0 -> h=4.0 -> sorted[4] = 5.0 - let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); // When - let result_p0: Result = sut.quantile(0.0); - let result_p25: Result = sut.quantile(0.25); - let result_p50: Result = sut.quantile(0.5); - let result_p75: Result = sut.quantile(0.75); + let result_p0: Result = sut.quantile(0.0); + let result_p25: Result = sut.quantile(0.25); + let result_p50: Result = sut.quantile(0.5); + let result_p75: Result = sut.quantile(0.75); let result_p100: Result = sut.quantile(1.0); // Then @@ -74,13 +75,14 @@ fn test__given_even_length_series__when_compute_median__then_interpolates_correc fn test__given_unordered_series__when_compute_quantile__then_sorts_before_computing() { // Given // Same values as ordered test but shuffled — result must be identical - let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![5.0, 3.0, 1.0, 4.0, 2.0]).unwrap(); + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4, 5], vec![5.0, 3.0, 1.0, 4.0, 2.0]).unwrap(); // When - let result_p0: Result = sut.quantile(0.0); - let result_p25: Result = sut.quantile(0.25); - let result_p50: Result = sut.quantile(0.5); - let result_p75: Result = sut.quantile(0.75); + let result_p0: Result = sut.quantile(0.0); + let result_p25: Result = sut.quantile(0.25); + let result_p50: Result = sut.quantile(0.5); + let result_p75: Result = sut.quantile(0.75); let result_p100: Result = sut.quantile(1.0); // Then @@ -98,7 +100,7 @@ fn test__given_boundary_p_values__when_compute_quantile__then_returns_ok() { let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![1.0, 2.0, 3.0]).unwrap(); // When - let result_p0: Result = sut.quantile(0.0); + let result_p0: Result = sut.quantile(0.0); let result_p100: Result = sut.quantile(1.0); // Then @@ -120,8 +122,20 @@ fn test__given_invalid_p__when_compute_quantile__then_returns_parameter_range_er let result_sut_2_above_1: Result = sut_2.quantile(1.1); // Then - assert!(matches!(result_sut_1_below_0, Err(TemporalSeriesError::ParameterRangeError(_)))); - assert!(matches!(result_sut_1_above_1, Err(TemporalSeriesError::ParameterRangeError(_)))); - assert!(matches!(result_sut_2_below_0, Err(TemporalSeriesError::ParameterRangeError(_)))); - assert!(matches!(result_sut_2_above_1, Err(TemporalSeriesError::ParameterRangeError(_)))); + assert!(matches!( + result_sut_1_below_0, + Err(TemporalSeriesError::ParameterRangeError(_)) + )); + assert!(matches!( + result_sut_1_above_1, + Err(TemporalSeriesError::ParameterRangeError(_)) + )); + assert!(matches!( + result_sut_2_below_0, + Err(TemporalSeriesError::ParameterRangeError(_)) + )); + assert!(matches!( + result_sut_2_above_1, + Err(TemporalSeriesError::ParameterRangeError(_)) + )); } From 7519f2a1d6541aa7cd5a28c4aa4e87fc451557a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 19 Jun 2026 16:17:00 +0200 Subject: [PATCH 07/19] add a lot of implementatinos and their respective ut --- crates/series/time_series.rs | 742 ++++++++++++++++-- tests/series/test_autocorrelation_function.rs | 60 +- tests/series/test_average_true_range.rs | 37 +- tests/series/test_borillenger_bads.rs | 48 +- tests/series/test_crossover_signal.rs | 51 +- tests/series/test_cumulative_return.rs | 52 +- tests/series/test_dickey_fuller_test.rs | 47 +- tests/series/test_excess_kurtosis.rs | 25 +- .../series/test_exponential_moving_average.rs | 49 +- tests/series/test_jacque_bera_test.rs | 44 +- tests/series/test_log_return.rs | 58 +- tests/series/test_moving_average.rs | 48 +- .../test_partial_autocorrelation_function.rs | 44 +- .../series/test_rolling_standard_deviation.rs | 59 +- tests/series/test_simple_return.rs | 59 +- tests/series/test_skewness.rs | 40 +- tests/series/test_true_range.rs | 62 +- 17 files changed, 1374 insertions(+), 151 deletions(-) diff --git a/crates/series/time_series.rs b/crates/series/time_series.rs index 4381c3f..28205f3 100644 --- a/crates/series/time_series.rs +++ b/crates/series/time_series.rs @@ -247,126 +247,708 @@ impl TimeSeries { Ok(q3 - q1) } - /// TODO: use the formula for computing this! - #[allow(dead_code)] - pub fn rimple_return(&self) -> f64 { - 0.0 + /// Returns the per-period simple return series. + /// + /// # Formula + /// + /// $$r_t = \frac{x_t - x_{t-1}}{x_{t-1}}$$ + /// + /// The first element is `NaN` because there is no prior observation. + /// Identical to [`TimeSeries::pct_change`], provided here under its financial name. + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::EmptySeries`] if the series is empty. + /// + /// # Examples + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new(vec![1, 2, 3], vec![100.0, 110.0, 121.0]).unwrap(); + /// let r = ts.simple_return().unwrap(); + /// + /// assert!(r.values[0].is_nan()); + /// assert!((r.values[1] - 0.1).abs() < 1e-9); + /// assert!((r.values[2] - 0.1).abs() < 1e-9); + /// ``` + pub fn simple_return(&self) -> Result { + if self.is_empty() { + return Err(TemporalSeriesError::EmptySeries); + } + let mut values: Vec = vec![f64::NAN; self.len()]; + for (result, window) in values[1..].iter_mut().zip(self.values.windows(2)) { + *result = (window[1] - window[0]) / window[0]; + } + Self::new(self.index.clone(), values) } - /// TODO: use the formula for computing this! - /// TODO: Check how to work with logs on RUST - #[allow(dead_code)] - pub fn log_return(&self) -> f64 { - 0.0 + /// Returns the per-period logarithmic return series. + /// + /// # Formula + /// + /// $$r_t^{log} = \ln\!\left(\frac{x_t}{x_{t-1}}\right)$$ + /// + /// The first element is `NaN` because there is no prior observation. + /// Log returns are additive over time, making them useful for multi-period analysis. + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::EmptySeries`] if the series is empty. + /// + /// # Examples + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new(vec![1, 2], vec![1.0, std::f64::consts::E]).unwrap(); + /// let r = ts.log_return().unwrap(); + /// + /// assert!(r.values[0].is_nan()); + /// assert!((r.values[1] - 1.0).abs() < 1e-9); + /// ``` + pub fn log_return(&self) -> Result { + if self.is_empty() { + return Err(TemporalSeriesError::EmptySeries); + } + let mut values: Vec = vec![f64::NAN; self.len()]; + for (result, window) in values[1..].iter_mut().zip(self.values.windows(2)) { + *result = (window[1] / window[0]).ln(); + } + Self::new(self.index.clone(), values) } - /// TODO: use the formula for computing this! - #[allow(dead_code)] - pub fn cumulative_return(&self) -> f64 { - 0.0 + /// Returns the total cumulative return from the first to the last observation. + /// + /// # Formula + /// + /// $$R_{cum} = \frac{x_T - x_0}{x_0}$$ + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::EmptySeries`] if the series is empty. + /// + /// # Examples + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new(vec![1, 2, 3], vec![100.0, 110.0, 121.0]).unwrap(); + /// + /// // (121 - 100) / 100 = 0.21 + /// assert!((ts.cumulative_return().unwrap() - 0.21).abs() < 1e-9); + /// ``` + pub fn cumulative_return(&self) -> Result { + if self.is_empty() { + return Err(TemporalSeriesError::EmptySeries); + } + let x0: f64 = self.values[0]; + let xt: f64 = self.values[self.len() - 1]; + Ok((xt - x0) / x0) } - /// TODO: look for the correct type... probably return a time series? - #[allow(dead_code)] - #[allow(unused_variables)] - pub fn moving_average(&self, n: u64) -> f64 { - 0.0 + /// Returns the n-period simple moving average series. + /// + /// # Formula + /// + /// $$MA_t^{(n)} = \frac{1}{n} \sum_{i=0}^{n-1} x_{t-i}$$ + /// + /// The first `n - 1` elements are `NaN` because the window is not yet full. + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::InvalidWindow`] if `n` exceeds the series length. + /// + /// # Examples + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + /// let ma = ts.moving_average(3).unwrap(); + /// + /// assert!(ma.values[0].is_nan()); + /// assert!(ma.values[1].is_nan()); + /// assert!((ma.values[2] - 2.0).abs() < 1e-9); + /// assert!((ma.values[4] - 4.0).abs() < 1e-9); + /// ``` + pub fn moving_average(&self, n: usize) -> Result { + if n > self.len() { + return Err(TemporalSeriesError::InvalidWindow { + window: n, + series_len: self.len(), + }); + } + self.rolling(n).mean() } - /// TODO: use the formula for computing this! - #[allow(dead_code)] - pub fn exponential_moving_average(&self) -> f64 { - 0.0 + /// Returns the exponential moving average (EMA) series for a given span. + /// + /// # Formula + /// + /// $$\alpha = \frac{2}{span + 1}, \qquad EMA_t = \alpha \cdot x_t + (1 - \alpha) \cdot EMA_{t-1}$$ + /// + /// The first value seeds the EMA as $EMA_0 = x_0$. + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::EmptySeries`] if the series is empty. + /// + /// # Examples + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// // span=3 -> alpha=0.5 + /// let ts = TimeSeries::new(vec![1, 2, 3], vec![1.0, 2.0, 3.0]).unwrap(); + /// let ema = ts.exponential_moving_average(3).unwrap(); + /// + /// assert_eq!(ema.values[0], 1.0); + /// assert_eq!(ema.values[1], 1.5); // 0.5*2 + 0.5*1 + /// assert_eq!(ema.values[2], 2.25); // 0.5*3 + 0.5*1.5 + /// ``` + pub fn exponential_moving_average(&self, span: usize) -> Result { + if self.is_empty() { + return Err(TemporalSeriesError::EmptySeries); + } + let alpha: f64 = 2.0 / (span as f64 + 1.0); + let mut values: Vec = vec![0.0; self.len()]; + values[0] = self.values[0]; + for i in 1..self.len() { + values[i] = alpha * self.values[i] + (1.0 - alpha) * values[i - 1]; + } + Self::new(self.index.clone(), values) } - /// TODO: use the formula for computing this! - #[allow(dead_code)] - pub fn crossover_signal(&self) -> f64 { - 0.0 + /// Returns the MA crossover signal series. + /// + /// Compares a fast (shorter-window) moving average against a slow (longer-window) one + /// and marks crossover moments: + /// + /// - `+1.0` — fast MA crosses **above** slow MA (bullish signal) + /// - `-1.0` — fast MA crosses **below** slow MA (bearish signal) + /// - `0.0` — no crossover + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::ParameterRangeError`] if `fast >= slow`, or + /// [`TemporalSeriesError::InvalidWindow`] if either window exceeds the series length. + /// + /// # Examples + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// // Constant series — MAs are always equal, no crossover ever occurs. + /// let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![3.0, 3.0, 3.0, 3.0, 3.0]).unwrap(); + /// let sig = ts.crossover_signal(2, 3).unwrap(); + /// + /// assert!(sig.values.iter().all(|&v| v == 0.0)); + /// ``` + pub fn crossover_signal(&self, fast: usize, slow: usize) -> Result { + if fast >= slow { + return Err(TemporalSeriesError::ParameterRangeError(format!( + "fast window ({fast}) must be smaller than slow window ({slow})" + ))); + } + let fast_ma: TimeSeries = self.moving_average(fast)?; + let slow_ma: TimeSeries = self.moving_average(slow)?; + let mut signals: Vec = vec![0.0; self.len()]; + for i in 1..self.len() { + let prev: f64 = fast_ma.values[i - 1] - slow_ma.values[i - 1]; + let curr: f64 = fast_ma.values[i] - slow_ma.values[i]; + if prev.is_nan() || curr.is_nan() { + continue; + } + if prev <= 0.0 && curr > 0.0 { + signals[i] = 1.0; + } else if prev >= 0.0 && curr < 0.0 { + signals[i] = -1.0; + } + } + Self::new(self.index.clone(), signals) } // VOLATILITY ------------------------------------------------------------- - /// TODO: use the formula for computing this! - #[allow(dead_code)] - pub fn rolling_standard_derivation(&self) -> f64 { - 0.0 + + /// Returns the n-period rolling standard deviation series (Bessel-corrected). + /// + /// # Formula + /// + /// $$\sigma_t^{(n)} = \sqrt{\frac{1}{n-1} \sum_{i=0}^{n-1} \left(x_{t-i} - \bar{x}_t^{(n)}\right)^2}$$ + /// + /// The first `n - 1` elements are `NaN` because the window is not yet full. + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::InvalidWindow`] if `n` exceeds the series length or `n < 2`. + /// + /// # Examples + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// // Linear series [1..5] has rolling std 1.0 for every full window of 3. + /// let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + /// let std = ts.rolling_standard_deviation(3).unwrap(); + /// + /// assert!(std.values[0].is_nan()); + /// assert!(std.values[1].is_nan()); + /// assert!((std.values[2] - 1.0).abs() < 1e-9); + /// assert!((std.values[4] - 1.0).abs() < 1e-9); + /// ``` + pub fn rolling_standard_deviation(&self, n: usize) -> Result { + if n < 2 || n > self.len() { + return Err(TemporalSeriesError::InvalidWindow { + window: n, + series_len: self.len(), + }); + } + let len: usize = self.len(); + let mut result: Vec = vec![f64::NAN; len]; + for i in (n - 1)..len { + let window: &[f64] = &self.values[i + 1 - n..=i]; + let mean: f64 = window.iter().sum::() / n as f64; + let variance: f64 = + window.iter().map(|x| (x - mean).powi(2)).sum::() / (n - 1) as f64; + result[i] = variance.sqrt(); + } + Self::new(self.index.clone(), result) } - /// TODO: check how to compute this! - #[allow(dead_code)] - pub fn true_range(&self) -> f64 { - 0.0 + /// Returns the per-period true range series. + /// + /// For a univariate series (no OHLC data), the true range simplifies to the + /// absolute change between consecutive observations: + /// + /// $$TR_t = \left|x_t - x_{t-1}\right|$$ + /// + /// The first element is `NaN` because there is no prior observation. + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::EmptySeries`] if the series is empty. + /// + /// # Examples + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 3.0, 6.0, 10.0]).unwrap(); + /// let tr = ts.true_range().unwrap(); + /// + /// assert!(tr.values[0].is_nan()); + /// assert_eq!(tr.values[1], 2.0); + /// assert_eq!(tr.values[2], 3.0); + /// assert_eq!(tr.values[3], 4.0); + /// ``` + pub fn true_range(&self) -> Result { + if self.is_empty() { + return Err(TemporalSeriesError::EmptySeries); + } + let mut values: Vec = vec![f64::NAN; self.len()]; + for (result, window) in values[1..].iter_mut().zip(self.values.windows(2)) { + *result = (window[1] - window[0]).abs(); + } + Self::new(self.index.clone(), values) } - /// TODO: check how to compute this! - #[allow(dead_code)] - pub fn average_true_range(&self) -> f64 { - 0.0 + /// Returns the n-period average true range (ATR) series. + /// + /// # Formula + /// + /// $$ATR_t^{(n)} = \frac{1}{n} \sum_{i=0}^{n-1} TR_{t-i}, \qquad TR_t = \left|x_t - x_{t-1}\right|$$ + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::InvalidWindow`] if `n` is too large for the series. + /// + /// # Examples + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 3.0, 6.0, 10.0]).unwrap(); + /// let atr = ts.average_true_range(2).unwrap(); + /// + /// // TR = [NaN, 2, 3, 4]; ATR(2): mean(2,3)=2.5 at t=2, mean(3,4)=3.5 at t=3 + /// assert!((atr.values[2] - 2.5).abs() < 1e-9); + /// assert!((atr.values[3] - 3.5).abs() < 1e-9); + /// ``` + pub fn average_true_range(&self, n: usize) -> Result { + self.true_range()?.rolling(n).mean() } - /// TODO: check how we can return both bands... what is the most rustonean way to do it? - #[allow(dead_code)] - pub fn borillenger_bands(&self) -> f64 { - 0.0 + /// Returns Bollinger Bands as `(upper, middle, lower)`. + /// + /// # Formula + /// + /// $$BB_{upper}(t) = MA_t^{(w)} + k \cdot \sigma_t^{(w)}$$ + /// + /// $$BB_{mid}(t) = MA_t^{(w)}$$ + /// + /// $$BB_{lower}(t) = MA_t^{(w)} - k \cdot \sigma_t^{(w)}$$ + /// + /// where $w$ is the window size, $k$ the band multiplier (typically 2), and + /// $\sigma$ uses Bessel's correction (see [`TimeSeries::rolling_standard_deviation`]). + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::InvalidWindow`] if `window` is invalid. + /// + /// # Examples + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new( + /// vec![1, 2, 3, 4, 5], + /// vec![3.0, 3.0, 3.0, 3.0, 3.0], + /// ).unwrap(); + /// + /// let (upper, mid, lower) = ts.bollinger_bands(3, 2.0).unwrap(); + /// + /// // Constant series: std=0, all bands collapse onto the mean. + /// assert_eq!(upper.values[2], 3.0); + /// assert_eq!(mid.values[2], 3.0); + /// assert_eq!(lower.values[2], 3.0); + /// ``` + pub fn bollinger_bands( + &self, + window: usize, + k: f64, + ) -> Result<(TimeSeries, TimeSeries, TimeSeries), TemporalSeriesError> { + let middle: TimeSeries = self.moving_average(window)?; + let rolling_std: TimeSeries = self.rolling_standard_deviation(window)?; + let upper_values: Vec = middle + .values + .iter() + .zip(rolling_std.values.iter()) + .map(|(&m, &s)| m + k * s) + .collect(); + let lower_values: Vec = middle + .values + .iter() + .zip(rolling_std.values.iter()) + .map(|(&m, &s)| m - k * s) + .collect(); + let upper: TimeSeries = TimeSeries::new(self.index.clone(), upper_values)?; + let lower: TimeSeries = TimeSeries::new(self.index.clone(), lower_values)?; + Ok((upper, middle, lower)) } // AUTOCORRELATION -------------------------------------------------------- - /// TODO: check how to compute this - #[allow(dead_code)] - pub fn autocorrelation_function(&self) -> f64 { - 0.0 + + /// Returns the autocorrelation function (ACF) at a given lag. + /// + /// # Formula + /// + /// $$\rho(k) = \frac{\displaystyle\sum_{t=k}^{n-1}(x_t - \bar{x})(x_{t-k} - \bar{x})}{\displaystyle\sum_{t=0}^{n-1}(x_t - \bar{x})^2}$$ + /// + /// By definition $\rho(0) = 1$. Returns `NaN` if the series has zero variance. + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::EmptySeries`] if the series is empty, or + /// [`TemporalSeriesError::ParameterRangeError`] if `lag >= n`. + /// + /// # Examples + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + /// + /// assert_eq!(ts.autocorrelation_function(0).unwrap(), 1.0); + /// assert!((ts.autocorrelation_function(1).unwrap() - 0.4).abs() < 1e-9); + /// ``` + pub fn autocorrelation_function(&self, lag: usize) -> Result { + let n: usize = self.len(); + if n == 0 { + return Err(TemporalSeriesError::EmptySeries); + } + if lag >= n { + return Err(TemporalSeriesError::ParameterRangeError(format!( + "lag ({lag}) must be less than series length ({n})" + ))); + } + let mean: f64 = self.mean(); + let variance: f64 = self.values.iter().map(|x| (x - mean).powi(2)).sum::(); + if variance == 0.0 { + return Ok(f64::NAN); + } + let covariance: f64 = (lag..n) + .map(|t| (self.values[t] - mean) * (self.values[t - lag] - mean)) + .sum::(); + Ok(covariance / variance) } - /// TODO: check how to compute this - #[allow(dead_code)] - pub fn partial_autocorrelation_function(&self) -> f64 { - 0.0 + /// Returns the partial autocorrelation function (PACF) at a given lag. + /// + /// Uses the Levinson-Durbin recursion to isolate the correlation between + /// $x_t$ and $x_{t-k}$ after removing the linear influence of all intermediate lags. + /// + /// - $PACF(0) = 1$ + /// - $PACF(1) = \rho(1)$ + /// - $PACF(k) = \phi_{k,k}$ via Levinson-Durbin for $k \geq 2$ + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::EmptySeries`] if the series is empty, or + /// [`TemporalSeriesError::ParameterRangeError`] if `lag >= n`. + /// + /// # Examples + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + /// + /// assert_eq!(ts.partial_autocorrelation_function(0).unwrap(), 1.0); + /// assert!((ts.partial_autocorrelation_function(1).unwrap() - 0.4).abs() < 1e-9); + /// ``` + pub fn partial_autocorrelation_function(&self, lag: usize) -> Result { + let n: usize = self.len(); + if n == 0 { + return Err(TemporalSeriesError::EmptySeries); + } + if lag >= n { + return Err(TemporalSeriesError::ParameterRangeError(format!( + "lag ({lag}) must be less than series length ({n})" + ))); + } + if lag == 0 { + return Ok(1.0); + } + let acf: Vec = (1..=lag) + .map(|k| self.autocorrelation_function(k)) + .collect::, _>>()?; + + // Levinson-Durbin: phi[j] holds the AR coefficients for the current order. + let mut phi: Vec = vec![acf[0]]; + for k in 1..lag { + let num: f64 = + acf[k] - (0..k).map(|j| phi[j] * acf[k - 1 - j]).sum::(); + let den: f64 = + 1.0 - (0..k).map(|j| phi[j] * acf[j]).sum::(); + let phi_kk: f64 = if den.abs() < f64::EPSILON { 0.0 } else { num / den }; + let prev: Vec = phi.clone(); + let updated: Vec = (0..k) + .map(|j| prev[j] - phi_kk * prev[k - 1 - j]) + .collect(); + phi = updated; + phi.push(phi_kk); + } + Ok(*phi.last().unwrap()) } // STATIONARITY ----------------------------------------------------------- - /// TODO: add test for this function in this same file - /// CHECK the formula for computing this statistic - #[allow(dead_code)] - fn stationary_dickey_fuller_statistics(&self) -> f64 { - 0.0 + /// Computes the Dickey-Fuller test statistic for a unit root. + /// + /// Fits $\Delta x_t = \gamma x_{t-1} + \varepsilon_t$ via OLS and returns + /// $\hat{\gamma} / SE(\hat{\gamma})$. Under $H_0$ (unit root) this statistic does + /// not follow a standard $t$-distribution — interpret via + /// [`TimeSeries::stationary_dickey_fuller_test`]. + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::EmptySeries`] if `n < 3`. + pub fn stationary_dickey_fuller_statistics(&self) -> Result { + let n: usize = self.len(); + if n < 3 { + return Err(TemporalSeriesError::EmptySeries); + } + let delta: Vec = (1..n).map(|t| self.values[t] - self.values[t - 1]).collect(); + let lagged: Vec = (0..n - 1).map(|t| self.values[t]).collect(); + + let ss_xy: f64 = lagged.iter().zip(delta.iter()).map(|(x, y)| x * y).sum(); + let ss_xx: f64 = lagged.iter().map(|x| x * x).sum(); + if ss_xx.abs() < f64::EPSILON { + return Ok(0.0); + } + let gamma: f64 = ss_xy / ss_xx; + let sse: f64 = lagged + .iter() + .zip(delta.iter()) + .map(|(x, y)| (y - gamma * x).powi(2)) + .sum(); + if sse < f64::EPSILON { + return Ok(f64::NEG_INFINITY); + } + let m: usize = lagged.len(); + let sigma2: f64 = sse / (m - 1) as f64; + let se: f64 = (sigma2 / ss_xx).sqrt(); + Ok(gamma / se) } - /// TODO: add test for this function in this same file - /// CHECK the formula for computing this statistic - #[allow(dead_code)] - #[allow(unused_variables)] - pub fn stationary_dickey_fuller_test(&self, alpha: f32) -> bool { - true + /// Tests for stationarity using the Dickey-Fuller test. + /// + /// Returns `true` if the unit-root null hypothesis is rejected at `alpha` + /// (i.e. the series is stationary). + /// + /// Critical values (no constant, no trend): + /// + /// | alpha | critical value | + /// |-------|---------------| + /// | 0.10 | −1.61 | + /// | 0.05 | −1.95 | + /// | 0.01 | −2.60 | + /// + /// # Errors + /// + /// Propagates errors from [`TimeSeries::stationary_dickey_fuller_statistics`]. + /// + /// # Examples + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// // Pure trend — non-stationary; H0 should not be rejected. + /// let rw = TimeSeries::new( + /// vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + /// vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0], + /// ).unwrap(); + /// assert!(!rw.stationary_dickey_fuller_test(0.05).unwrap()); + /// ``` + pub fn stationary_dickey_fuller_test(&self, alpha: f32) -> Result { + let critical_value: f64 = match alpha { + a if a <= 0.01 => -2.60, + a if a <= 0.05 => -1.95, + _ => -1.61, + }; + let statistic: f64 = self.stationary_dickey_fuller_statistics()?; + Ok(statistic < critical_value) } // DISTRIBUTION ANALYSIS -------------------------------------------------- - /// TODO: add test for this function in this same file - /// TODO: CHECK the formula for computing this statistic - #[allow(dead_code)] - pub fn skewness(&self) -> f64 { - 0.0 + /// Returns the Fisher-Pearson skewness of the series. + /// + /// # Formula + /// + /// $$\text{Skew} = \frac{\displaystyle\frac{1}{n}\sum_{i=1}^{n}(x_i - \bar{x})^3}{\left(\displaystyle\frac{1}{n}\sum_{i=1}^{n}(x_i - \bar{x})^2\right)^{3/2}}$$ + /// + /// A symmetric distribution has skewness 0; positive values indicate a right tail, + /// negative values a left tail. + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::EmptySeries`] if `n < 3`. + /// + /// # Examples + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// // Symmetric series has zero skewness. + /// let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + /// assert!(ts.skewness().unwrap().abs() < 1e-9); + /// ``` + pub fn skewness(&self) -> Result { + let n: usize = self.len(); + if n < 3 { + return Err(TemporalSeriesError::EmptySeries); + } + let mean: f64 = self.mean(); + let nf: f64 = n as f64; + let m2: f64 = self.values.iter().map(|x| (x - mean).powi(2)).sum::() / nf; + let m3: f64 = self.values.iter().map(|x| (x - mean).powi(3)).sum::() / nf; + if m2 < f64::EPSILON { + return Ok(0.0); + } + Ok(m3 / m2.powf(1.5)) } - /// TODO: CHECK the formula for computing this statistic - #[allow(dead_code)] - pub fn excess_kurtosis(&self) -> f64 { - 0.0 + /// Returns the excess kurtosis of the series. + /// + /// # Formula + /// + /// $$\kappa_{excess} = \frac{\displaystyle\frac{1}{n}\sum_{i=1}^{n}(x_i - \bar{x})^4}{\left(\displaystyle\frac{1}{n}\sum_{i=1}^{n}(x_i - \bar{x})^2\right)^{2}} - 3$$ + /// + /// A normal distribution has excess kurtosis 0 (mesokurtic). Positive values indicate + /// heavy tails (leptokurtic); negative values indicate light tails (platykurtic). + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::EmptySeries`] if `n < 4`. + /// + /// # Examples + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// // [1,2,3,4,5]: m2=2.0, m4=6.8 => 6.8/4.0 - 3 = -1.3 + /// let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + /// assert!((ts.excess_kurtosis().unwrap() - (-1.3)).abs() < 1e-9); + /// ``` + pub fn excess_kurtosis(&self) -> Result { + let n: usize = self.len(); + if n < 4 { + return Err(TemporalSeriesError::EmptySeries); + } + let mean: f64 = self.mean(); + let nf: f64 = n as f64; + let m2: f64 = self.values.iter().map(|x| (x - mean).powi(2)).sum::() / nf; + let m4: f64 = self.values.iter().map(|x| (x - mean).powi(4)).sum::() / nf; + if m2 < f64::EPSILON { + return Ok(-3.0); + } + Ok(m4 / m2.powi(2) - 3.0) } - /// TODO: add test for this function in this same file - /// TODO: look for this formula - #[allow(dead_code)] - fn jacque_bera_statistics(&self) -> f64 { - 0.0 + /// Computes the Jarque-Bera test statistic. + /// + /// # Formula + /// + /// $$JB = n\!\left(\frac{S^2}{6} + \frac{K^2}{24}\right)$$ + /// + /// where $S$ is the skewness and $K$ the excess kurtosis. + /// Under $H_0$ (normality), $JB \sim \chi^2(2)$. + /// + /// # Errors + /// + /// Propagates errors from [`TimeSeries::skewness`] and [`TimeSeries::excess_kurtosis`]. + pub fn jacque_bera_statistics(&self) -> Result { + let n: f64 = self.len() as f64; + let s: f64 = self.skewness()?; + let k: f64 = self.excess_kurtosis()?; + Ok(n * (s.powi(2) / 6.0 + k.powi(2) / 24.0)) } - /// TODO: check this test implementation - #[allow(dead_code)] - #[allow(unused_variables)] - pub fn jacque_bera_test(&self, alpha: f32) -> bool { - true + /// Tests for normality using the Jarque-Bera test. + /// + /// Returns `true` if the series is consistent with normality ($H_0$ not rejected). + /// + /// Critical values from $\chi^2(2)$: + /// + /// | alpha | critical value | + /// |-------|---------------| + /// | 0.10 | 4.605 | + /// | 0.05 | 5.991 | + /// | 0.01 | 9.210 | + /// + /// # Errors + /// + /// Propagates errors from [`TimeSeries::jacque_bera_statistics`]. + /// + /// # Examples + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// // [1,2,3,4,5] produces a small JB statistic — consistent with normality at 5%. + /// let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + /// assert!(ts.jacque_bera_test(0.05).unwrap()); + /// ``` + pub fn jacque_bera_test(&self, alpha: f32) -> Result { + let critical_value: f64 = match alpha { + a if a <= 0.01 => 9.210, + a if a <= 0.05 => 5.991, + _ => 4.605, + }; + let jb: f64 = self.jacque_bera_statistics()?; + Ok(jb < critical_value) } /// Shifts the series forward by `periods` positions. diff --git a/tests/series/test_autocorrelation_function.rs b/tests/series/test_autocorrelation_function.rs index 9fcc8c8..5b46f6d 100644 --- a/tests/series/test_autocorrelation_function.rs +++ b/tests/series/test_autocorrelation_function.rs @@ -1,14 +1,64 @@ -use temporalseries::series::TimeSeries; +use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; #[test] #[allow(non_snake_case)] -fn test__given_valid_time_series_object__when_compute_autocorrelation_function__then_returns_it_correctly() - { +fn test__given_any_series__when_compute_acf_lag_0__then_returns_one() { // Given - let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); - let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + let sut_1: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![1.0, 2.0, 3.0]).unwrap(); + let sut_2: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4, 5], vec![5.0, 2.0, 8.0, 1.0, 4.0]).unwrap(); // When + let result_1: Result = sut_1.autocorrelation_function(0); + let result_2: Result = sut_2.autocorrelation_function(0); // Then + assert!((result_1.unwrap() - 1.0).abs() < 1e-9); + assert!((result_2.unwrap() - 1.0).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_linear_series__when_compute_acf_lag_1__then_computes_correctly() { + // Given + // [1,2,3,4,5], mean=3, variance=10 + // ACF(1) = [(-1)(-2)+(0)(-1)+(1)(0)+(2)(1)] / 10 = 4/10 = 0.4 + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + + // When + let result: Result = sut.autocorrelation_function(1); + + // Then + assert!((result.unwrap() - 0.4).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_constant_series__when_compute_acf__then_returns_nan() { + // Given + // Zero variance -> ACF is NaN + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![7.0, 7.0, 7.0]).unwrap(); + + // When + let result: Result = sut.autocorrelation_function(1); + + // Then + assert!(result.unwrap().is_nan()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_lag_out_of_range__when_compute_acf__then_returns_error() { + // Given + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![1.0, 2.0, 3.0]).unwrap(); + + // When + let result: Result = sut.autocorrelation_function(5); + + // Then + assert!(matches!( + result, + Err(TemporalSeriesError::ParameterRangeError(_)) + )); } diff --git a/tests/series/test_average_true_range.rs b/tests/series/test_average_true_range.rs index 77ccd7e..63b82e6 100644 --- a/tests/series/test_average_true_range.rs +++ b/tests/series/test_average_true_range.rs @@ -1,14 +1,41 @@ -use temporalseries::series::TimeSeries; +use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; #[test] #[allow(non_snake_case)] -fn test__given_valid_time_series_object__when_compute_average_true_range__then_returns_it_correctly() - { +fn test__given_constant_series__when_compute_average_true_range__then_returns_zeros() { // Given - let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); - let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4], vec![5.0, 5.0, 5.0, 5.0]).unwrap(); // When + let result: Result = sut.average_true_range(2); // Then + let atr: TimeSeries = result.unwrap(); + // TR = [NaN, 0, 0, 0]; ATR(2): positions 0,1 = NaN, positions 2,3 = 0 + assert!(atr.values[0].is_nan()); + assert!(atr.values[1].is_nan()); + assert!(atr.values[2] == 0.0); + assert!(atr.values[3] == 0.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_growing_series_window_2__when_compute_average_true_range__then_computes_correctly() +{ + // Given + // [1, 3, 6, 10] -> TR = [NaN, 2, 3, 4] + // ATR(2): t=2: mean(2,3)=2.5, t=3: mean(3,4)=3.5 + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 3.0, 6.0, 10.0]).unwrap(); + + // When + let result: Result = sut.average_true_range(2); + + // Then + let atr: TimeSeries = result.unwrap(); + assert!(atr.values[0].is_nan()); + assert!(atr.values[1].is_nan()); + assert!((atr.values[2] - 2.5).abs() < 1e-9); + assert!((atr.values[3] - 3.5).abs() < 1e-9); } diff --git a/tests/series/test_borillenger_bads.rs b/tests/series/test_borillenger_bads.rs index 86650bb..0d1b237 100644 --- a/tests/series/test_borillenger_bads.rs +++ b/tests/series/test_borillenger_bads.rs @@ -1,13 +1,53 @@ -use temporalseries::series::TimeSeries; +use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; #[test] #[allow(non_snake_case)] -fn test__given_valid_time_series_object__when_compute_borilleng_bands__then_returns_it_correctly() { +fn test__given_constant_series__when_compute_bollinger_bands__then_all_bands_collapse_onto_mean() { // Given - let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); - let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4, 5], vec![3.0, 3.0, 3.0, 3.0, 3.0]).unwrap(); // When + let result: Result<(TimeSeries, TimeSeries, TimeSeries), TemporalSeriesError> = + sut.bollinger_bands(3, 2.0); // Then + let (upper, mid, lower): (TimeSeries, TimeSeries, TimeSeries) = result.unwrap(); + assert!((upper.values[2] - 3.0).abs() < 1e-9); + assert!((mid.values[2] - 3.0).abs() < 1e-9); + assert!((lower.values[2] - 3.0).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_linear_series__when_compute_bollinger_bands__then_upper_above_lower() { + // Given + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + + // When + let result: Result<(TimeSeries, TimeSeries, TimeSeries), TemporalSeriesError> = + sut.bollinger_bands(3, 2.0); + + // Then + let (upper, mid, lower): (TimeSeries, TimeSeries, TimeSeries) = result.unwrap(); + // For every position where the band is defined, upper >= mid >= lower + for i in 2..5 { + assert!(upper.values[i] >= mid.values[i]); + assert!(mid.values[i] >= lower.values[i]); + } +} + +#[test] +#[allow(non_snake_case)] +fn test__given_invalid_window__when_compute_bollinger_bands__then_returns_error() { + // Given + let sut: TimeSeries = TimeSeries::new(vec![1, 2], vec![1.0, 2.0]).unwrap(); + + // When + let result: Result<(TimeSeries, TimeSeries, TimeSeries), TemporalSeriesError> = + sut.bollinger_bands(5, 2.0); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::InvalidWindow { .. }))); } diff --git a/tests/series/test_crossover_signal.rs b/tests/series/test_crossover_signal.rs index dd4cd14..0544171 100644 --- a/tests/series/test_crossover_signal.rs +++ b/tests/series/test_crossover_signal.rs @@ -1,14 +1,57 @@ -use temporalseries::series::TimeSeries; +use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; #[test] #[allow(non_snake_case)] -fn test__given_valid_time_series_object__when_compute_crossover_signal__then_returns_it_correctly() +fn test__given_constant_series__when_compute_crossover_signal__then_all_signals_are_zero() { + // Given + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4, 5], vec![3.0, 3.0, 3.0, 3.0, 3.0]).unwrap(); + + // When + let result: Result = sut.crossover_signal(2, 3); + + // Then + let sig: TimeSeries = result.unwrap(); + assert!(sig.values.iter().all(|&v| v == 0.0)); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_bullish_crossover__when_compute_crossover_signal__then_detects_plus_one() { // Given - let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); - let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + // Falling then rising: fast MA (2) crosses above slow MA (3) partway through. + // [3,2,1,2,3,4,5]: fast crosses slow after the trough. + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4, 5, 6, 7], vec![3.0, 2.0, 1.0, 2.0, 3.0, 4.0, 5.0]) + .unwrap(); + + // When + let result: Result = sut.crossover_signal(2, 3); + + // Then + let sig: TimeSeries = result.unwrap(); + assert!(sig.values.iter().any(|&v| v == 1.0)); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_fast_not_smaller_than_slow__when_compute_crossover_signal__then_returns_error() { + // Given + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); // When + let result_equal: Result = sut.crossover_signal(3, 3); + let result_fast_larger: Result = sut.crossover_signal(4, 2); // Then + assert!(matches!( + result_equal, + Err(TemporalSeriesError::ParameterRangeError(_)) + )); + assert!(matches!( + result_fast_larger, + Err(TemporalSeriesError::ParameterRangeError(_)) + )); } diff --git a/tests/series/test_cumulative_return.rs b/tests/series/test_cumulative_return.rs index c69493f..b58c743 100644 --- a/tests/series/test_cumulative_return.rs +++ b/tests/series/test_cumulative_return.rs @@ -1,14 +1,56 @@ -use temporalseries::series::TimeSeries; +use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; #[test] #[allow(non_snake_case)] -fn test__given_valid_time_series_object__when_compute_cumulative_returns__then_returns_it_correctly() - { +fn test__given_constant_series__when_compute_cumulative_return__then_returns_zero() { // Given - let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); - let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![5.0, 5.0, 5.0]).unwrap(); // When + let result: Result = sut.cumulative_return(); // Then + assert!(result.unwrap() == 0.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_growing_series__when_compute_cumulative_return__then_computes_correctly() { + // Given + // (121 - 100) / 100 = 0.21 + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3], vec![100.0, 110.0, 121.0]).unwrap(); + + // When + let result: Result = sut.cumulative_return(); + + // Then + assert!((result.unwrap() - 0.21).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_halving_series__when_compute_cumulative_return__then_returns_minus_half() { + // Given + // (50 - 100) / 100 = -0.5 + let sut: TimeSeries = TimeSeries::new(vec![1, 2], vec![100.0, 50.0]).unwrap(); + + // When + let result: Result = sut.cumulative_return(); + + // Then + assert!((result.unwrap() - (-0.5)).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_empty_series__when_compute_cumulative_return__then_returns_empty_series_error() { + // Given + let sut: TimeSeries = TimeSeries::new(vec![], vec![]).unwrap(); + + // When + let result: Result = sut.cumulative_return(); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::EmptySeries))); } diff --git a/tests/series/test_dickey_fuller_test.rs b/tests/series/test_dickey_fuller_test.rs index 6ef340a..f64a98e 100644 --- a/tests/series/test_dickey_fuller_test.rs +++ b/tests/series/test_dickey_fuller_test.rs @@ -1,14 +1,51 @@ -use temporalseries::series::TimeSeries; +use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; #[test] #[allow(non_snake_case)] -fn test__given_valid_time_series_object__when_compute_dickey_fuller_test__then_returns_it_correctly() - { +fn test__given_linear_trend__when_compute_dickey_fuller_test__then_returns_non_stationary() { // Given - let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); - let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + // Pure upward trend is non-stationary — DF statistic >> -1.95, H0 not rejected + let sut: TimeSeries = TimeSeries::new( + vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0], + ) + .unwrap(); // When + let result: Result = sut.stationary_dickey_fuller_test(0.05); // Then + assert!(!result.unwrap()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_mean_reverting_series__when_compute_dickey_fuller_test__then_returns_stationary() { + // Given + // Alternating ±1 around zero — strongly mean-reverting, DF statistic -> -inf + let sut: TimeSeries = TimeSeries::new( + vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + vec![1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0], + ) + .unwrap(); + + // When + let result: Result = sut.stationary_dickey_fuller_test(0.05); + + // Then + assert!(result.unwrap()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_short_series__when_compute_dickey_fuller_test__then_returns_error() { + // Given + // Requires at least 3 elements + let sut: TimeSeries = TimeSeries::new(vec![1, 2], vec![1.0, 2.0]).unwrap(); + + // When + let result: Result = sut.stationary_dickey_fuller_test(0.05); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::EmptySeries))); } diff --git a/tests/series/test_excess_kurtosis.rs b/tests/series/test_excess_kurtosis.rs index 8538e4f..4a6af0c 100644 --- a/tests/series/test_excess_kurtosis.rs +++ b/tests/series/test_excess_kurtosis.rs @@ -1,13 +1,30 @@ -use temporalseries::series::TimeSeries; +use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; #[test] #[allow(non_snake_case)] -fn test__given_valid_time_series_object__when_compute_excess_kurtosis__then_returns_it_correctly() { +fn test__given_linear_series__when_compute_excess_kurtosis__then_computes_correctly() { // Given - let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); - let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + // [1,2,3,4,5]: m2=2.0, m4=6.8 + // excess_kurtosis = 6.8 / 2.0^2 - 3 = 1.7 - 3 = -1.3 + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); // When + let result: Result = sut.excess_kurtosis(); // Then + assert!((result.unwrap() - (-1.3)).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_too_short__when_compute_excess_kurtosis__then_returns_error() { + // Given + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![1.0, 2.0, 3.0]).unwrap(); + + // When + let result: Result = sut.excess_kurtosis(); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::EmptySeries))); } diff --git a/tests/series/test_exponential_moving_average.rs b/tests/series/test_exponential_moving_average.rs index 9025bc0..8f8fad6 100644 --- a/tests/series/test_exponential_moving_average.rs +++ b/tests/series/test_exponential_moving_average.rs @@ -1,14 +1,53 @@ -use temporalseries::series::TimeSeries; +use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; #[test] #[allow(non_snake_case)] -fn test__given_valid_time_series_object__when_compute_exponential_moving_average__then_returns_it_correctly() - { +fn test__given_constant_series__when_compute_ema__then_returns_constant() { // Given - let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); - let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![4.0, 4.0, 4.0]).unwrap(); // When + let result: Result = sut.exponential_moving_average(3); // Then + let ema: TimeSeries = result.unwrap(); + assert!(ema.values.iter().all(|&v| v == 4.0)); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_linear_series_span_3__when_compute_ema__then_computes_correctly() { + // Given + // span=3 -> alpha = 2/(3+1) = 0.5 + // EMA[0] = 1.0 + // EMA[1] = 0.5*2 + 0.5*1.0 = 1.5 + // EMA[2] = 0.5*3 + 0.5*1.5 = 2.25 + // EMA[3] = 0.5*4 + 0.5*2.25 = 3.125 + // EMA[4] = 0.5*5 + 0.5*3.125 = 4.0625 + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + + // When + let result: Result = sut.exponential_moving_average(3); + + // Then + let ema: TimeSeries = result.unwrap(); + assert!((ema.values[0] - 1.0).abs() < 1e-9); + assert!((ema.values[1] - 1.5).abs() < 1e-9); + assert!((ema.values[2] - 2.25).abs() < 1e-9); + assert!((ema.values[3] - 3.125).abs() < 1e-9); + assert!((ema.values[4] - 4.0625).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_empty_series__when_compute_ema__then_returns_empty_series_error() { + // Given + let sut: TimeSeries = TimeSeries::new(vec![], vec![]).unwrap(); + + // When + let result: Result = sut.exponential_moving_average(3); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::EmptySeries))); } diff --git a/tests/series/test_jacque_bera_test.rs b/tests/series/test_jacque_bera_test.rs index f1da0d9..96eb4d3 100644 --- a/tests/series/test_jacque_bera_test.rs +++ b/tests/series/test_jacque_bera_test.rs @@ -1,13 +1,49 @@ -use temporalseries::series::TimeSeries; +use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; #[test] #[allow(non_snake_case)] -fn test__given_valid_time_series_object__when_compute_jacque_bera__then_returns_it_correctly() { +fn test__given_near_normal_series__when_compute_jacque_bera_test__then_returns_true() { // Given - let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); - let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + // [1,2,3,4,5]: skewness=0, excess_kurtosis=-1.3 + // JB = 5*(0/6 + 1.69/24) = 0.352 < 5.991 -> fail to reject normality + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); // When + let result: Result = sut.jacque_bera_test(0.05); // Then + assert!(result.unwrap()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_highly_skewed_series__when_compute_jacque_bera_test__then_returns_false() { + // Given + // [1,1,1,1,1,1,1,1,1,20]: strong right skew, heavy tail -> JB >> 5.991 -> reject normality + let sut: TimeSeries = TimeSeries::new( + vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + vec![1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 20.0], + ) + .unwrap(); + + // When + let result: Result = sut.jacque_bera_test(0.05); + + // Then + assert!(!result.unwrap()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_too_short__when_compute_jacque_bera_test__then_returns_error() { + // Given + // jacque_bera_statistics requires n >= 4 (excess_kurtosis requirement) + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![1.0, 2.0, 3.0]).unwrap(); + + // When + let result: Result = sut.jacque_bera_test(0.05); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::EmptySeries))); } diff --git a/tests/series/test_log_return.rs b/tests/series/test_log_return.rs index f94f047..5e57329 100644 --- a/tests/series/test_log_return.rs +++ b/tests/series/test_log_return.rs @@ -1,13 +1,63 @@ -use temporalseries::series::TimeSeries; +use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; #[test] #[allow(non_snake_case)] -fn test__given_valid_time_series_object__when_compute_log_returns__then_returns_it_correctly() { +fn test__given_constant_series__when_compute_log_return__then_all_returns_are_zero() { // Given - let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); - let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![5.0, 5.0, 5.0]).unwrap(); // When + let result: Result = sut.log_return(); // Then + let r: TimeSeries = result.unwrap(); + assert!(r.values[0].is_nan()); + assert!(r.values[1] == 0.0); + assert!(r.values[2] == 0.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_e_ratio__when_compute_log_return__then_returns_one() { + // Given + // ln(e / 1) = 1.0 + let sut: TimeSeries = + TimeSeries::new(vec![1, 2], vec![1.0, std::f64::consts::E]).unwrap(); + + // When + let result: Result = sut.log_return(); + + // Then + let r: TimeSeries = result.unwrap(); + assert!(r.values[0].is_nan()); + assert!((r.values[1] - 1.0).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_growing_series__when_compute_log_return__then_computes_correctly() { + // Given + // [100, 110] -> r_1 = ln(110/100) = ln(1.1) + let sut: TimeSeries = TimeSeries::new(vec![1, 2], vec![100.0, 110.0]).unwrap(); + + // When + let result: Result = sut.log_return(); + + // Then + let r: TimeSeries = result.unwrap(); + assert!(r.values[0].is_nan()); + assert!((r.values[1] - (1.1_f64).ln()).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_empty_series__when_compute_log_return__then_returns_empty_series_error() { + // Given + let sut: TimeSeries = TimeSeries::new(vec![], vec![]).unwrap(); + + // When + let result: Result = sut.log_return(); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::EmptySeries))); } diff --git a/tests/series/test_moving_average.rs b/tests/series/test_moving_average.rs index 5de513c..d2e709e 100644 --- a/tests/series/test_moving_average.rs +++ b/tests/series/test_moving_average.rs @@ -1,13 +1,53 @@ -use temporalseries::series::TimeSeries; +use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; #[test] #[allow(non_snake_case)] -fn test__given_valid_time_series_object__when_compute_moving_average__then_returns_it_correctly() { +fn test__given_constant_series__when_compute_moving_average__then_returns_constant() { // Given - let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); - let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![4.0, 4.0, 4.0]).unwrap(); // When + let result: Result = sut.moving_average(2); // Then + let ma: TimeSeries = result.unwrap(); + assert!(ma.values[0].is_nan()); + assert!(ma.values[1] == 4.0); + assert!(ma.values[2] == 4.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_linear_series_window_3__when_compute_moving_average__then_computes_correctly() { + // Given + // [1,2,3,4,5], window=3 + // MA[2] = (1+2+3)/3 = 2.0 + // MA[3] = (2+3+4)/3 = 3.0 + // MA[4] = (3+4+5)/3 = 4.0 + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + + // When + let result: Result = sut.moving_average(3); + + // Then + let ma: TimeSeries = result.unwrap(); + assert!(ma.values[0].is_nan()); + assert!(ma.values[1].is_nan()); + assert!((ma.values[2] - 2.0).abs() < 1e-9); + assert!((ma.values[3] - 3.0).abs() < 1e-9); + assert!((ma.values[4] - 4.0).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_window_larger_than_series__when_compute_moving_average__then_returns_error() { + // Given + let sut: TimeSeries = TimeSeries::new(vec![1, 2], vec![1.0, 2.0]).unwrap(); + + // When + let result: Result = sut.moving_average(5); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::InvalidWindow { .. }))); } diff --git a/tests/series/test_partial_autocorrelation_function.rs b/tests/series/test_partial_autocorrelation_function.rs index 3b417c4..2243854 100644 --- a/tests/series/test_partial_autocorrelation_function.rs +++ b/tests/series/test_partial_autocorrelation_function.rs @@ -1,14 +1,48 @@ -use temporalseries::series::TimeSeries; +use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; #[test] #[allow(non_snake_case)] -fn test__given_valid_time_series_object__when_compute_partial_autocorrelation_function__then_returns_it_correctly() - { +fn test__given_any_series__when_compute_pacf_lag_0__then_returns_one() { // Given - let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); - let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); // When + let result: Result = sut.partial_autocorrelation_function(0); // Then + assert!((result.unwrap() - 1.0).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series__when_compute_pacf_lag_1__then_equals_acf_lag_1() { + // Given + // PACF(1) == ACF(1) by definition + // For [1,2,3,4,5]: ACF(1) = 0.4 + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + + // When + let result_pacf: Result = sut.partial_autocorrelation_function(1); + let result_acf: Result = sut.autocorrelation_function(1); + + // Then + assert!((result_pacf.unwrap() - result_acf.unwrap()).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_lag_out_of_range__when_compute_pacf__then_returns_error() { + // Given + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![1.0, 2.0, 3.0]).unwrap(); + + // When + let result: Result = sut.partial_autocorrelation_function(5); + + // Then + assert!(matches!( + result, + Err(TemporalSeriesError::ParameterRangeError(_)) + )); } diff --git a/tests/series/test_rolling_standard_deviation.rs b/tests/series/test_rolling_standard_deviation.rs index 9cb91bf..ae683dc 100644 --- a/tests/series/test_rolling_standard_deviation.rs +++ b/tests/series/test_rolling_standard_deviation.rs @@ -1,14 +1,63 @@ -use temporalseries::series::TimeSeries; +use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; #[test] #[allow(non_snake_case)] -fn test__given_valid_time_series_object__when_compute_rolling_standard_deviation__then_returns_it_correctly() - { +fn test__given_constant_series__when_compute_rolling_std__then_returns_zeros() { // Given - let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); - let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4], vec![5.0, 5.0, 5.0, 5.0]).unwrap(); // When + let result: Result = sut.rolling_standard_deviation(2); // Then + let std: TimeSeries = result.unwrap(); + assert!(std.values[0].is_nan()); + assert!(std.values[1] == 0.0); + assert!(std.values[2] == 0.0); + assert!(std.values[3] == 0.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_linear_series_window_3__when_compute_rolling_std__then_computes_correctly() { + // Given + // [1,2,3,4,5], window=3 + // Each window [1,2,3], [2,3,4], [3,4,5] has mean 2,3,4 and sample std = 1.0 + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + + // When + let result: Result = sut.rolling_standard_deviation(3); + + // Then + let std: TimeSeries = result.unwrap(); + assert!(std.values[0].is_nan()); + assert!(std.values[1].is_nan()); + assert!((std.values[2] - 1.0).abs() < 1e-9); + assert!((std.values[3] - 1.0).abs() < 1e-9); + assert!((std.values[4] - 1.0).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_invalid_window__when_compute_rolling_std__then_returns_error() { + // Given + let sut: TimeSeries = TimeSeries::new(vec![1, 2], vec![1.0, 2.0]).unwrap(); + + // When + let result_too_large: Result = + sut.rolling_standard_deviation(5); + let result_window_1: Result = + sut.rolling_standard_deviation(1); + + // Then + assert!(matches!( + result_too_large, + Err(TemporalSeriesError::InvalidWindow { .. }) + )); + assert!(matches!( + result_window_1, + Err(TemporalSeriesError::InvalidWindow { .. }) + )); } diff --git a/tests/series/test_simple_return.rs b/tests/series/test_simple_return.rs index b49cf4a..b8ff18d 100644 --- a/tests/series/test_simple_return.rs +++ b/tests/series/test_simple_return.rs @@ -1,13 +1,64 @@ -use temporalseries::series::TimeSeries; +use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; #[test] #[allow(non_snake_case)] -fn test__given_valid_time_series_object__when_compute_simple_return__then_returns_it_correctly() { +fn test__given_constant_series__when_compute_simple_return__then_all_returns_are_zero() { // Given - let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); - let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![5.0, 5.0, 5.0]).unwrap(); // When + let result: Result = sut.simple_return(); // Then + let r: TimeSeries = result.unwrap(); + assert!(r.values[0].is_nan()); + assert!(r.values[1] == 0.0); + assert!(r.values[2] == 0.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_growing_series__when_compute_simple_return__then_computes_correctly() { + // Given + // [100, 110, 121] -> r_1 = (110-100)/100 = 0.1, r_2 = (121-110)/110 = 0.1 + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3], vec![100.0, 110.0, 121.0]).unwrap(); + + // When + let result: Result = sut.simple_return(); + + // Then + let r: TimeSeries = result.unwrap(); + assert!(r.values[0].is_nan()); + assert!((r.values[1] - 0.1).abs() < 1e-9); + assert!((r.values[2] - 0.1).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_halving_series__when_compute_simple_return__then_returns_minus_half() { + // Given + // [100, 50] -> r_1 = (50 - 100) / 100 = -0.5 + let sut: TimeSeries = TimeSeries::new(vec![1, 2], vec![100.0, 50.0]).unwrap(); + + // When + let result: Result = sut.simple_return(); + + // Then + let r: TimeSeries = result.unwrap(); + assert!(r.values[0].is_nan()); + assert!((r.values[1] - (-0.5)).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_empty_series__when_compute_simple_return__then_returns_empty_series_error() { + // Given + let sut: TimeSeries = TimeSeries::new(vec![], vec![]).unwrap(); + + // When + let result: Result = sut.simple_return(); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::EmptySeries))); } diff --git a/tests/series/test_skewness.rs b/tests/series/test_skewness.rs index e2eb237..83aa55f 100644 --- a/tests/series/test_skewness.rs +++ b/tests/series/test_skewness.rs @@ -1,13 +1,45 @@ -use temporalseries::series::TimeSeries; +use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; #[test] #[allow(non_snake_case)] -fn test__given_valid_time_series_object__when_compute_skewness__then_returns_it_correctly() { +fn test__given_symmetric_series__when_compute_skewness__then_returns_zero() { // Given - let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); - let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + // [1,2,3,4,5] is symmetric around 3; skewness = 0 + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); // When + let result: Result = sut.skewness(); // Then + assert!(result.unwrap().abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_right_skewed_series__when_compute_skewness__then_returns_positive() { + // Given + // [1,1,1,1,5]: heavy right tail -> positive skewness = 1.5 + // mean=1.8, m2=2.56, m3=6.144, skew=6.144/2.56^1.5=1.5 + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 1.0, 1.0, 1.0, 5.0]).unwrap(); + + // When + let result: Result = sut.skewness(); + + // Then + assert!((result.unwrap() - 1.5).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_too_short__when_compute_skewness__then_returns_error() { + // Given + let sut: TimeSeries = TimeSeries::new(vec![1, 2], vec![1.0, 2.0]).unwrap(); + + // When + let result: Result = sut.skewness(); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::EmptySeries))); } diff --git a/tests/series/test_true_range.rs b/tests/series/test_true_range.rs index c174891..cc2e35b 100644 --- a/tests/series/test_true_range.rs +++ b/tests/series/test_true_range.rs @@ -1,13 +1,67 @@ -use temporalseries::series::TimeSeries; +use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; #[test] #[allow(non_snake_case)] -fn test__given_valid_time_series_object__when_compute_true_range__then_returns_it_correctly() { +fn test__given_constant_series__when_compute_true_range__then_returns_zeros() { // Given - let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); - let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![5.0, 5.0, 5.0]).unwrap(); // When + let result: Result = sut.true_range(); // Then + let tr: TimeSeries = result.unwrap(); + assert!(tr.values[0].is_nan()); + assert!(tr.values[1] == 0.0); + assert!(tr.values[2] == 0.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_growing_series__when_compute_true_range__then_computes_correctly() { + // Given + // |3-1|=2, |6-3|=3, |10-6|=4 + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 3.0, 6.0, 10.0]).unwrap(); + + // When + let result: Result = sut.true_range(); + + // Then + let tr: TimeSeries = result.unwrap(); + assert!(tr.values[0].is_nan()); + assert!((tr.values[1] - 2.0).abs() < 1e-9); + assert!((tr.values[2] - 3.0).abs() < 1e-9); + assert!((tr.values[3] - 4.0).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_falling_series__when_compute_true_range__then_returns_absolute_values() { + // Given + // True range is always non-negative: |4-10|=6, |2-4|=2 + let sut: TimeSeries = + TimeSeries::new(vec![1, 2, 3], vec![10.0, 4.0, 2.0]).unwrap(); + + // When + let result: Result = sut.true_range(); + + // Then + let tr: TimeSeries = result.unwrap(); + assert!(tr.values[0].is_nan()); + assert!((tr.values[1] - 6.0).abs() < 1e-9); + assert!((tr.values[2] - 2.0).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_empty_series__when_compute_true_range__then_returns_empty_series_error() { + // Given + let sut: TimeSeries = TimeSeries::new(vec![], vec![]).unwrap(); + + // When + let result: Result = sut.true_range(); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::EmptySeries))); } From c30c36d1ea096d87494b8b025f465bd6d2f4ea1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 19 Jun 2026 16:17:25 +0200 Subject: [PATCH 08/19] format --- crates/series/time_series.rs | 35 ++++++++++++------- tests/series/test_average_true_range.rs | 9 ++--- tests/series/test_borillenger_bads.rs | 5 ++- tests/series/test_crossover_signal.rs | 8 +++-- tests/series/test_cumulative_return.rs | 3 +- tests/series/test_log_return.rs | 3 +- tests/series/test_moving_average.rs | 5 ++- .../series/test_rolling_standard_deviation.rs | 3 +- tests/series/test_simple_return.rs | 3 +- tests/series/test_true_range.rs | 6 ++-- 10 files changed, 44 insertions(+), 36 deletions(-) diff --git a/crates/series/time_series.rs b/crates/series/time_series.rs index 28205f3..4cc7e2f 100644 --- a/crates/series/time_series.rs +++ b/crates/series/time_series.rs @@ -407,7 +407,10 @@ impl TimeSeries { /// assert_eq!(ema.values[1], 1.5); // 0.5*2 + 0.5*1 /// assert_eq!(ema.values[2], 2.25); // 0.5*3 + 0.5*1.5 /// ``` - pub fn exponential_moving_average(&self, span: usize) -> Result { + pub fn exponential_moving_average( + &self, + span: usize, + ) -> Result { if self.is_empty() { return Err(TemporalSeriesError::EmptySeries); } @@ -445,7 +448,11 @@ impl TimeSeries { /// /// assert!(sig.values.iter().all(|&v| v == 0.0)); /// ``` - pub fn crossover_signal(&self, fast: usize, slow: usize) -> Result { + pub fn crossover_signal( + &self, + fast: usize, + slow: usize, + ) -> Result { if fast >= slow { return Err(TemporalSeriesError::ParameterRangeError(format!( "fast window ({fast}) must be smaller than slow window ({slow})" @@ -727,15 +734,15 @@ impl TimeSeries { // Levinson-Durbin: phi[j] holds the AR coefficients for the current order. let mut phi: Vec = vec![acf[0]]; for k in 1..lag { - let num: f64 = - acf[k] - (0..k).map(|j| phi[j] * acf[k - 1 - j]).sum::(); - let den: f64 = - 1.0 - (0..k).map(|j| phi[j] * acf[j]).sum::(); - let phi_kk: f64 = if den.abs() < f64::EPSILON { 0.0 } else { num / den }; + let num: f64 = acf[k] - (0..k).map(|j| phi[j] * acf[k - 1 - j]).sum::(); + let den: f64 = 1.0 - (0..k).map(|j| phi[j] * acf[j]).sum::(); + let phi_kk: f64 = if den.abs() < f64::EPSILON { + 0.0 + } else { + num / den + }; let prev: Vec = phi.clone(); - let updated: Vec = (0..k) - .map(|j| prev[j] - phi_kk * prev[k - 1 - j]) - .collect(); + let updated: Vec = (0..k).map(|j| prev[j] - phi_kk * prev[k - 1 - j]).collect(); phi = updated; phi.push(phi_kk); } @@ -759,7 +766,9 @@ impl TimeSeries { if n < 3 { return Err(TemporalSeriesError::EmptySeries); } - let delta: Vec = (1..n).map(|t| self.values[t] - self.values[t - 1]).collect(); + let delta: Vec = (1..n) + .map(|t| self.values[t] - self.values[t - 1]) + .collect(); let lagged: Vec = (0..n - 1).map(|t| self.values[t]).collect(); let ss_xy: f64 = lagged.iter().zip(delta.iter()).map(|(x, y)| x * y).sum(); @@ -815,7 +824,7 @@ impl TimeSeries { let critical_value: f64 = match alpha { a if a <= 0.01 => -2.60, a if a <= 0.05 => -1.95, - _ => -1.61, + _ => -1.61, }; let statistic: f64 = self.stationary_dickey_fuller_statistics()?; Ok(statistic < critical_value) @@ -945,7 +954,7 @@ impl TimeSeries { let critical_value: f64 = match alpha { a if a <= 0.01 => 9.210, a if a <= 0.05 => 5.991, - _ => 4.605, + _ => 4.605, }; let jb: f64 = self.jacque_bera_statistics()?; Ok(jb < critical_value) diff --git a/tests/series/test_average_true_range.rs b/tests/series/test_average_true_range.rs index 63b82e6..f99602a 100644 --- a/tests/series/test_average_true_range.rs +++ b/tests/series/test_average_true_range.rs @@ -4,8 +4,7 @@ use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; #[allow(non_snake_case)] fn test__given_constant_series__when_compute_average_true_range__then_returns_zeros() { // Given - let sut: TimeSeries = - TimeSeries::new(vec![1, 2, 3, 4], vec![5.0, 5.0, 5.0, 5.0]).unwrap(); + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3, 4], vec![5.0, 5.0, 5.0, 5.0]).unwrap(); // When let result: Result = sut.average_true_range(2); @@ -21,13 +20,11 @@ fn test__given_constant_series__when_compute_average_true_range__then_returns_ze #[test] #[allow(non_snake_case)] -fn test__given_growing_series_window_2__when_compute_average_true_range__then_computes_correctly() -{ +fn test__given_growing_series_window_2__when_compute_average_true_range__then_computes_correctly() { // Given // [1, 3, 6, 10] -> TR = [NaN, 2, 3, 4] // ATR(2): t=2: mean(2,3)=2.5, t=3: mean(3,4)=3.5 - let sut: TimeSeries = - TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 3.0, 6.0, 10.0]).unwrap(); + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 3.0, 6.0, 10.0]).unwrap(); // When let result: Result = sut.average_true_range(2); diff --git a/tests/series/test_borillenger_bads.rs b/tests/series/test_borillenger_bads.rs index 0d1b237..ba0885b 100644 --- a/tests/series/test_borillenger_bads.rs +++ b/tests/series/test_borillenger_bads.rs @@ -49,5 +49,8 @@ fn test__given_invalid_window__when_compute_bollinger_bands__then_returns_error( sut.bollinger_bands(5, 2.0); // Then - assert!(matches!(result, Err(TemporalSeriesError::InvalidWindow { .. }))); + assert!(matches!( + result, + Err(TemporalSeriesError::InvalidWindow { .. }) + )); } diff --git a/tests/series/test_crossover_signal.rs b/tests/series/test_crossover_signal.rs index 0544171..878249f 100644 --- a/tests/series/test_crossover_signal.rs +++ b/tests/series/test_crossover_signal.rs @@ -22,9 +22,11 @@ fn test__given_series_with_bullish_crossover__when_compute_crossover_signal__the // Given // Falling then rising: fast MA (2) crosses above slow MA (3) partway through. // [3,2,1,2,3,4,5]: fast crosses slow after the trough. - let sut: TimeSeries = - TimeSeries::new(vec![1, 2, 3, 4, 5, 6, 7], vec![3.0, 2.0, 1.0, 2.0, 3.0, 4.0, 5.0]) - .unwrap(); + let sut: TimeSeries = TimeSeries::new( + vec![1, 2, 3, 4, 5, 6, 7], + vec![3.0, 2.0, 1.0, 2.0, 3.0, 4.0, 5.0], + ) + .unwrap(); // When let result: Result = sut.crossover_signal(2, 3); diff --git a/tests/series/test_cumulative_return.rs b/tests/series/test_cumulative_return.rs index b58c743..2f43be4 100644 --- a/tests/series/test_cumulative_return.rs +++ b/tests/series/test_cumulative_return.rs @@ -18,8 +18,7 @@ fn test__given_constant_series__when_compute_cumulative_return__then_returns_zer fn test__given_growing_series__when_compute_cumulative_return__then_computes_correctly() { // Given // (121 - 100) / 100 = 0.21 - let sut: TimeSeries = - TimeSeries::new(vec![1, 2, 3], vec![100.0, 110.0, 121.0]).unwrap(); + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![100.0, 110.0, 121.0]).unwrap(); // When let result: Result = sut.cumulative_return(); diff --git a/tests/series/test_log_return.rs b/tests/series/test_log_return.rs index 5e57329..aa2c08d 100644 --- a/tests/series/test_log_return.rs +++ b/tests/series/test_log_return.rs @@ -21,8 +21,7 @@ fn test__given_constant_series__when_compute_log_return__then_all_returns_are_ze fn test__given_series_with_e_ratio__when_compute_log_return__then_returns_one() { // Given // ln(e / 1) = 1.0 - let sut: TimeSeries = - TimeSeries::new(vec![1, 2], vec![1.0, std::f64::consts::E]).unwrap(); + let sut: TimeSeries = TimeSeries::new(vec![1, 2], vec![1.0, std::f64::consts::E]).unwrap(); // When let result: Result = sut.log_return(); diff --git a/tests/series/test_moving_average.rs b/tests/series/test_moving_average.rs index d2e709e..7071e67 100644 --- a/tests/series/test_moving_average.rs +++ b/tests/series/test_moving_average.rs @@ -49,5 +49,8 @@ fn test__given_window_larger_than_series__when_compute_moving_average__then_retu let result: Result = sut.moving_average(5); // Then - assert!(matches!(result, Err(TemporalSeriesError::InvalidWindow { .. }))); + assert!(matches!( + result, + Err(TemporalSeriesError::InvalidWindow { .. }) + )); } diff --git a/tests/series/test_rolling_standard_deviation.rs b/tests/series/test_rolling_standard_deviation.rs index ae683dc..a85fdfd 100644 --- a/tests/series/test_rolling_standard_deviation.rs +++ b/tests/series/test_rolling_standard_deviation.rs @@ -4,8 +4,7 @@ use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; #[allow(non_snake_case)] fn test__given_constant_series__when_compute_rolling_std__then_returns_zeros() { // Given - let sut: TimeSeries = - TimeSeries::new(vec![1, 2, 3, 4], vec![5.0, 5.0, 5.0, 5.0]).unwrap(); + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3, 4], vec![5.0, 5.0, 5.0, 5.0]).unwrap(); // When let result: Result = sut.rolling_standard_deviation(2); diff --git a/tests/series/test_simple_return.rs b/tests/series/test_simple_return.rs index b8ff18d..2649b54 100644 --- a/tests/series/test_simple_return.rs +++ b/tests/series/test_simple_return.rs @@ -21,8 +21,7 @@ fn test__given_constant_series__when_compute_simple_return__then_all_returns_are fn test__given_growing_series__when_compute_simple_return__then_computes_correctly() { // Given // [100, 110, 121] -> r_1 = (110-100)/100 = 0.1, r_2 = (121-110)/110 = 0.1 - let sut: TimeSeries = - TimeSeries::new(vec![1, 2, 3], vec![100.0, 110.0, 121.0]).unwrap(); + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![100.0, 110.0, 121.0]).unwrap(); // When let result: Result = sut.simple_return(); diff --git a/tests/series/test_true_range.rs b/tests/series/test_true_range.rs index cc2e35b..bcb4a31 100644 --- a/tests/series/test_true_range.rs +++ b/tests/series/test_true_range.rs @@ -21,8 +21,7 @@ fn test__given_constant_series__when_compute_true_range__then_returns_zeros() { fn test__given_growing_series__when_compute_true_range__then_computes_correctly() { // Given // |3-1|=2, |6-3|=3, |10-6|=4 - let sut: TimeSeries = - TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 3.0, 6.0, 10.0]).unwrap(); + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 3.0, 6.0, 10.0]).unwrap(); // When let result: Result = sut.true_range(); @@ -40,8 +39,7 @@ fn test__given_growing_series__when_compute_true_range__then_computes_correctly( fn test__given_falling_series__when_compute_true_range__then_returns_absolute_values() { // Given // True range is always non-negative: |4-10|=6, |2-4|=2 - let sut: TimeSeries = - TimeSeries::new(vec![1, 2, 3], vec![10.0, 4.0, 2.0]).unwrap(); + let sut: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![10.0, 4.0, 2.0]).unwrap(); // When let result: Result = sut.true_range(); From 440c71fe7207ea8bfc870ed65cfad65499102442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 19 Jun 2026 16:30:45 +0200 Subject: [PATCH 09/19] add examples of statistical tests --- README.md | 78 ++++++++++++++++++++++++++++++++++++ crates/series/time_series.rs | 58 ++++++++++++++++++++++++--- 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1a4a52a..94f60be 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,84 @@ println!("{}", dts[1]); // 1970-01-01 00:00:01 UTC `TimeUnit`, `with_unit`, and `time_unit()` are always available. Only `from_datetimes` and `datetimes` require `--features chrono`. +## Statistical Tests + +### Augmented Dickey-Fuller (stationarity) + +`stationary_dickey_fuller_test(alpha)` tests whether a series is stationary — i.e., its statistical properties do not change over time. Stationarity is a prerequisite for many forecasting models. + +**How it works:** the test fits an OLS regression of the form + +``` +Δxₜ = γ · xₜ₋₁ + εₜ +``` + +and computes the t-statistic `γ̂ / SE(γ̂)`. Under the null hypothesis (unit root, non-stationary), this statistic follows a non-standard Dickey-Fuller distribution. The null is rejected — and stationarity is concluded — when the statistic falls below the critical value for the chosen significance level. + +| `alpha` | Critical value | +|---------|---------------| +| `0.01` | −2.60 | +| `0.05` | −1.95 | +| `0.10` | −1.61 | + +Returns `true` when the series is stationary (null rejected), `false` otherwise. + +```rust +use temporalseries::series::TimeSeries; + +// A trending series is NOT stationary +let trend = TimeSeries::new( + vec![1, 2, 3, 4, 5, 6, 7, 8], + vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], +).unwrap(); +assert!(!trend.stationary_dickey_fuller_test(0.05).unwrap()); + +// An alternating series IS stationary +let alternating = TimeSeries::new( + vec![1, 2, 3, 4, 5, 6, 7, 8], + vec![1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0], +).unwrap(); +assert!(alternating.stationary_dickey_fuller_test(0.05).unwrap()); +``` + +### Jarque-Bera (normality) + +`jacque_bera_test(alpha)` tests whether a series follows a normal distribution, using its skewness and excess kurtosis. It is commonly applied to residuals from regression or time-series models. + +**How it works:** the Jarque-Bera statistic is + +``` +JB = n · (S² / 6 + K² / 24) +``` + +where `S` is the Fisher-Pearson skewness and `K` is the excess kurtosis. Under the null hypothesis of normality, `JB` follows a χ²(2) distribution. + +| `alpha` | χ²(2) critical value | +|---------|--------------------| +| `0.01` | 9.210 | +| `0.05` | 5.991 | +| `0.10` | 4.605 | + +Returns `true` when the series is consistent with normality (null not rejected), `false` when normality is rejected. + +```rust +use temporalseries::series::TimeSeries; + +// A near-symmetric series is consistent with normality +let normal_like = TimeSeries::new( + vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + vec![-2.0, -1.0, -0.5, 0.0, 0.2, 0.3, 0.5, 1.0, 1.5, 2.0], +).unwrap(); +assert!(normal_like.jacque_bera_test(0.05).unwrap()); + +// A heavily skewed series is NOT consistent with normality +let skewed = TimeSeries::new( + vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + vec![1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 100.0], +).unwrap(); +assert!(!skewed.jacque_bera_test(0.05).unwrap()); +``` + ## NaN convention Operations that cannot produce a value for a position (e.g. the first element diff --git a/crates/series/time_series.rs b/crates/series/time_series.rs index 4cc7e2f..75971cd 100644 --- a/crates/series/time_series.rs +++ b/crates/series/time_series.rs @@ -810,15 +810,36 @@ impl TimeSeries { /// /// # Examples /// + /// A purely trending series is **non-stationary** — the test returns `false` because + /// it cannot reject the unit-root null hypothesis: + /// /// ```rust /// use temporalseries::series::TimeSeries; /// - /// // Pure trend — non-stationary; H0 should not be rejected. - /// let rw = TimeSeries::new( + /// let trend = TimeSeries::new( /// vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10], /// vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0], /// ).unwrap(); - /// assert!(!rw.stationary_dickey_fuller_test(0.05).unwrap()); + /// + /// // DF statistic >> -1.95 => cannot reject unit root => non-stationary + /// let is_stationary = trend.stationary_dickey_fuller_test(0.05).unwrap(); + /// assert!(!is_stationary); + /// ``` + /// + /// A strongly mean-reverting series is **stationary** — the DF statistic is + /// deeply negative and the test returns `true`: + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let mean_reverting = TimeSeries::new( + /// vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + /// vec![1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0], + /// ).unwrap(); + /// + /// // DF statistic -> -inf => unit root rejected => stationary + /// let is_stationary = mean_reverting.stationary_dickey_fuller_test(0.05).unwrap(); + /// assert!(is_stationary); /// ``` pub fn stationary_dickey_fuller_test(&self, alpha: f32) -> Result { let critical_value: f64 = match alpha { @@ -943,12 +964,37 @@ impl TimeSeries { /// /// # Examples /// + /// A symmetric, uniform-ish series has near-zero skewness and moderate kurtosis — + /// the JB statistic stays below the critical value, so the test returns `true` + /// (consistent with normality): + /// /// ```rust /// use temporalseries::series::TimeSeries; /// - /// // [1,2,3,4,5] produces a small JB statistic — consistent with normality at 5%. - /// let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); - /// assert!(ts.jacque_bera_test(0.05).unwrap()); + /// let symmetric = TimeSeries::new( + /// vec![1, 2, 3, 4, 5], + /// vec![1.0, 2.0, 3.0, 4.0, 5.0], + /// ).unwrap(); + /// + /// // JB ≈ 0.35 < 5.991 => fail to reject normality + /// let is_normal = symmetric.jacque_bera_test(0.05).unwrap(); + /// assert!(is_normal); + /// ``` + /// + /// A heavily right-skewed series has a large JB statistic that exceeds the + /// critical value — the test returns `false` (normality rejected): + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let skewed = TimeSeries::new( + /// vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + /// vec![1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 20.0], + /// ).unwrap(); + /// + /// // JB ≈ 22.7 > 5.991 => reject normality + /// let is_normal = skewed.jacque_bera_test(0.05).unwrap(); + /// assert!(!is_normal); /// ``` pub fn jacque_bera_test(&self, alpha: f32) -> Result { let critical_value: f64 = match alpha { From a217b59f81a92ebaa0f1d89845b3f33d944742e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 19 Jun 2026 16:33:36 +0200 Subject: [PATCH 10/19] add new examples and document them - dickey fuller test - jarque bara test --- README.md | 2 ++ examples/dickey_fuller_test.rs | 61 ++++++++++++++++++++++++++++++++++ examples/jarque_bera_test.rs | 58 ++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 examples/dickey_fuller_test.rs create mode 100644 examples/jarque_bera_test.rs diff --git a/README.md b/README.md index 94f60be..e9c6677 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,8 @@ output length equal to the input length and preserves index alignment. | `temporal_series_with_row_backend` | `cargo run --example temporal_series_with_row_backend` | Builds a `TemporalSeries` with a `RowBackend` and demonstrates access and iteration | | `panel` | `cargo run --example panel` | Builds a `Panel` of named series on a shared index and extracts one series for analysis | | `temporal_series_with_chrono` | `cargo run --example temporal_series_with_chrono --features chrono` | Builds a `TemporalSeries` from `DateTime` values and round-trips the index back to calendar dates | +| `dickey_fuller_test` | `cargo run --example dickey_fuller_test` | Contrasts a trending (non-stationary) series and an alternating (stationary) series using the Dickey-Fuller test | +| `jarque_bera_test` | `cargo run --example jarque_bera_test` | Contrasts a near-symmetric series and a heavily skewed series using the Jarque-Bera normality test | ## Development diff --git a/examples/dickey_fuller_test.rs b/examples/dickey_fuller_test.rs new file mode 100644 index 0000000..0ac9a9f --- /dev/null +++ b/examples/dickey_fuller_test.rs @@ -0,0 +1,61 @@ +//! Demonstrates the Augmented Dickey-Fuller stationarity test. +//! +//! A time series is *stationary* when its statistical properties (mean, +//! variance, autocorrelation) do not change over time. Many forecasting +//! and econometric models require stationarity as a precondition. +//! +//! [`TimeSeries::stationary_dickey_fuller_test`] fits an OLS regression of +//! the form +//! +//! ```text +//! Δxₜ = γ · xₜ₋₁ + εₜ +//! ``` +//! +//! and computes the t-statistic `γ̂ / SE(γ̂)`. The null hypothesis is the +//! presence of a unit root (non-stationary). The null is rejected — and +//! stationarity is concluded — when the statistic falls below the critical +//! value for the chosen significance level `alpha`. +//! +//! This example contrasts two series: +//! +//! - A **linear trend** (always growing) — the test should return `false` +//! because the mean is not constant. +//! - An **alternating** series (mean-reverting) — the test should return `true` +//! because the series strongly reverts to its mean. +//! +//! # Expected output +//! +//! ```text +//! Trending series → stationary: false +//! Alternating series → stationary: true +//! ``` +//! +//! # Run +//! +//! ```bash +//! cargo run --example dickey_fuller_test +//! ``` + +use temporalseries::series::TimeSeries; + +fn main() { + // A linearly increasing series has a unit root — it is NOT stationary. + let trend = TimeSeries::new( + vec![1, 2, 3, 4, 5, 6, 7, 8], + vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], + ) + .unwrap(); + + let trend_stationary = trend.stationary_dickey_fuller_test(0.05).unwrap(); + println!("Trending series → stationary: {trend_stationary}"); + + // An alternating series mean-reverts strongly — it IS stationary. + let alternating = TimeSeries::new( + vec![1, 2, 3, 4, 5, 6, 7, 8], + vec![1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0], + ) + .unwrap(); + + let alt_stationary = alternating.stationary_dickey_fuller_test(0.05).unwrap(); + println!("Alternating series → stationary: {alt_stationary}"); +} diff --git a/examples/jarque_bera_test.rs b/examples/jarque_bera_test.rs new file mode 100644 index 0000000..c8e3088 --- /dev/null +++ b/examples/jarque_bera_test.rs @@ -0,0 +1,58 @@ +//! Demonstrates the Jarque-Bera normality test. +//! +//! The Jarque-Bera test checks whether a series is consistent with a normal +//! distribution by measuring its skewness (`S`) and excess kurtosis (`K`). +//! It is commonly applied to residuals from regression or ARIMA models. +//! +//! [`TimeSeries::jacque_bera_test`] computes the statistic +//! +//! ```text +//! JB = n · (S² / 6 + K² / 24) +//! ``` +//! +//! and compares it against the χ²(2) critical value for the chosen significance +//! level `alpha`. The null hypothesis is normality. +//! +//! This example contrasts two series: +//! +//! - A **near-symmetric** series — the test should return `true` because the +//! series does not deviate significantly from a normal distribution. +//! - A **heavily skewed** series with a single large outlier — the test should +//! return `false` because normality is clearly rejected. +//! +//! # Expected output +//! +//! ```text +//! Near-symmetric series → consistent with normality: true +//! Heavily skewed series → consistent with normality: false +//! ``` +//! +//! # Run +//! +//! ```bash +//! cargo run --example jarque_bera_test +//! ``` + +use temporalseries::series::TimeSeries; + +fn main() { + // A roughly symmetric, spread-out series does not violate normality. + let normal_like = TimeSeries::new( + vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + vec![-2.0, -1.0, -0.5, 0.0, 0.2, 0.3, 0.5, 1.0, 1.5, 2.0], + ) + .unwrap(); + + let nl_normal = normal_like.jacque_bera_test(0.05).unwrap(); + println!("Near-symmetric series → consistent with normality: {nl_normal}"); + + // Nine identical values plus a massive outlier produces extreme skewness. + let skewed = TimeSeries::new( + vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + vec![1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 100.0], + ) + .unwrap(); + + let sk_normal = skewed.jacque_bera_test(0.05).unwrap(); + println!("Heavily skewed series → consistent with normality: {sk_normal}"); +} From 4da42449472200c5f33409cd4c670a8a275b9897 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 19 Jun 2026 16:37:24 +0200 Subject: [PATCH 11/19] remove all trazes of boilerplate for 'all_quantiles' method - We do not need it --- crates/series/time_series.rs | 7 ------- tests/series/mod.rs | 1 - tests/series/test_all_quantiles.rs | 13 ------------- 3 files changed, 21 deletions(-) delete mode 100644 tests/series/test_all_quantiles.rs diff --git a/crates/series/time_series.rs b/crates/series/time_series.rs index 75971cd..b67d7ad 100644 --- a/crates/series/time_series.rs +++ b/crates/series/time_series.rs @@ -216,13 +216,6 @@ impl TimeSeries { Ok(sorted[lo] + frac * (sorted[hi] - sorted[lo])) } - /// TODO: create an interface for this object or similar -> change rust's approach to thsi problem - /// This will only call quantile function a bunch of times... - #[allow(dead_code)] - pub fn all_quantiles(&self) -> Vec { - vec![0.0] - } - /// Returns the Interquartile Range (IQR) of the series. /// /// IQR = Q3 − Q1 = `quantile(0.75)` − `quantile(0.25)`. diff --git a/tests/series/mod.rs b/tests/series/mod.rs index 71c509f..e5b180d 100644 --- a/tests/series/mod.rs +++ b/tests/series/mod.rs @@ -1,4 +1,3 @@ -mod test_all_quantiles; mod test_autocorrelation_function; mod test_average_true_range; mod test_borillenger_bads; diff --git a/tests/series/test_all_quantiles.rs b/tests/series/test_all_quantiles.rs deleted file mode 100644 index 842024c..0000000 --- a/tests/series/test_all_quantiles.rs +++ /dev/null @@ -1,13 +0,0 @@ -use temporalseries::series::TimeSeries; - -#[test] -#[allow(non_snake_case)] -fn test__given_valid_time_series_object__when_compute_all_quantiles__then_returns_it_correctly() { - // Given - let sut_1: TimeSeries = TimeSeries::new(vec![1], vec![0.0]).unwrap(); - let sut_2: TimeSeries = TimeSeries::new(vec![1, 2, 3], vec![0.0, 0.0, 0.0]).unwrap(); - - // When - - // Then -} From 1235ae25c8ea5dade8d98bccf6933bc434cb8040 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 19 Jun 2026 16:40:21 +0200 Subject: [PATCH 12/19] clippy --- crates/series/time_series.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/series/time_series.rs b/crates/series/time_series.rs index b67d7ad..8af86ad 100644 --- a/crates/series/time_series.rs +++ b/crates/series/time_series.rs @@ -127,7 +127,7 @@ impl TimeSeries { let estimated_mean: f64 = self.mean(); let len_casted: f64 = self.len() as f64; if len_casted == 1.0 { - return 0.0; + 0.0 } else { let bessels_correction_factor: f64 = 1.0 / (len_casted - 1.0); let mut summatory: f64 = 0.0; @@ -135,7 +135,7 @@ impl TimeSeries { for element in &self.values { summatory += (element - estimated_mean).powi(2); } - return bessels_correction_factor * summatory; + bessels_correction_factor * summatory } } @@ -190,7 +190,7 @@ impl TimeSeries { /// assert!(matches!(ts.quantile(1.1), Err(TemporalSeriesError::ParameterRangeError(_)))); /// ``` pub fn quantile(&self, p: f32) -> Result { - if p < 0.0 || p > 1.0 { + if !(0.0..=1.0).contains(&p) { return Err(TemporalSeriesError::ParameterRangeError(format!( "p must be in [0.0, 1.0], got {p}" ))); @@ -454,16 +454,16 @@ impl TimeSeries { let fast_ma: TimeSeries = self.moving_average(fast)?; let slow_ma: TimeSeries = self.moving_average(slow)?; let mut signals: Vec = vec![0.0; self.len()]; - for i in 1..self.len() { + for (i, signal) in signals.iter_mut().enumerate().skip(1) { let prev: f64 = fast_ma.values[i - 1] - slow_ma.values[i - 1]; let curr: f64 = fast_ma.values[i] - slow_ma.values[i]; if prev.is_nan() || curr.is_nan() { continue; } if prev <= 0.0 && curr > 0.0 { - signals[i] = 1.0; + *signal = 1.0; } else if prev >= 0.0 && curr < 0.0 { - signals[i] = -1.0; + *signal = -1.0; } } Self::new(self.index.clone(), signals) @@ -506,12 +506,12 @@ impl TimeSeries { } let len: usize = self.len(); let mut result: Vec = vec![f64::NAN; len]; - for i in (n - 1)..len { + for (i, val) in result.iter_mut().enumerate().skip(n - 1) { let window: &[f64] = &self.values[i + 1 - n..=i]; let mean: f64 = window.iter().sum::() / n as f64; let variance: f64 = window.iter().map(|x| (x - mean).powi(2)).sum::() / (n - 1) as f64; - result[i] = variance.sqrt(); + *val = variance.sqrt(); } Self::new(self.index.clone(), result) } From ed0abe55c199e372e307811192f64f8b3d0e43f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 19 Jun 2026 16:53:25 +0200 Subject: [PATCH 13/19] implement methos of time series into temporal series - add unittests - refactor way tests are organized for series crate --- crates/series/temporal_series.rs | 543 ++++++++++++++++++ tests/series/mod.rs | 26 +- tests/series/temporal_series/mod.rs | 20 + .../test_autocorrelation_function.rs | 56 ++ .../test_average_true_range.rs | 45 ++ .../temporal_series/test_bollinger_bands.rs | 66 +++ .../temporal_series/test_crossover_signal.rs | 62 ++ .../temporal_series/test_cumulative_return.rs | 50 ++ .../test_dickey_fuller_test.rs | 52 ++ .../temporal_series/test_excess_kurtosis.rs | 37 ++ .../test_exponential_moving_average.rs | 53 ++ tests/series/temporal_series/test_iqr.rs | 53 ++ .../temporal_series/test_jacque_bera_test.rs | 53 ++ .../series/temporal_series/test_log_return.rs | 55 ++ tests/series/temporal_series/test_mean.rs | 35 ++ .../temporal_series/test_moving_average.rs | 67 +++ .../test_partial_autocorrelation_function.rs | 57 ++ tests/series/temporal_series/test_quantile.rs | 63 ++ .../test_rolling_standard_deviation.rs | 70 +++ .../temporal_series/test_simple_return.rs | 56 ++ tests/series/temporal_series/test_skewness.rs | 52 ++ .../temporal_series/test_std_deviation.rs | 46 ++ .../series/temporal_series/test_true_range.rs | 57 ++ tests/series/time_series/mod.rs | 24 + .../test_autocorrelation_function.rs | 0 .../test_average_true_range.rs | 0 .../test_borillenger_bads.rs | 0 .../test_crossover_signal.rs | 0 .../test_cumulative_return.rs | 0 .../test_dickey_fuller_test.rs | 0 .../{ => time_series}/test_excess_kurtosis.rs | 0 .../test_exponential_moving_average.rs | 0 tests/series/{ => time_series}/test_iqr.rs | 0 .../series/{ => time_series}/test_is_empty.rs | 0 .../test_jacque_bera_test.rs | 0 tests/series/{ => time_series}/test_len.rs | 0 .../{ => time_series}/test_log_return.rs | 0 tests/series/{ => time_series}/test_mean.rs | 0 .../{ => time_series}/test_moving_average.rs | 0 .../{ => time_series}/test_new_instance.rs | 0 .../test_partial_autocorrelation_function.rs | 0 .../{ => time_series}/test_pct_change.rs | 0 .../series/{ => time_series}/test_quantile.rs | 0 .../test_rolling_standard_deviation.rs | 0 .../{ => time_series}/test_simple_return.rs | 0 .../series/{ => time_series}/test_skewness.rs | 0 .../{ => time_series}/test_std_deviation.rs | 0 .../{ => time_series}/test_true_range.rs | 0 48 files changed, 1674 insertions(+), 24 deletions(-) create mode 100644 tests/series/temporal_series/mod.rs create mode 100644 tests/series/temporal_series/test_autocorrelation_function.rs create mode 100644 tests/series/temporal_series/test_average_true_range.rs create mode 100644 tests/series/temporal_series/test_bollinger_bands.rs create mode 100644 tests/series/temporal_series/test_crossover_signal.rs create mode 100644 tests/series/temporal_series/test_cumulative_return.rs create mode 100644 tests/series/temporal_series/test_dickey_fuller_test.rs create mode 100644 tests/series/temporal_series/test_excess_kurtosis.rs create mode 100644 tests/series/temporal_series/test_exponential_moving_average.rs create mode 100644 tests/series/temporal_series/test_iqr.rs create mode 100644 tests/series/temporal_series/test_jacque_bera_test.rs create mode 100644 tests/series/temporal_series/test_log_return.rs create mode 100644 tests/series/temporal_series/test_mean.rs create mode 100644 tests/series/temporal_series/test_moving_average.rs create mode 100644 tests/series/temporal_series/test_partial_autocorrelation_function.rs create mode 100644 tests/series/temporal_series/test_quantile.rs create mode 100644 tests/series/temporal_series/test_rolling_standard_deviation.rs create mode 100644 tests/series/temporal_series/test_simple_return.rs create mode 100644 tests/series/temporal_series/test_skewness.rs create mode 100644 tests/series/temporal_series/test_std_deviation.rs create mode 100644 tests/series/temporal_series/test_true_range.rs create mode 100644 tests/series/time_series/mod.rs rename tests/series/{ => time_series}/test_autocorrelation_function.rs (100%) rename tests/series/{ => time_series}/test_average_true_range.rs (100%) rename tests/series/{ => time_series}/test_borillenger_bads.rs (100%) rename tests/series/{ => time_series}/test_crossover_signal.rs (100%) rename tests/series/{ => time_series}/test_cumulative_return.rs (100%) rename tests/series/{ => time_series}/test_dickey_fuller_test.rs (100%) rename tests/series/{ => time_series}/test_excess_kurtosis.rs (100%) rename tests/series/{ => time_series}/test_exponential_moving_average.rs (100%) rename tests/series/{ => time_series}/test_iqr.rs (100%) rename tests/series/{ => time_series}/test_is_empty.rs (100%) rename tests/series/{ => time_series}/test_jacque_bera_test.rs (100%) rename tests/series/{ => time_series}/test_len.rs (100%) rename tests/series/{ => time_series}/test_log_return.rs (100%) rename tests/series/{ => time_series}/test_mean.rs (100%) rename tests/series/{ => time_series}/test_moving_average.rs (100%) rename tests/series/{ => time_series}/test_new_instance.rs (100%) rename tests/series/{ => time_series}/test_partial_autocorrelation_function.rs (100%) rename tests/series/{ => time_series}/test_pct_change.rs (100%) rename tests/series/{ => time_series}/test_quantile.rs (100%) rename tests/series/{ => time_series}/test_rolling_standard_deviation.rs (100%) rename tests/series/{ => time_series}/test_simple_return.rs (100%) rename tests/series/{ => time_series}/test_skewness.rs (100%) rename tests/series/{ => time_series}/test_std_deviation.rs (100%) rename tests/series/{ => time_series}/test_true_range.rs (100%) diff --git a/crates/series/temporal_series.rs b/crates/series/temporal_series.rs index 4513547..7bb844c 100644 --- a/crates/series/temporal_series.rs +++ b/crates/series/temporal_series.rs @@ -300,3 +300,546 @@ impl> TemporalSeries { self.index.iter().map(|&ts| unit.to_datetime(ts)).collect() } } + +// --------------------------------------------------------------------------- +// Analytical methods for f64-valued series +// --------------------------------------------------------------------------- + +/// Concrete output type produced by series-returning analytical methods. +/// +/// All methods that return a new series use [`crate::storage::ColumnarBackend`] +/// regardless of the input backend, since there is no generic way to construct +/// an arbitrary `B` from a `Vec`. +pub type ColSeries = TemporalSeries>; + +impl> TemporalSeries { + fn values_vec(&self) -> Vec { + self.iter().copied().collect() + } + + fn series_from(index: Vec, values: Vec) -> Result { + TemporalSeries::new(index, crate::storage::ColumnarBackend::new(values)) + } + + // STATISTICS ------------------------------------------------------------- + + /// Returns the arithmetic mean of the series. + /// + /// $$\hat{\mu} = \frac{1}{n} \sum_{i=1}^{n} x_i$$ + pub fn mean(&self) -> f64 { + let total: f64 = self.iter().sum(); + total / self.len() as f64 + } + + /// Returns the sample variance (Bessel-corrected, denominator `n − 1`). + /// + /// Named `std_deviation` to mirror [`crate::series::TimeSeries::std_deviation`], + /// which computes the same quantity. + pub fn std_deviation(&self) -> f64 { + let mean = self.mean(); + let n = self.len() as f64; + if n <= 1.0 { + return 0.0; + } + let sum_sq: f64 = self.iter().map(|x| (x - mean).powi(2)).sum(); + sum_sq / (n - 1.0) + } + + /// Returns the p-th quantile using linear interpolation (numpy `method='linear'`). + /// + /// # Errors + /// + /// - [`TemporalSeriesError::ParameterRangeError`] if `p` is outside `[0.0, 1.0]`. + /// - [`TemporalSeriesError::EmptySeries`] if the series has no non-NaN values. + pub fn quantile(&self, p: f32) -> Result { + if !(0.0..=1.0).contains(&p) { + return Err(TemporalSeriesError::ParameterRangeError(format!( + "p must be in [0.0, 1.0], got {p}" + ))); + } + let mut sorted: Vec = self.iter().copied().filter(|v| !v.is_nan()).collect(); + if sorted.is_empty() { + return Err(TemporalSeriesError::EmptySeries); + } + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let n = sorted.len(); + let h = p as f64 * (n - 1) as f64; + let lo = h.floor() as usize; + let hi = h.ceil() as usize; + let frac = h - lo as f64; + Ok(sorted[lo] + frac * (sorted[hi] - sorted[lo])) + } + + /// Returns the Interquartile Range: Q3 − Q1. + /// + /// # Errors + /// + /// - [`TemporalSeriesError::EmptySeries`] if the series has no non-NaN values. + pub fn iqr(&self) -> Result { + Ok(self.quantile(0.75)? - self.quantile(0.25)?) + } + + // RETURNS ---------------------------------------------------------------- + + /// Returns the per-period simple return series. + /// + /// $$r_t = \frac{x_t - x_{t-1}}{x_{t-1}}$$ + /// + /// The first element is `NaN`. + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::EmptySeries`] if the series is empty. + pub fn simple_return(&self) -> Result { + if self.is_empty() { + return Err(TemporalSeriesError::EmptySeries); + } + let vals = self.values_vec(); + let mut out = vec![f64::NAN; vals.len()]; + for (r, w) in out[1..].iter_mut().zip(vals.windows(2)) { + *r = (w[1] - w[0]) / w[0]; + } + Self::series_from(self.index.clone(), out) + } + + /// Returns the per-period logarithmic return series. + /// + /// $$r_t^{log} = \ln\!\left(\frac{x_t}{x_{t-1}}\right)$$ + /// + /// The first element is `NaN`. + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::EmptySeries`] if the series is empty. + pub fn log_return(&self) -> Result { + if self.is_empty() { + return Err(TemporalSeriesError::EmptySeries); + } + let vals = self.values_vec(); + let mut out = vec![f64::NAN; vals.len()]; + for (r, w) in out[1..].iter_mut().zip(vals.windows(2)) { + *r = (w[1] / w[0]).ln(); + } + Self::series_from(self.index.clone(), out) + } + + /// Returns the total cumulative return from the first to the last observation. + /// + /// $$R_{cum} = \frac{x_T - x_0}{x_0}$$ + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::EmptySeries`] if the series is empty. + pub fn cumulative_return(&self) -> Result { + if self.is_empty() { + return Err(TemporalSeriesError::EmptySeries); + } + let x0 = *self.get(0).unwrap(); + let xt = *self.get(self.len() - 1).unwrap(); + Ok((xt - x0) / x0) + } + + // MOVING AVERAGES -------------------------------------------------------- + + /// Returns the n-period simple moving average series. + /// + /// $$MA_t^{(n)} = \frac{1}{n} \sum_{i=0}^{n-1} x_{t-i}$$ + /// + /// The first `n − 1` elements are `NaN`. + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::InvalidWindow`] if `n` exceeds the series length. + pub fn moving_average(&self, n: usize) -> Result { + if n > self.len() { + return Err(TemporalSeriesError::InvalidWindow { + window: n, + series_len: self.len(), + }); + } + let vals = self.values_vec(); + let len = vals.len(); + let mut out = vec![f64::NAN; len]; + for (i, v) in out.iter_mut().enumerate().skip(n - 1) { + let window = &vals[i + 1 - n..=i]; + *v = window.iter().sum::() / n as f64; + } + Self::series_from(self.index.clone(), out) + } + + /// Returns the exponential moving average (EMA) series for a given span. + /// + /// $$\alpha = \frac{2}{span + 1}, \qquad EMA_t = \alpha \cdot x_t + (1 - \alpha) \cdot EMA_{t-1}$$ + /// + /// Seeded with $EMA_0 = x_0$. + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::EmptySeries`] if the series is empty. + pub fn exponential_moving_average( + &self, + span: usize, + ) -> Result { + if self.is_empty() { + return Err(TemporalSeriesError::EmptySeries); + } + let vals = self.values_vec(); + let alpha = 2.0 / (span as f64 + 1.0); + let mut out = vec![0.0f64; vals.len()]; + out[0] = vals[0]; + for i in 1..vals.len() { + out[i] = alpha * vals[i] + (1.0 - alpha) * out[i - 1]; + } + Self::series_from(self.index.clone(), out) + } + + /// Returns the MA crossover signal series. + /// + /// - `+1.0` — fast MA crosses **above** slow MA (bullish) + /// - `-1.0` — fast MA crosses **below** slow MA (bearish) + /// - `0.0` — no crossover + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::ParameterRangeError`] if `fast >= slow`, or + /// [`TemporalSeriesError::InvalidWindow`] if either window exceeds the series length. + pub fn crossover_signal( + &self, + fast: usize, + slow: usize, + ) -> Result { + if fast >= slow { + return Err(TemporalSeriesError::ParameterRangeError(format!( + "fast window ({fast}) must be smaller than slow window ({slow})" + ))); + } + let fast_vals: Vec = self.moving_average(fast)?.iter().copied().collect(); + let slow_vals: Vec = self.moving_average(slow)?.iter().copied().collect(); + let n = self.len(); + let mut signals = vec![0.0f64; n]; + for (i, sig) in signals.iter_mut().enumerate().skip(1) { + let prev = fast_vals[i - 1] - slow_vals[i - 1]; + let curr = fast_vals[i] - slow_vals[i]; + if prev.is_nan() || curr.is_nan() { + continue; + } + if prev <= 0.0 && curr > 0.0 { + *sig = 1.0; + } else if prev >= 0.0 && curr < 0.0 { + *sig = -1.0; + } + } + Self::series_from(self.index.clone(), signals) + } + + // VOLATILITY ------------------------------------------------------------- + + /// Returns the n-period rolling standard deviation series (Bessel-corrected). + /// + /// $$\sigma_t^{(n)} = \sqrt{\frac{1}{n-1} \sum_{i=0}^{n-1} \left(x_{t-i} - \bar{x}_t^{(n)}\right)^2}$$ + /// + /// The first `n − 1` elements are `NaN`. + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::InvalidWindow`] if `n > len` or `n < 2`. + pub fn rolling_standard_deviation(&self, n: usize) -> Result { + if n < 2 || n > self.len() { + return Err(TemporalSeriesError::InvalidWindow { + window: n, + series_len: self.len(), + }); + } + let vals = self.values_vec(); + let len = vals.len(); + let mut out = vec![f64::NAN; len]; + for (i, v) in out.iter_mut().enumerate().skip(n - 1) { + let window = &vals[i + 1 - n..=i]; + let mean: f64 = window.iter().sum::() / n as f64; + let variance: f64 = + window.iter().map(|x| (x - mean).powi(2)).sum::() / (n - 1) as f64; + *v = variance.sqrt(); + } + Self::series_from(self.index.clone(), out) + } + + /// Returns the per-period true range series. + /// + /// $$TR_t = \left|x_t - x_{t-1}\right|$$ + /// + /// The first element is `NaN`. + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::EmptySeries`] if the series is empty. + pub fn true_range(&self) -> Result { + if self.is_empty() { + return Err(TemporalSeriesError::EmptySeries); + } + let vals = self.values_vec(); + let mut out = vec![f64::NAN; vals.len()]; + for (r, w) in out[1..].iter_mut().zip(vals.windows(2)) { + *r = (w[1] - w[0]).abs(); + } + Self::series_from(self.index.clone(), out) + } + + /// Returns the n-period average true range (ATR) series. + /// + /// $$ATR_t^{(n)} = \frac{1}{n} \sum_{i=0}^{n-1} TR_{t-i}, \qquad TR_t = \left|x_t - x_{t-1}\right|$$ + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::InvalidWindow`] if `n` is too large for the series. + pub fn average_true_range(&self, n: usize) -> Result { + self.true_range()?.moving_average(n) + } + + /// Returns Bollinger Bands as `(upper, middle, lower)`. + /// + /// $$BB_{upper}(t) = MA_t^{(w)} + k \cdot \sigma_t^{(w)}, \quad BB_{lower}(t) = MA_t^{(w)} - k \cdot \sigma_t^{(w)}$$ + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::InvalidWindow`] if `window` is invalid. + pub fn bollinger_bands( + &self, + window: usize, + k: f64, + ) -> Result<(ColSeries, ColSeries, ColSeries), TemporalSeriesError> { + let middle = self.moving_average(window)?; + let rolling_std = self.rolling_standard_deviation(window)?; + let upper_values: Vec = middle + .iter() + .copied() + .zip(rolling_std.iter().copied()) + .map(|(m, s)| m + k * s) + .collect(); + let lower_values: Vec = middle + .iter() + .copied() + .zip(rolling_std.iter().copied()) + .map(|(m, s)| m - k * s) + .collect(); + let upper = Self::series_from(self.index.clone(), upper_values)?; + let lower = Self::series_from(self.index.clone(), lower_values)?; + Ok((upper, middle, lower)) + } + + // AUTOCORRELATION -------------------------------------------------------- + + /// Returns the autocorrelation function (ACF) at a given lag. + /// + /// $$\rho(k) = \frac{\displaystyle\sum_{t=k}^{n-1}(x_t - \bar{x})(x_{t-k} - \bar{x})}{\displaystyle\sum_{t=0}^{n-1}(x_t - \bar{x})^2}$$ + /// + /// By definition $\rho(0) = 1$. Returns `NaN` if the series has zero variance. + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::EmptySeries`] if the series is empty, or + /// [`TemporalSeriesError::ParameterRangeError`] if `lag >= n`. + pub fn autocorrelation_function(&self, lag: usize) -> Result { + let n = self.len(); + if n == 0 { + return Err(TemporalSeriesError::EmptySeries); + } + if lag >= n { + return Err(TemporalSeriesError::ParameterRangeError(format!( + "lag ({lag}) must be less than series length ({n})" + ))); + } + let vals = self.values_vec(); + let mean = self.mean(); + let variance: f64 = vals.iter().map(|x| (x - mean).powi(2)).sum::(); + if variance == 0.0 { + return Ok(f64::NAN); + } + let covariance: f64 = (lag..n) + .map(|t| (vals[t] - mean) * (vals[t - lag] - mean)) + .sum::(); + Ok(covariance / variance) + } + + /// Returns the partial autocorrelation function (PACF) at a given lag + /// via Levinson-Durbin recursion. + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::EmptySeries`] if the series is empty, or + /// [`TemporalSeriesError::ParameterRangeError`] if `lag >= n`. + pub fn partial_autocorrelation_function(&self, lag: usize) -> Result { + let n = self.len(); + if n == 0 { + return Err(TemporalSeriesError::EmptySeries); + } + if lag >= n { + return Err(TemporalSeriesError::ParameterRangeError(format!( + "lag ({lag}) must be less than series length ({n})" + ))); + } + if lag == 0 { + return Ok(1.0); + } + let acf: Vec = (1..=lag) + .map(|k| self.autocorrelation_function(k)) + .collect::, _>>()?; + let mut phi: Vec = vec![acf[0]]; + for k in 1..lag { + let num: f64 = acf[k] - (0..k).map(|j| phi[j] * acf[k - 1 - j]).sum::(); + let den: f64 = 1.0 - (0..k).map(|j| phi[j] * acf[j]).sum::(); + let phi_kk = if den.abs() < f64::EPSILON { + 0.0 + } else { + num / den + }; + let prev = phi.clone(); + let updated: Vec = (0..k).map(|j| prev[j] - phi_kk * prev[k - 1 - j]).collect(); + phi = updated; + phi.push(phi_kk); + } + Ok(*phi.last().unwrap()) + } + + // STATIONARITY ----------------------------------------------------------- + + /// Computes the Dickey-Fuller test statistic for a unit root. + /// + /// Fits $\Delta x_t = \gamma x_{t-1} + \varepsilon_t$ via OLS and returns + /// $\hat{\gamma} / SE(\hat{\gamma})$. + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::EmptySeries`] if `n < 3`. + pub fn stationary_dickey_fuller_statistics(&self) -> Result { + let n = self.len(); + if n < 3 { + return Err(TemporalSeriesError::EmptySeries); + } + let vals = self.values_vec(); + let delta: Vec = (1..n).map(|t| vals[t] - vals[t - 1]).collect(); + let lagged: Vec = (0..n - 1).map(|t| vals[t]).collect(); + let ss_xy: f64 = lagged.iter().zip(delta.iter()).map(|(x, y)| x * y).sum(); + let ss_xx: f64 = lagged.iter().map(|x| x * x).sum(); + if ss_xx.abs() < f64::EPSILON { + return Ok(0.0); + } + let gamma = ss_xy / ss_xx; + let sse: f64 = lagged + .iter() + .zip(delta.iter()) + .map(|(x, y)| (y - gamma * x).powi(2)) + .sum(); + if sse < f64::EPSILON { + return Ok(f64::NEG_INFINITY); + } + let m = lagged.len(); + let sigma2 = sse / (m - 1) as f64; + let se = (sigma2 / ss_xx).sqrt(); + Ok(gamma / se) + } + + /// Tests for stationarity using the Dickey-Fuller test. + /// + /// Returns `true` if the unit-root null hypothesis is rejected at `alpha` + /// (i.e. the series is stationary). + /// + /// | `alpha` | Critical value | + /// |---------|---------------| + /// | 0.01 | −2.60 | + /// | 0.05 | −1.95 | + /// | 0.10 | −1.61 | + /// + /// # Errors + /// + /// Propagates errors from [`Self::stationary_dickey_fuller_statistics`]. + pub fn stationary_dickey_fuller_test(&self, alpha: f32) -> Result { + let cv = match alpha { + a if a <= 0.01 => -2.60, + a if a <= 0.05 => -1.95, + _ => -1.61, + }; + Ok(self.stationary_dickey_fuller_statistics()? < cv) + } + + // DISTRIBUTION ANALYSIS -------------------------------------------------- + + /// Returns the Fisher-Pearson skewness of the series. + /// + /// $$\text{Skew} = \frac{m_3}{m_2^{3/2}}, \quad m_k = \frac{1}{n}\sum_{i=1}^{n}(x_i - \bar{x})^k$$ + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::EmptySeries`] if `n < 3`. + pub fn skewness(&self) -> Result { + let n = self.len(); + if n < 3 { + return Err(TemporalSeriesError::EmptySeries); + } + let mean = self.mean(); + let nf = n as f64; + let m2: f64 = self.iter().map(|x| (x - mean).powi(2)).sum::() / nf; + let m3: f64 = self.iter().map(|x| (x - mean).powi(3)).sum::() / nf; + if m2 < f64::EPSILON { + return Ok(0.0); + } + Ok(m3 / m2.powf(1.5)) + } + + /// Returns the excess kurtosis of the series. + /// + /// $$\kappa_{excess} = \frac{m_4}{m_2^2} - 3$$ + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError::EmptySeries`] if `n < 4`. + pub fn excess_kurtosis(&self) -> Result { + let n = self.len(); + if n < 4 { + return Err(TemporalSeriesError::EmptySeries); + } + let mean = self.mean(); + let nf = n as f64; + let m2: f64 = self.iter().map(|x| (x - mean).powi(2)).sum::() / nf; + let m4: f64 = self.iter().map(|x| (x - mean).powi(4)).sum::() / nf; + if m2 < f64::EPSILON { + return Ok(-3.0); + } + Ok(m4 / m2.powi(2) - 3.0) + } + + /// Computes the Jarque-Bera test statistic. + /// + /// $$JB = n\!\left(\frac{S^2}{6} + \frac{K^2}{24}\right) \sim \chi^2(2)$$ + /// + /// # Errors + /// + /// Propagates errors from [`Self::skewness`] and [`Self::excess_kurtosis`]. + pub fn jacque_bera_statistics(&self) -> Result { + let n = self.len() as f64; + let s = self.skewness()?; + let k = self.excess_kurtosis()?; + Ok(n * (s.powi(2) / 6.0 + k.powi(2) / 24.0)) + } + + /// Tests for normality using the Jarque-Bera test. + /// + /// Returns `true` when the series is consistent with normality (null not rejected). + /// + /// | `alpha` | χ²(2) critical value | + /// |---------|---------------------| + /// | 0.01 | 9.210 | + /// | 0.05 | 5.991 | + /// | 0.10 | 4.605 | + /// + /// # Errors + /// + /// Propagates errors from [`Self::jacque_bera_statistics`]. + pub fn jacque_bera_test(&self, alpha: f32) -> Result { + let cv = match alpha { + a if a <= 0.01 => 9.210, + a if a <= 0.05 => 5.991, + _ => 4.605, + }; + Ok(self.jacque_bera_statistics()? < cv) + } +} diff --git a/tests/series/mod.rs b/tests/series/mod.rs index e5b180d..398925e 100644 --- a/tests/series/mod.rs +++ b/tests/series/mod.rs @@ -1,24 +1,2 @@ -mod test_autocorrelation_function; -mod test_average_true_range; -mod test_borillenger_bads; -mod test_crossover_signal; -mod test_cumulative_return; -mod test_dickey_fuller_test; -mod test_excess_kurtosis; -mod test_exponential_moving_average; -mod test_iqr; -mod test_is_empty; -mod test_jacque_bera_test; -mod test_len; -mod test_log_return; -mod test_mean; -mod test_moving_average; -mod test_new_instance; -mod test_partial_autocorrelation_function; -mod test_pct_change; -mod test_quantile; -mod test_rolling_standard_deviation; -mod test_simple_return; -mod test_skewness; -mod test_std_deviation; -mod test_true_range; +mod temporal_series; +mod time_series; diff --git a/tests/series/temporal_series/mod.rs b/tests/series/temporal_series/mod.rs new file mode 100644 index 0000000..fbb4328 --- /dev/null +++ b/tests/series/temporal_series/mod.rs @@ -0,0 +1,20 @@ +mod test_autocorrelation_function; +mod test_average_true_range; +mod test_bollinger_bands; +mod test_crossover_signal; +mod test_cumulative_return; +mod test_dickey_fuller_test; +mod test_excess_kurtosis; +mod test_exponential_moving_average; +mod test_iqr; +mod test_jacque_bera_test; +mod test_log_return; +mod test_mean; +mod test_moving_average; +mod test_partial_autocorrelation_function; +mod test_quantile; +mod test_rolling_standard_deviation; +mod test_simple_return; +mod test_skewness; +mod test_std_deviation; +mod test_true_range; diff --git a/tests/series/temporal_series/test_autocorrelation_function.rs b/tests/series/temporal_series/test_autocorrelation_function.rs new file mode 100644 index 0000000..782cd84 --- /dev/null +++ b/tests/series/temporal_series/test_autocorrelation_function.rs @@ -0,0 +1,56 @@ +use temporalseries::errors::TemporalSeriesError; +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ColumnarBackend; + +type TS = TemporalSeries>; + +#[test] +#[allow(non_snake_case)] +fn test__given_any_series__when_compute_acf_at_lag_0__then_returns_one() { + // Given + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4, 5], + ColumnarBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]), + ) + .unwrap(); + + // When + let result: f64 = sut.autocorrelation_function(0).unwrap(); + + // Then + assert_eq!(result, 1.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_linear_series__when_compute_acf_at_lag_1__then_returns_correct_value() { + // Given + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4, 5], + ColumnarBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]), + ) + .unwrap(); + + // When + let result: f64 = sut.autocorrelation_function(1).unwrap(); + + // Then + assert!((result - 0.4).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_lag_out_of_range__when_compute_acf__then_returns_parameter_range_error() { + // Given + let sut: TS = + TemporalSeries::new(vec![1, 2, 3], ColumnarBackend::new(vec![1.0, 2.0, 3.0])).unwrap(); + + // When + let result: Result = sut.autocorrelation_function(10); + + // Then + assert!(matches!( + result, + Err(TemporalSeriesError::ParameterRangeError(_)) + )); +} diff --git a/tests/series/temporal_series/test_average_true_range.rs b/tests/series/temporal_series/test_average_true_range.rs new file mode 100644 index 0000000..3e4b703 --- /dev/null +++ b/tests/series/temporal_series/test_average_true_range.rs @@ -0,0 +1,45 @@ +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ColumnarBackend; + +type TS = TemporalSeries>; + +#[test] +#[allow(non_snake_case)] +fn test__given_constant_series__when_compute_atr__then_returns_zeros() { + // Given + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4], + ColumnarBackend::new(vec![5.0, 5.0, 5.0, 5.0]), + ) + .unwrap(); + + // When + let result = sut.average_true_range(2).unwrap(); + let values: Vec = result.iter().copied().collect(); + + // Then + assert!(values[0].is_nan()); + assert!(values[1].is_nan()); + assert_eq!(values[2], 0.0); + assert_eq!(values[3], 0.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_growing_series_window_2__when_compute_atr__then_computes_correctly() { + // Given + // TR = [NaN, 2, 3, 4]; ATR(2): mean(2,3)=2.5, mean(3,4)=3.5 + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4], + ColumnarBackend::new(vec![1.0, 3.0, 6.0, 10.0]), + ) + .unwrap(); + + // When + let result = sut.average_true_range(2).unwrap(); + let values: Vec = result.iter().copied().collect(); + + // Then + assert!((values[2] - 2.5).abs() < 1e-9); + assert!((values[3] - 3.5).abs() < 1e-9); +} diff --git a/tests/series/temporal_series/test_bollinger_bands.rs b/tests/series/temporal_series/test_bollinger_bands.rs new file mode 100644 index 0000000..c582f47 --- /dev/null +++ b/tests/series/temporal_series/test_bollinger_bands.rs @@ -0,0 +1,66 @@ +use temporalseries::errors::TemporalSeriesError; +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ColumnarBackend; + +type TS = TemporalSeries>; + +#[test] +#[allow(non_snake_case)] +fn test__given_constant_series__when_compute_bollinger_bands__then_all_bands_collapse() { + // Given + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4, 5], + ColumnarBackend::new(vec![3.0, 3.0, 3.0, 3.0, 3.0]), + ) + .unwrap(); + + // When + let (upper, mid, lower) = sut.bollinger_bands(3, 2.0).unwrap(); + let u: Vec = upper.iter().copied().collect(); + let m: Vec = mid.iter().copied().collect(); + let l: Vec = lower.iter().copied().collect(); + + // Then + assert_eq!(u[2], 3.0); + assert_eq!(m[2], 3.0); + assert_eq!(l[2], 3.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_linear_series__when_compute_bollinger_bands__then_upper_ge_mid_ge_lower() { + // Given + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4, 5], + ColumnarBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]), + ) + .unwrap(); + + // When + let (upper, mid, lower) = sut.bollinger_bands(3, 2.0).unwrap(); + let u: Vec = upper.iter().copied().collect(); + let m: Vec = mid.iter().copied().collect(); + let l: Vec = lower.iter().copied().collect(); + + // Then + for i in 2..5 { + assert!(u[i] >= m[i]); + assert!(m[i] >= l[i]); + } +} + +#[test] +#[allow(non_snake_case)] +fn test__given_invalid_window__when_compute_bollinger_bands__then_returns_invalid_window_error() { + // Given + let sut: TS = TemporalSeries::new(vec![1, 2], ColumnarBackend::new(vec![1.0, 2.0])).unwrap(); + + // When + let result: Result<_, TemporalSeriesError> = sut.bollinger_bands(5, 2.0); + + // Then + assert!(matches!( + result, + Err(TemporalSeriesError::InvalidWindow { .. }) + )); +} diff --git a/tests/series/temporal_series/test_crossover_signal.rs b/tests/series/temporal_series/test_crossover_signal.rs new file mode 100644 index 0000000..4467d6d --- /dev/null +++ b/tests/series/temporal_series/test_crossover_signal.rs @@ -0,0 +1,62 @@ +use temporalseries::errors::TemporalSeriesError; +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ColumnarBackend; + +type TS = TemporalSeries>; + +#[test] +#[allow(non_snake_case)] +fn test__given_constant_series__when_compute_crossover_signal__then_all_zeros() { + // Given + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4, 5], + ColumnarBackend::new(vec![3.0, 3.0, 3.0, 3.0, 3.0]), + ) + .unwrap(); + + // When + let result = sut.crossover_signal(2, 3).unwrap(); + + // Then + assert!(result.iter().all(|&v| v == 0.0)); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_bullish_crossover__when_compute_crossover_signal__then_returns_positive_one() { + // Given + // Fast(2) crosses above slow(3) when the series suddenly rises. + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4, 5, 6, 7, 8], + ColumnarBackend::new(vec![1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0]), + ) + .unwrap(); + + // When + let result = sut.crossover_signal(2, 3).unwrap(); + let signals: Vec = result.iter().copied().collect(); + + // Then + assert!(signals.contains(&1.0)); + assert!(!signals.contains(&-1.0)); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_fast_ge_slow__when_compute_crossover_signal__then_returns_parameter_range_error() { + // Given + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4, 5], + ColumnarBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]), + ) + .unwrap(); + + // When + let result: Result<_, TemporalSeriesError> = sut.crossover_signal(3, 3); + + // Then + assert!(matches!( + result, + Err(TemporalSeriesError::ParameterRangeError(_)) + )); +} diff --git a/tests/series/temporal_series/test_cumulative_return.rs b/tests/series/temporal_series/test_cumulative_return.rs new file mode 100644 index 0000000..bad75af --- /dev/null +++ b/tests/series/temporal_series/test_cumulative_return.rs @@ -0,0 +1,50 @@ +use temporalseries::errors::TemporalSeriesError; +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ColumnarBackend; + +type TS = TemporalSeries>; + +#[test] +#[allow(non_snake_case)] +fn test__given_growing_series__when_compute_cumulative_return__then_computes_correctly() { + // Given + // (121 - 100) / 100 = 0.21 + let sut: TS = TemporalSeries::new( + vec![1, 2, 3], + ColumnarBackend::new(vec![100.0, 110.0, 121.0]), + ) + .unwrap(); + + // When + let result: f64 = sut.cumulative_return().unwrap(); + + // Then + assert!((result - 0.21).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_constant_series__when_compute_cumulative_return__then_returns_zero() { + // Given + let sut: TS = + TemporalSeries::new(vec![1, 2, 3], ColumnarBackend::new(vec![50.0, 50.0, 50.0])).unwrap(); + + // When + let result: f64 = sut.cumulative_return().unwrap(); + + // Then + assert_eq!(result, 0.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_empty_series__when_compute_cumulative_return__then_returns_empty_series_error() { + // Given + let sut: TS = TemporalSeries::new(vec![], ColumnarBackend::new(vec![])).unwrap(); + + // When + let result: Result = sut.cumulative_return(); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::EmptySeries))); +} diff --git a/tests/series/temporal_series/test_dickey_fuller_test.rs b/tests/series/temporal_series/test_dickey_fuller_test.rs new file mode 100644 index 0000000..62e04a5 --- /dev/null +++ b/tests/series/temporal_series/test_dickey_fuller_test.rs @@ -0,0 +1,52 @@ +use temporalseries::errors::TemporalSeriesError; +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ColumnarBackend; + +type TS = TemporalSeries>; + +#[test] +#[allow(non_snake_case)] +fn test__given_trending_series__when_dickey_fuller_test__then_returns_false() { + // Given + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4, 5, 6, 7, 8], + ColumnarBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]), + ) + .unwrap(); + + // When + let result: bool = sut.stationary_dickey_fuller_test(0.05).unwrap(); + + // Then + assert!(!result); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_alternating_series__when_dickey_fuller_test__then_returns_true() { + // Given + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4, 5, 6, 7, 8], + ColumnarBackend::new(vec![1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0]), + ) + .unwrap(); + + // When + let result: bool = sut.stationary_dickey_fuller_test(0.05).unwrap(); + + // Then + assert!(result); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_short_series__when_dickey_fuller_test__then_returns_empty_series_error() { + // Given + let sut: TS = TemporalSeries::new(vec![1, 2], ColumnarBackend::new(vec![1.0, 2.0])).unwrap(); + + // When + let result: Result = sut.stationary_dickey_fuller_test(0.05); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::EmptySeries))); +} diff --git a/tests/series/temporal_series/test_excess_kurtosis.rs b/tests/series/temporal_series/test_excess_kurtosis.rs new file mode 100644 index 0000000..a2164f6 --- /dev/null +++ b/tests/series/temporal_series/test_excess_kurtosis.rs @@ -0,0 +1,37 @@ +use temporalseries::errors::TemporalSeriesError; +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ColumnarBackend; + +type TS = TemporalSeries>; + +#[test] +#[allow(non_snake_case)] +fn test__given_linear_series__when_compute_excess_kurtosis__then_returns_correct_value() { + // Given + // [1,2,3,4,5]: m2=2.0, m4=6.8 => 6.8/4.0 - 3 = -1.3 + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4, 5], + ColumnarBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]), + ) + .unwrap(); + + // When + let result: f64 = sut.excess_kurtosis().unwrap(); + + // Then + assert!((result - (-1.3)).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_too_short_series__when_compute_excess_kurtosis__then_returns_empty_series_error() { + // Given + let sut: TS = + TemporalSeries::new(vec![1, 2, 3], ColumnarBackend::new(vec![1.0, 2.0, 3.0])).unwrap(); + + // When + let result: Result = sut.excess_kurtosis(); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::EmptySeries))); +} diff --git a/tests/series/temporal_series/test_exponential_moving_average.rs b/tests/series/temporal_series/test_exponential_moving_average.rs new file mode 100644 index 0000000..cae0906 --- /dev/null +++ b/tests/series/temporal_series/test_exponential_moving_average.rs @@ -0,0 +1,53 @@ +use temporalseries::errors::TemporalSeriesError; +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ColumnarBackend; + +type TS = TemporalSeries>; + +#[test] +#[allow(non_snake_case)] +fn test__given_constant_series__when_compute_ema__then_returns_constant() { + // Given + let sut: TS = + TemporalSeries::new(vec![1, 2, 3], ColumnarBackend::new(vec![5.0, 5.0, 5.0])).unwrap(); + + // When + let result = sut.exponential_moving_average(3).unwrap(); + let values: Vec = result.iter().copied().collect(); + + // Then + assert_eq!(values[0], 5.0); + assert_eq!(values[1], 5.0); + assert_eq!(values[2], 5.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_span_3__when_compute_ema__then_computes_with_alpha_0_5() { + // Given + // span=3 -> alpha=0.5; EMA_0=1, EMA_1=0.5*2+0.5*1=1.5, EMA_2=0.5*3+0.5*1.5=2.25 + let sut: TS = + TemporalSeries::new(vec![1, 2, 3], ColumnarBackend::new(vec![1.0, 2.0, 3.0])).unwrap(); + + // When + let result = sut.exponential_moving_average(3).unwrap(); + let values: Vec = result.iter().copied().collect(); + + // Then + assert_eq!(values[0], 1.0); + assert_eq!(values[1], 1.5); + assert_eq!(values[2], 2.25); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_empty_series__when_compute_ema__then_returns_empty_series_error() { + // Given + let sut: TS = TemporalSeries::new(vec![], ColumnarBackend::new(vec![])).unwrap(); + + // When + let result: Result<_, TemporalSeriesError> = sut.exponential_moving_average(3); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::EmptySeries))); +} diff --git a/tests/series/temporal_series/test_iqr.rs b/tests/series/temporal_series/test_iqr.rs new file mode 100644 index 0000000..29c3edf --- /dev/null +++ b/tests/series/temporal_series/test_iqr.rs @@ -0,0 +1,53 @@ +use temporalseries::errors::TemporalSeriesError; +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ColumnarBackend; + +type TS = TemporalSeries>; + +#[test] +#[allow(non_snake_case)] +fn test__given_linear_series__when_compute_iqr__then_returns_correct_spread() { + // Given + // [1,2,3,4,5]: Q1=2.0, Q3=4.0, IQR=2.0 + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4, 5], + ColumnarBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]), + ) + .unwrap(); + + // When + let result: f64 = sut.iqr().unwrap(); + + // Then + assert_eq!(result, 2.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_constant_series__when_compute_iqr__then_returns_zero() { + // Given + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4], + ColumnarBackend::new(vec![7.0, 7.0, 7.0, 7.0]), + ) + .unwrap(); + + // When + let result: f64 = sut.iqr().unwrap(); + + // Then + assert_eq!(result, 0.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_empty_series__when_compute_iqr__then_returns_empty_series_error() { + // Given + let sut: TS = TemporalSeries::new(vec![], ColumnarBackend::new(vec![])).unwrap(); + + // When + let result: Result = sut.iqr(); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::EmptySeries))); +} diff --git a/tests/series/temporal_series/test_jacque_bera_test.rs b/tests/series/temporal_series/test_jacque_bera_test.rs new file mode 100644 index 0000000..a7909b9 --- /dev/null +++ b/tests/series/temporal_series/test_jacque_bera_test.rs @@ -0,0 +1,53 @@ +use temporalseries::errors::TemporalSeriesError; +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ColumnarBackend; + +type TS = TemporalSeries>; + +#[test] +#[allow(non_snake_case)] +fn test__given_near_normal_series__when_jacque_bera_test__then_returns_true() { + // Given + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4, 5], + ColumnarBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]), + ) + .unwrap(); + + // When + let result: bool = sut.jacque_bera_test(0.05).unwrap(); + + // Then + assert!(result); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_heavily_skewed_series__when_jacque_bera_test__then_returns_false() { + // Given + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + ColumnarBackend::new(vec![1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 100.0]), + ) + .unwrap(); + + // When + let result: bool = sut.jacque_bera_test(0.05).unwrap(); + + // Then + assert!(!result); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_too_short_series__when_jacque_bera_test__then_returns_empty_series_error() { + // Given + let sut: TS = + TemporalSeries::new(vec![1, 2, 3], ColumnarBackend::new(vec![1.0, 2.0, 3.0])).unwrap(); + + // When + let result: Result = sut.jacque_bera_test(0.05); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::EmptySeries))); +} diff --git a/tests/series/temporal_series/test_log_return.rs b/tests/series/temporal_series/test_log_return.rs new file mode 100644 index 0000000..3c6e937 --- /dev/null +++ b/tests/series/temporal_series/test_log_return.rs @@ -0,0 +1,55 @@ +use temporalseries::errors::TemporalSeriesError; +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ColumnarBackend; + +type TS = TemporalSeries>; + +#[test] +#[allow(non_snake_case)] +fn test__given_constant_series__when_compute_log_return__then_all_returns_are_zero() { + // Given + let sut: TS = + TemporalSeries::new(vec![1, 2, 3], ColumnarBackend::new(vec![5.0, 5.0, 5.0])).unwrap(); + + // When + let result = sut.log_return().unwrap(); + let values: Vec = result.iter().copied().collect(); + + // Then + assert!(values[0].is_nan()); + assert_eq!(values[1], 0.0); + assert_eq!(values[2], 0.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_e_ratio__when_compute_log_return__then_returns_one() { + // Given + // ln(e / 1) = 1.0 + let sut: TS = TemporalSeries::new( + vec![1, 2], + ColumnarBackend::new(vec![1.0, std::f64::consts::E]), + ) + .unwrap(); + + // When + let result = sut.log_return().unwrap(); + let values: Vec = result.iter().copied().collect(); + + // Then + assert!(values[0].is_nan()); + assert!((values[1] - 1.0).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_empty_series__when_compute_log_return__then_returns_empty_series_error() { + // Given + let sut: TS = TemporalSeries::new(vec![], ColumnarBackend::new(vec![])).unwrap(); + + // When + let result: Result<_, TemporalSeriesError> = sut.log_return(); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::EmptySeries))); +} diff --git a/tests/series/temporal_series/test_mean.rs b/tests/series/temporal_series/test_mean.rs new file mode 100644 index 0000000..ad0463d --- /dev/null +++ b/tests/series/temporal_series/test_mean.rs @@ -0,0 +1,35 @@ +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ColumnarBackend; + +type TS = TemporalSeries>; + +#[test] +#[allow(non_snake_case)] +fn test__given_zeros__when_compute_mean__then_returns_zero() { + // Given + let sut: TS = + TemporalSeries::new(vec![1, 2, 3], ColumnarBackend::new(vec![0.0, 0.0, 0.0])).unwrap(); + + // When + let result: f64 = sut.mean(); + + // Then + assert_eq!(result, 0.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_linear_series__when_compute_mean__then_returns_arithmetic_mean() { + // Given + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4], + ColumnarBackend::new(vec![1.0, 2.0, 3.0, 4.0]), + ) + .unwrap(); + + // When + let result: f64 = sut.mean(); + + // Then + assert!((result - 2.5).abs() < 1e-9); +} diff --git a/tests/series/temporal_series/test_moving_average.rs b/tests/series/temporal_series/test_moving_average.rs new file mode 100644 index 0000000..b3baa51 --- /dev/null +++ b/tests/series/temporal_series/test_moving_average.rs @@ -0,0 +1,67 @@ +use temporalseries::errors::TemporalSeriesError; +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ColumnarBackend; + +type TS = TemporalSeries>; + +#[test] +#[allow(non_snake_case)] +fn test__given_constant_series_window_3__when_compute_moving_average__then_returns_constant() { + // Given + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4, 5], + ColumnarBackend::new(vec![4.0, 4.0, 4.0, 4.0, 4.0]), + ) + .unwrap(); + + // When + let result = sut.moving_average(3).unwrap(); + let values: Vec = result.iter().copied().collect(); + + // Then + assert!(values[0].is_nan()); + assert!(values[1].is_nan()); + assert_eq!(values[2], 4.0); + assert_eq!(values[3], 4.0); + assert_eq!(values[4], 4.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_linear_series_window_3__when_compute_moving_average__then_computes_correctly() { + // Given + // windows: [1,2,3]=2, [2,3,4]=3, [3,4,5]=4 + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4, 5], + ColumnarBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]), + ) + .unwrap(); + + // When + let result = sut.moving_average(3).unwrap(); + let values: Vec = result.iter().copied().collect(); + + // Then + assert!(values[0].is_nan()); + assert!(values[1].is_nan()); + assert!((values[2] - 2.0).abs() < 1e-9); + assert!((values[3] - 3.0).abs() < 1e-9); + assert!((values[4] - 4.0).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_window_exceeds_length__when_compute_moving_average__then_returns_invalid_window_error() + { + // Given + let sut: TS = TemporalSeries::new(vec![1, 2], ColumnarBackend::new(vec![1.0, 2.0])).unwrap(); + + // When + let result: Result<_, TemporalSeriesError> = sut.moving_average(5); + + // Then + assert!(matches!( + result, + Err(TemporalSeriesError::InvalidWindow { .. }) + )); +} diff --git a/tests/series/temporal_series/test_partial_autocorrelation_function.rs b/tests/series/temporal_series/test_partial_autocorrelation_function.rs new file mode 100644 index 0000000..c1a57bf --- /dev/null +++ b/tests/series/temporal_series/test_partial_autocorrelation_function.rs @@ -0,0 +1,57 @@ +use temporalseries::errors::TemporalSeriesError; +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ColumnarBackend; + +type TS = TemporalSeries>; + +#[test] +#[allow(non_snake_case)] +fn test__given_any_series__when_compute_pacf_at_lag_0__then_returns_one() { + // Given + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4, 5], + ColumnarBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]), + ) + .unwrap(); + + // When + let result: f64 = sut.partial_autocorrelation_function(0).unwrap(); + + // Then + assert_eq!(result, 1.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_linear_series__when_compute_pacf_at_lag_1__then_equals_acf_at_lag_1() { + // Given + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4, 5], + ColumnarBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]), + ) + .unwrap(); + + // When + let acf: f64 = sut.autocorrelation_function(1).unwrap(); + let pacf: f64 = sut.partial_autocorrelation_function(1).unwrap(); + + // Then + assert!((acf - pacf).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_lag_out_of_range__when_compute_pacf__then_returns_parameter_range_error() { + // Given + let sut: TS = + TemporalSeries::new(vec![1, 2, 3], ColumnarBackend::new(vec![1.0, 2.0, 3.0])).unwrap(); + + // When + let result: Result = sut.partial_autocorrelation_function(10); + + // Then + assert!(matches!( + result, + Err(TemporalSeriesError::ParameterRangeError(_)) + )); +} diff --git a/tests/series/temporal_series/test_quantile.rs b/tests/series/temporal_series/test_quantile.rs new file mode 100644 index 0000000..b3b9ffb --- /dev/null +++ b/tests/series/temporal_series/test_quantile.rs @@ -0,0 +1,63 @@ +use temporalseries::errors::TemporalSeriesError; +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ColumnarBackend; + +type TS = TemporalSeries>; + +#[test] +#[allow(non_snake_case)] +fn test__given_odd_length_series__when_compute_quartiles__then_returns_exact_values() { + // Given + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4, 5], + ColumnarBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]), + ) + .unwrap(); + + // When / Then + assert_eq!(sut.quantile(0.0).unwrap(), 1.0); + assert_eq!(sut.quantile(0.25).unwrap(), 2.0); + assert_eq!(sut.quantile(0.5).unwrap(), 3.0); + assert_eq!(sut.quantile(0.75).unwrap(), 4.0); + assert_eq!(sut.quantile(1.0).unwrap(), 5.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_even_length_series__when_compute_median__then_interpolates() { + // Given + // h = 0.5 * 3 = 1.5 -> 2.0 + 0.5 * (3.0 - 2.0) = 2.5 + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4], + ColumnarBackend::new(vec![1.0, 2.0, 3.0, 4.0]), + ) + .unwrap(); + + // When + let result: f64 = sut.quantile(0.5).unwrap(); + + // Then + assert!((result - 2.5).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_out_of_range_p__when_compute_quantile__then_returns_parameter_range_error() { + // Given + let sut: TS = + TemporalSeries::new(vec![1, 2, 3], ColumnarBackend::new(vec![1.0, 2.0, 3.0])).unwrap(); + + // When + let result_low: Result = sut.quantile(-0.1); + let result_high: Result = sut.quantile(1.1); + + // Then + assert!(matches!( + result_low, + Err(TemporalSeriesError::ParameterRangeError(_)) + )); + assert!(matches!( + result_high, + Err(TemporalSeriesError::ParameterRangeError(_)) + )); +} diff --git a/tests/series/temporal_series/test_rolling_standard_deviation.rs b/tests/series/temporal_series/test_rolling_standard_deviation.rs new file mode 100644 index 0000000..a5d89a0 --- /dev/null +++ b/tests/series/temporal_series/test_rolling_standard_deviation.rs @@ -0,0 +1,70 @@ +use temporalseries::errors::TemporalSeriesError; +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ColumnarBackend; + +type TS = TemporalSeries>; + +#[test] +#[allow(non_snake_case)] +fn test__given_constant_series__when_compute_rolling_std__then_returns_zeros() { + // Given + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4], + ColumnarBackend::new(vec![5.0, 5.0, 5.0, 5.0]), + ) + .unwrap(); + + // When + let result = sut.rolling_standard_deviation(2).unwrap(); + let values: Vec = result.iter().copied().collect(); + + // Then + assert!(values[0].is_nan()); + assert_eq!(values[1], 0.0); + assert_eq!(values[2], 0.0); + assert_eq!(values[3], 0.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_linear_series_window_3__when_compute_rolling_std__then_computes_correctly() { + // Given + // Each window [1,2,3], [2,3,4], [3,4,5] has sample std = 1.0 + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4, 5], + ColumnarBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]), + ) + .unwrap(); + + // When + let result = sut.rolling_standard_deviation(3).unwrap(); + let values: Vec = result.iter().copied().collect(); + + // Then + assert!(values[0].is_nan()); + assert!(values[1].is_nan()); + assert!((values[2] - 1.0).abs() < 1e-9); + assert!((values[3] - 1.0).abs() < 1e-9); + assert!((values[4] - 1.0).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_invalid_window__when_compute_rolling_std__then_returns_invalid_window_error() { + // Given + let sut: TS = TemporalSeries::new(vec![1, 2], ColumnarBackend::new(vec![1.0, 2.0])).unwrap(); + + // When + let result_too_large: Result<_, TemporalSeriesError> = sut.rolling_standard_deviation(5); + let result_window_1: Result<_, TemporalSeriesError> = sut.rolling_standard_deviation(1); + + // Then + assert!(matches!( + result_too_large, + Err(TemporalSeriesError::InvalidWindow { .. }) + )); + assert!(matches!( + result_window_1, + Err(TemporalSeriesError::InvalidWindow { .. }) + )); +} diff --git a/tests/series/temporal_series/test_simple_return.rs b/tests/series/temporal_series/test_simple_return.rs new file mode 100644 index 0000000..f510551 --- /dev/null +++ b/tests/series/temporal_series/test_simple_return.rs @@ -0,0 +1,56 @@ +use temporalseries::errors::TemporalSeriesError; +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ColumnarBackend; + +type TS = TemporalSeries>; + +#[test] +#[allow(non_snake_case)] +fn test__given_constant_series__when_compute_simple_return__then_all_returns_are_zero() { + // Given + let sut: TS = + TemporalSeries::new(vec![1, 2, 3], ColumnarBackend::new(vec![5.0, 5.0, 5.0])).unwrap(); + + // When + let result = sut.simple_return().unwrap(); + let values: Vec = result.iter().copied().collect(); + + // Then + assert!(values[0].is_nan()); + assert_eq!(values[1], 0.0); + assert_eq!(values[2], 0.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_growing_series__when_compute_simple_return__then_computes_correctly() { + // Given + // [100, 110, 121] -> r_1 = (110-100)/100 = 0.1, r_2 = (121-110)/110 = 0.1 + let sut: TS = TemporalSeries::new( + vec![1, 2, 3], + ColumnarBackend::new(vec![100.0, 110.0, 121.0]), + ) + .unwrap(); + + // When + let result = sut.simple_return().unwrap(); + let values: Vec = result.iter().copied().collect(); + + // Then + assert!(values[0].is_nan()); + assert!((values[1] - 0.1).abs() < 1e-9); + assert!((values[2] - 0.1).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_empty_series__when_compute_simple_return__then_returns_empty_series_error() { + // Given + let sut: TS = TemporalSeries::new(vec![], ColumnarBackend::new(vec![])).unwrap(); + + // When + let result: Result<_, TemporalSeriesError> = sut.simple_return(); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::EmptySeries))); +} diff --git a/tests/series/temporal_series/test_skewness.rs b/tests/series/temporal_series/test_skewness.rs new file mode 100644 index 0000000..5fe25c4 --- /dev/null +++ b/tests/series/temporal_series/test_skewness.rs @@ -0,0 +1,52 @@ +use temporalseries::errors::TemporalSeriesError; +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ColumnarBackend; + +type TS = TemporalSeries>; + +#[test] +#[allow(non_snake_case)] +fn test__given_symmetric_series__when_compute_skewness__then_returns_zero() { + // Given + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4, 5], + ColumnarBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]), + ) + .unwrap(); + + // When + let result: f64 = sut.skewness().unwrap(); + + // Then + assert!(result.abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_right_skewed_series__when_compute_skewness__then_returns_positive_value() { + // Given + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4, 5], + ColumnarBackend::new(vec![1.0, 1.0, 1.0, 1.0, 5.0]), + ) + .unwrap(); + + // When + let result: f64 = sut.skewness().unwrap(); + + // Then + assert!(result > 0.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_too_short_series__when_compute_skewness__then_returns_empty_series_error() { + // Given + let sut: TS = TemporalSeries::new(vec![1, 2], ColumnarBackend::new(vec![1.0, 2.0])).unwrap(); + + // When + let result: Result = sut.skewness(); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::EmptySeries))); +} diff --git a/tests/series/temporal_series/test_std_deviation.rs b/tests/series/temporal_series/test_std_deviation.rs new file mode 100644 index 0000000..22ac98c --- /dev/null +++ b/tests/series/temporal_series/test_std_deviation.rs @@ -0,0 +1,46 @@ +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ColumnarBackend; + +type TS = TemporalSeries>; + +#[test] +#[allow(non_snake_case)] +fn test__given_single_element__when_compute_std_deviation__then_returns_zero() { + // Given + let sut: TS = TemporalSeries::new(vec![1], ColumnarBackend::new(vec![42.0])).unwrap(); + + // When + let result: f64 = sut.std_deviation(); + + // Then + assert_eq!(result, 0.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_constant_series__when_compute_std_deviation__then_returns_zero() { + // Given + let sut: TS = + TemporalSeries::new(vec![1, 2, 3], ColumnarBackend::new(vec![5.0, 5.0, 5.0])).unwrap(); + + // When + let result: f64 = sut.std_deviation(); + + // Then + assert_eq!(result, 0.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_two_element_series__when_compute_std_deviation__then_returns_bessel_corrected_variance() + { + // Given + // mean = 1.5, deviations = [-0.5, 0.5], variance = (0.25+0.25)/(2-1) = 0.5 + let sut: TS = TemporalSeries::new(vec![1, 2], ColumnarBackend::new(vec![1.0, 2.0])).unwrap(); + + // When + let result: f64 = sut.std_deviation(); + + // Then + assert!((result - 0.5).abs() < 1e-9); +} diff --git a/tests/series/temporal_series/test_true_range.rs b/tests/series/temporal_series/test_true_range.rs new file mode 100644 index 0000000..31d9bad --- /dev/null +++ b/tests/series/temporal_series/test_true_range.rs @@ -0,0 +1,57 @@ +use temporalseries::errors::TemporalSeriesError; +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ColumnarBackend; + +type TS = TemporalSeries>; + +#[test] +#[allow(non_snake_case)] +fn test__given_constant_series__when_compute_true_range__then_returns_zeros() { + // Given + let sut: TS = + TemporalSeries::new(vec![1, 2, 3], ColumnarBackend::new(vec![5.0, 5.0, 5.0])).unwrap(); + + // When + let result = sut.true_range().unwrap(); + let values: Vec = result.iter().copied().collect(); + + // Then + assert!(values[0].is_nan()); + assert_eq!(values[1], 0.0); + assert_eq!(values[2], 0.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_growing_series__when_compute_true_range__then_computes_correctly() { + // Given + // |3-1|=2, |6-3|=3, |10-6|=4 + let sut: TS = TemporalSeries::new( + vec![1, 2, 3, 4], + ColumnarBackend::new(vec![1.0, 3.0, 6.0, 10.0]), + ) + .unwrap(); + + // When + let result = sut.true_range().unwrap(); + let values: Vec = result.iter().copied().collect(); + + // Then + assert!(values[0].is_nan()); + assert!((values[1] - 2.0).abs() < 1e-9); + assert!((values[2] - 3.0).abs() < 1e-9); + assert!((values[3] - 4.0).abs() < 1e-9); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_empty_series__when_compute_true_range__then_returns_empty_series_error() { + // Given + let sut: TS = TemporalSeries::new(vec![], ColumnarBackend::new(vec![])).unwrap(); + + // When + let result: Result<_, TemporalSeriesError> = sut.true_range(); + + // Then + assert!(matches!(result, Err(TemporalSeriesError::EmptySeries))); +} diff --git a/tests/series/time_series/mod.rs b/tests/series/time_series/mod.rs new file mode 100644 index 0000000..e5b180d --- /dev/null +++ b/tests/series/time_series/mod.rs @@ -0,0 +1,24 @@ +mod test_autocorrelation_function; +mod test_average_true_range; +mod test_borillenger_bads; +mod test_crossover_signal; +mod test_cumulative_return; +mod test_dickey_fuller_test; +mod test_excess_kurtosis; +mod test_exponential_moving_average; +mod test_iqr; +mod test_is_empty; +mod test_jacque_bera_test; +mod test_len; +mod test_log_return; +mod test_mean; +mod test_moving_average; +mod test_new_instance; +mod test_partial_autocorrelation_function; +mod test_pct_change; +mod test_quantile; +mod test_rolling_standard_deviation; +mod test_simple_return; +mod test_skewness; +mod test_std_deviation; +mod test_true_range; diff --git a/tests/series/test_autocorrelation_function.rs b/tests/series/time_series/test_autocorrelation_function.rs similarity index 100% rename from tests/series/test_autocorrelation_function.rs rename to tests/series/time_series/test_autocorrelation_function.rs diff --git a/tests/series/test_average_true_range.rs b/tests/series/time_series/test_average_true_range.rs similarity index 100% rename from tests/series/test_average_true_range.rs rename to tests/series/time_series/test_average_true_range.rs diff --git a/tests/series/test_borillenger_bads.rs b/tests/series/time_series/test_borillenger_bads.rs similarity index 100% rename from tests/series/test_borillenger_bads.rs rename to tests/series/time_series/test_borillenger_bads.rs diff --git a/tests/series/test_crossover_signal.rs b/tests/series/time_series/test_crossover_signal.rs similarity index 100% rename from tests/series/test_crossover_signal.rs rename to tests/series/time_series/test_crossover_signal.rs diff --git a/tests/series/test_cumulative_return.rs b/tests/series/time_series/test_cumulative_return.rs similarity index 100% rename from tests/series/test_cumulative_return.rs rename to tests/series/time_series/test_cumulative_return.rs diff --git a/tests/series/test_dickey_fuller_test.rs b/tests/series/time_series/test_dickey_fuller_test.rs similarity index 100% rename from tests/series/test_dickey_fuller_test.rs rename to tests/series/time_series/test_dickey_fuller_test.rs diff --git a/tests/series/test_excess_kurtosis.rs b/tests/series/time_series/test_excess_kurtosis.rs similarity index 100% rename from tests/series/test_excess_kurtosis.rs rename to tests/series/time_series/test_excess_kurtosis.rs diff --git a/tests/series/test_exponential_moving_average.rs b/tests/series/time_series/test_exponential_moving_average.rs similarity index 100% rename from tests/series/test_exponential_moving_average.rs rename to tests/series/time_series/test_exponential_moving_average.rs diff --git a/tests/series/test_iqr.rs b/tests/series/time_series/test_iqr.rs similarity index 100% rename from tests/series/test_iqr.rs rename to tests/series/time_series/test_iqr.rs diff --git a/tests/series/test_is_empty.rs b/tests/series/time_series/test_is_empty.rs similarity index 100% rename from tests/series/test_is_empty.rs rename to tests/series/time_series/test_is_empty.rs diff --git a/tests/series/test_jacque_bera_test.rs b/tests/series/time_series/test_jacque_bera_test.rs similarity index 100% rename from tests/series/test_jacque_bera_test.rs rename to tests/series/time_series/test_jacque_bera_test.rs diff --git a/tests/series/test_len.rs b/tests/series/time_series/test_len.rs similarity index 100% rename from tests/series/test_len.rs rename to tests/series/time_series/test_len.rs diff --git a/tests/series/test_log_return.rs b/tests/series/time_series/test_log_return.rs similarity index 100% rename from tests/series/test_log_return.rs rename to tests/series/time_series/test_log_return.rs diff --git a/tests/series/test_mean.rs b/tests/series/time_series/test_mean.rs similarity index 100% rename from tests/series/test_mean.rs rename to tests/series/time_series/test_mean.rs diff --git a/tests/series/test_moving_average.rs b/tests/series/time_series/test_moving_average.rs similarity index 100% rename from tests/series/test_moving_average.rs rename to tests/series/time_series/test_moving_average.rs diff --git a/tests/series/test_new_instance.rs b/tests/series/time_series/test_new_instance.rs similarity index 100% rename from tests/series/test_new_instance.rs rename to tests/series/time_series/test_new_instance.rs diff --git a/tests/series/test_partial_autocorrelation_function.rs b/tests/series/time_series/test_partial_autocorrelation_function.rs similarity index 100% rename from tests/series/test_partial_autocorrelation_function.rs rename to tests/series/time_series/test_partial_autocorrelation_function.rs diff --git a/tests/series/test_pct_change.rs b/tests/series/time_series/test_pct_change.rs similarity index 100% rename from tests/series/test_pct_change.rs rename to tests/series/time_series/test_pct_change.rs diff --git a/tests/series/test_quantile.rs b/tests/series/time_series/test_quantile.rs similarity index 100% rename from tests/series/test_quantile.rs rename to tests/series/time_series/test_quantile.rs diff --git a/tests/series/test_rolling_standard_deviation.rs b/tests/series/time_series/test_rolling_standard_deviation.rs similarity index 100% rename from tests/series/test_rolling_standard_deviation.rs rename to tests/series/time_series/test_rolling_standard_deviation.rs diff --git a/tests/series/test_simple_return.rs b/tests/series/time_series/test_simple_return.rs similarity index 100% rename from tests/series/test_simple_return.rs rename to tests/series/time_series/test_simple_return.rs diff --git a/tests/series/test_skewness.rs b/tests/series/time_series/test_skewness.rs similarity index 100% rename from tests/series/test_skewness.rs rename to tests/series/time_series/test_skewness.rs diff --git a/tests/series/test_std_deviation.rs b/tests/series/time_series/test_std_deviation.rs similarity index 100% rename from tests/series/test_std_deviation.rs rename to tests/series/time_series/test_std_deviation.rs diff --git a/tests/series/test_true_range.rs b/tests/series/time_series/test_true_range.rs similarity index 100% rename from tests/series/test_true_range.rs rename to tests/series/time_series/test_true_range.rs From 439d67559c3effc899e3621de9cafe2693922360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 19 Jun 2026 17:02:17 +0200 Subject: [PATCH 14/19] add examples for temporal series --- README.md | 4 +- crates/series/temporal_series.rs | 68 ++++++++++++++++++++++++++++++++ examples/dickey_fuller_test.rs | 67 ++++++++++++++++++++++++------- examples/jarque_bera_test.rs | 63 +++++++++++++++++++++++------ 4 files changed, 172 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index e9c6677..3c595dc 100644 --- a/README.md +++ b/README.md @@ -257,8 +257,8 @@ output length equal to the input length and preserves index alignment. | `temporal_series_with_row_backend` | `cargo run --example temporal_series_with_row_backend` | Builds a `TemporalSeries` with a `RowBackend` and demonstrates access and iteration | | `panel` | `cargo run --example panel` | Builds a `Panel` of named series on a shared index and extracts one series for analysis | | `temporal_series_with_chrono` | `cargo run --example temporal_series_with_chrono --features chrono` | Builds a `TemporalSeries` from `DateTime` values and round-trips the index back to calendar dates | -| `dickey_fuller_test` | `cargo run --example dickey_fuller_test` | Contrasts a trending (non-stationary) series and an alternating (stationary) series using the Dickey-Fuller test | -| `jarque_bera_test` | `cargo run --example jarque_bera_test` | Contrasts a near-symmetric series and a heavily skewed series using the Jarque-Bera normality test | +| `dickey_fuller_test` | `cargo run --example dickey_fuller_test` | Contrasts a trending vs. alternating series with the Dickey-Fuller test, shown for both `TimeSeries` and `TemporalSeries` | +| `jarque_bera_test` | `cargo run --example jarque_bera_test` | Contrasts a near-symmetric vs. heavily skewed series with the Jarque-Bera test, shown for both `TimeSeries` and `TemporalSeries` | ## Development diff --git a/crates/series/temporal_series.rs b/crates/series/temporal_series.rs index 7bb844c..aaca3ab 100644 --- a/crates/series/temporal_series.rs +++ b/crates/series/temporal_series.rs @@ -752,6 +752,40 @@ impl> TemporalSeries { /// # Errors /// /// Propagates errors from [`Self::stationary_dickey_fuller_statistics`]. + /// + /// # Examples + /// + /// A linearly trending series is **non-stationary** — the test returns `false` + /// because it cannot reject the unit-root null hypothesis: + /// + /// ```rust + /// use temporalseries::series::TemporalSeries; + /// use temporalseries::storage::ColumnarBackend; + /// + /// let trend = TemporalSeries::new( + /// vec![1_i64, 2, 3, 4, 5, 6, 7, 8], + /// ColumnarBackend::new(vec![1.0_f64, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]), + /// ).unwrap(); + /// + /// // DF statistic >> −1.95 => cannot reject unit root => non-stationary + /// assert!(!trend.stationary_dickey_fuller_test(0.05).unwrap()); + /// ``` + /// + /// A strongly mean-reverting series is **stationary** — the DF statistic is + /// deeply negative and the test returns `true`: + /// + /// ```rust + /// use temporalseries::series::TemporalSeries; + /// use temporalseries::storage::ColumnarBackend; + /// + /// let alternating = TemporalSeries::new( + /// vec![1_i64, 2, 3, 4, 5, 6, 7, 8], + /// ColumnarBackend::new(vec![1.0_f64, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0]), + /// ).unwrap(); + /// + /// // DF statistic -> -inf => unit root rejected => stationary + /// assert!(alternating.stationary_dickey_fuller_test(0.05).unwrap()); + /// ``` pub fn stationary_dickey_fuller_test(&self, alpha: f32) -> Result { let cv = match alpha { a if a <= 0.01 => -2.60, @@ -834,6 +868,40 @@ impl> TemporalSeries { /// # Errors /// /// Propagates errors from [`Self::jacque_bera_statistics`]. + /// + /// # Examples + /// + /// A near-symmetric series has a low JB statistic — normality is **not** rejected + /// and the test returns `true`: + /// + /// ```rust + /// use temporalseries::series::TemporalSeries; + /// use temporalseries::storage::ColumnarBackend; + /// + /// let normal_like = TemporalSeries::new( + /// vec![1_i64, 2, 3, 4, 5], + /// ColumnarBackend::new(vec![1.0_f64, 2.0, 3.0, 4.0, 5.0]), + /// ).unwrap(); + /// + /// // JB ≈ 0.35 < 5.991 => fail to reject normality + /// assert!(normal_like.jacque_bera_test(0.05).unwrap()); + /// ``` + /// + /// A heavily right-skewed series has a large JB statistic — normality is + /// **rejected** and the test returns `false`: + /// + /// ```rust + /// use temporalseries::series::TemporalSeries; + /// use temporalseries::storage::ColumnarBackend; + /// + /// let skewed = TemporalSeries::new( + /// vec![1_i64, 2, 3, 4, 5, 6, 7, 8, 9, 10], + /// ColumnarBackend::new(vec![1.0_f64, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 100.0]), + /// ).unwrap(); + /// + /// // JB >> 5.991 => reject normality + /// assert!(!skewed.jacque_bera_test(0.05).unwrap()); + /// ``` pub fn jacque_bera_test(&self, alpha: f32) -> Result { let cv = match alpha { a if a <= 0.01 => 9.210, diff --git a/examples/dickey_fuller_test.rs b/examples/dickey_fuller_test.rs index 0ac9a9f..eebab09 100644 --- a/examples/dickey_fuller_test.rs +++ b/examples/dickey_fuller_test.rs @@ -1,11 +1,12 @@ -//! Demonstrates the Augmented Dickey-Fuller stationarity test. +//! Demonstrates the Augmented Dickey-Fuller stationarity test on both +//! [`TimeSeries`] and [`TemporalSeries`]. //! //! A time series is *stationary* when its statistical properties (mean, //! variance, autocorrelation) do not change over time. Many forecasting //! and econometric models require stationarity as a precondition. //! -//! [`TimeSeries::stationary_dickey_fuller_test`] fits an OLS regression of -//! the form +//! Both types expose `stationary_dickey_fuller_test`, which fits an OLS +//! regression of the form //! //! ```text //! Δxₜ = γ · xₜ₋₁ + εₜ @@ -16,7 +17,7 @@ //! stationarity is concluded — when the statistic falls below the critical //! value for the chosen significance level `alpha`. //! -//! This example contrasts two series: +//! Each section contrasts two series: //! //! - A **linear trend** (always growing) — the test should return `false` //! because the mean is not constant. @@ -26,7 +27,12 @@ //! # Expected output //! //! ```text -//! Trending series → stationary: false +//! --- TimeSeries --- +//! Trending series → stationary: false +//! Alternating series → stationary: true +//! +//! --- TemporalSeries (ColumnarBackend) --- +//! Trending series → stationary: false //! Alternating series → stationary: true //! ``` //! @@ -36,26 +42,57 @@ //! cargo run --example dickey_fuller_test //! ``` -use temporalseries::series::TimeSeries; +use temporalseries::series::{TemporalSeries, TimeSeries}; +use temporalseries::storage::ColumnarBackend; fn main() { - // A linearly increasing series has a unit root — it is NOT stationary. - let trend = TimeSeries::new( + // ----------------------------------------------------------------------- + // TimeSeries + // ----------------------------------------------------------------------- + println!("--- TimeSeries ---"); + + let trend_ts = TimeSeries::new( vec![1, 2, 3, 4, 5, 6, 7, 8], vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], ) .unwrap(); + println!( + "Trending series → stationary: {}", + trend_ts.stationary_dickey_fuller_test(0.05).unwrap() + ); - let trend_stationary = trend.stationary_dickey_fuller_test(0.05).unwrap(); - println!("Trending series → stationary: {trend_stationary}"); - - // An alternating series mean-reverts strongly — it IS stationary. - let alternating = TimeSeries::new( + let alternating_ts = TimeSeries::new( vec![1, 2, 3, 4, 5, 6, 7, 8], vec![1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0], ) .unwrap(); + println!( + "Alternating series → stationary: {}", + alternating_ts.stationary_dickey_fuller_test(0.05).unwrap() + ); + + // ----------------------------------------------------------------------- + // TemporalSeries (ColumnarBackend) + // ----------------------------------------------------------------------- + println!("\n--- TemporalSeries (ColumnarBackend) ---"); + + let trend_col = TemporalSeries::new( + vec![1_i64, 2, 3, 4, 5, 6, 7, 8], + ColumnarBackend::new(vec![1.0_f64, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]), + ) + .unwrap(); + println!( + "Trending series → stationary: {}", + trend_col.stationary_dickey_fuller_test(0.05).unwrap() + ); - let alt_stationary = alternating.stationary_dickey_fuller_test(0.05).unwrap(); - println!("Alternating series → stationary: {alt_stationary}"); + let alternating_col = TemporalSeries::new( + vec![1_i64, 2, 3, 4, 5, 6, 7, 8], + ColumnarBackend::new(vec![1.0_f64, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0]), + ) + .unwrap(); + println!( + "Alternating series → stationary: {}", + alternating_col.stationary_dickey_fuller_test(0.05).unwrap() + ); } diff --git a/examples/jarque_bera_test.rs b/examples/jarque_bera_test.rs index c8e3088..5af9ad5 100644 --- a/examples/jarque_bera_test.rs +++ b/examples/jarque_bera_test.rs @@ -1,10 +1,11 @@ -//! Demonstrates the Jarque-Bera normality test. +//! Demonstrates the Jarque-Bera normality test on both [`TimeSeries`] and +//! [`TemporalSeries`]. //! //! The Jarque-Bera test checks whether a series is consistent with a normal //! distribution by measuring its skewness (`S`) and excess kurtosis (`K`). //! It is commonly applied to residuals from regression or ARIMA models. //! -//! [`TimeSeries::jacque_bera_test`] computes the statistic +//! Both types expose `jacque_bera_test`, which computes the statistic //! //! ```text //! JB = n · (S² / 6 + K² / 24) @@ -13,7 +14,7 @@ //! and compares it against the χ²(2) critical value for the chosen significance //! level `alpha`. The null hypothesis is normality. //! -//! This example contrasts two series: +//! Each section contrasts two series: //! //! - A **near-symmetric** series — the test should return `true` because the //! series does not deviate significantly from a normal distribution. @@ -23,6 +24,11 @@ //! # Expected output //! //! ```text +//! --- TimeSeries --- +//! Near-symmetric series → consistent with normality: true +//! Heavily skewed series → consistent with normality: false +//! +//! --- TemporalSeries (ColumnarBackend) --- //! Near-symmetric series → consistent with normality: true //! Heavily skewed series → consistent with normality: false //! ``` @@ -33,26 +39,57 @@ //! cargo run --example jarque_bera_test //! ``` -use temporalseries::series::TimeSeries; +use temporalseries::series::{TemporalSeries, TimeSeries}; +use temporalseries::storage::ColumnarBackend; fn main() { - // A roughly symmetric, spread-out series does not violate normality. - let normal_like = TimeSeries::new( + // ----------------------------------------------------------------------- + // TimeSeries + // ----------------------------------------------------------------------- + println!("--- TimeSeries ---"); + + let normal_ts = TimeSeries::new( vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10], vec![-2.0, -1.0, -0.5, 0.0, 0.2, 0.3, 0.5, 1.0, 1.5, 2.0], ) .unwrap(); + println!( + "Near-symmetric series → consistent with normality: {}", + normal_ts.jacque_bera_test(0.05).unwrap() + ); - let nl_normal = normal_like.jacque_bera_test(0.05).unwrap(); - println!("Near-symmetric series → consistent with normality: {nl_normal}"); - - // Nine identical values plus a massive outlier produces extreme skewness. - let skewed = TimeSeries::new( + let skewed_ts = TimeSeries::new( vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10], vec![1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 100.0], ) .unwrap(); + println!( + "Heavily skewed series → consistent with normality: {}", + skewed_ts.jacque_bera_test(0.05).unwrap() + ); + + // ----------------------------------------------------------------------- + // TemporalSeries (ColumnarBackend) + // ----------------------------------------------------------------------- + println!("\n--- TemporalSeries (ColumnarBackend) ---"); - let sk_normal = skewed.jacque_bera_test(0.05).unwrap(); - println!("Heavily skewed series → consistent with normality: {sk_normal}"); + let normal_col = TemporalSeries::new( + vec![1_i64, 2, 3, 4, 5], + ColumnarBackend::new(vec![1.0_f64, 2.0, 3.0, 4.0, 5.0]), + ) + .unwrap(); + println!( + "Near-symmetric series → consistent with normality: {}", + normal_col.jacque_bera_test(0.05).unwrap() + ); + + let skewed_col = TemporalSeries::new( + vec![1_i64, 2, 3, 4, 5, 6, 7, 8, 9, 10], + ColumnarBackend::new(vec![1.0_f64, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 100.0]), + ) + .unwrap(); + println!( + "Heavily skewed series → consistent with normality: {}", + skewed_col.jacque_bera_test(0.05).unwrap() + ); } From c6027ae7b62f6e3a5b42e684e493dde94f0fed4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 19 Jun 2026 17:12:08 +0200 Subject: [PATCH 15/19] add all implemented methods to rolling element - just call the appropiated methods from temporal/time series - add unittests --- crates/panel/core.rs | 475 ++++++++++++++++++++++ tests/panel/mod.rs | 1 + tests/panel/test_panel_methods.rs | 633 ++++++++++++++++++++++++++++++ 3 files changed, 1109 insertions(+) create mode 100644 tests/panel/test_panel_methods.rs diff --git a/crates/panel/core.rs b/crates/panel/core.rs index 2019781..2387eee 100644 --- a/crates/panel/core.rs +++ b/crates/panel/core.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use crate::errors::TemporalSeriesError; use crate::series::TimeSeries; @@ -19,6 +21,17 @@ pub type Timestamp = i64; /// /// Both invariants are enforced at construction time by [`Panel::new`]. /// +/// # Analytical methods +/// +/// Every analytical method available on [`TimeSeries`] is also available on +/// `Panel`. Methods are applied **column-by-column**: each column is wrapped +/// in a temporary `TimeSeries`, the corresponding method is called, and the +/// results are collected back into either a `HashMap` (for scalar +/// results) or a new `Panel` (for series results). +/// +/// This means `panel.mean()["AAPL"]` is always identical to +/// `panel.get_series("AAPL").unwrap().mean()`. +/// /// # Example /// /// ```rust @@ -205,4 +218,466 @@ impl Panel { let pos = self.symbols.iter().position(|s| s == symbol)?; TimeSeries::new(self.index.clone(), self.values[pos].clone()).ok() } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + /// Wraps column `col` as a [`TimeSeries`]. Panics if the Panel is invalid + /// (impossible by construction, since `Panel::new` validates lengths). + fn col_series(&self, col: usize) -> TimeSeries { + TimeSeries::new(self.index.clone(), self.values[col].clone()).unwrap() + } + + /// Applies a fallible scalar operation to every column and collects the + /// results into a `HashMap`. Returns the first error encountered. + fn scalar_map(&self, f: F) -> Result, TemporalSeriesError> + where + F: Fn(&TimeSeries) -> Result, + { + self.symbols + .iter() + .enumerate() + .map(|(i, sym)| Ok((sym.clone(), f(&self.col_series(i))?))) + .collect() + } + + /// Applies a fallible bool operation to every column and collects the + /// results into a `HashMap`. Returns the first error encountered. + fn bool_map(&self, f: F) -> Result, TemporalSeriesError> + where + F: Fn(&TimeSeries) -> Result, + { + self.symbols + .iter() + .enumerate() + .map(|(i, sym)| Ok((sym.clone(), f(&self.col_series(i))?))) + .collect() + } + + /// Applies a fallible series operation to every column and assembles the + /// results into a new `Panel` with the same index and symbols. + /// Returns the first error encountered. + fn panel_map(&self, f: F) -> Result + where + F: Fn(&TimeSeries) -> Result, + { + let mut new_values = Vec::with_capacity(self.n_series()); + for i in 0..self.n_series() { + new_values.push(f(&self.col_series(i))?.values); + } + Panel::new(self.index.clone(), self.symbols.clone(), new_values) + } + + // ----------------------------------------------------------------------- + // STATISTICS + // ----------------------------------------------------------------------- + + /// Returns the arithmetic mean of each column. + /// + /// Delegates to [`TimeSeries::mean`] for every column. + /// + /// # Example + /// + /// ```rust + /// use temporalseries::panel::Panel; + /// + /// let panel = Panel::new( + /// vec![1, 2, 3], + /// vec!["A".into()], + /// vec![vec![1.0, 2.0, 3.0]], + /// ).unwrap(); + /// + /// assert_eq!(panel.mean()["A"], 2.0); + /// ``` + pub fn mean(&self) -> HashMap { + self.symbols + .iter() + .enumerate() + .map(|(i, sym)| (sym.clone(), self.col_series(i).mean())) + .collect() + } + + /// Returns the sample variance (Bessel-corrected) of each column. + /// + /// Delegates to [`TimeSeries::std_deviation`] for every column. + /// + /// # Example + /// + /// ```rust + /// use temporalseries::panel::Panel; + /// + /// let panel = Panel::new( + /// vec![1, 2, 3], + /// vec!["A".into()], + /// vec![vec![5.0, 5.0, 5.0]], + /// ).unwrap(); + /// + /// assert_eq!(panel.std_deviation()["A"], 0.0); + /// ``` + pub fn std_deviation(&self) -> HashMap { + self.symbols + .iter() + .enumerate() + .map(|(i, sym)| (sym.clone(), self.col_series(i).std_deviation())) + .collect() + } + + /// Returns the p-th quantile of each column using linear interpolation. + /// + /// Delegates to [`TimeSeries::quantile`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn quantile(&self, p: f32) -> Result, TemporalSeriesError> { + self.scalar_map(|ts| ts.quantile(p)) + } + + /// Returns the Interquartile Range (Q3 − Q1) of each column. + /// + /// Delegates to [`TimeSeries::iqr`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn iqr(&self) -> Result, TemporalSeriesError> { + self.scalar_map(|ts| ts.iqr()) + } + + // ----------------------------------------------------------------------- + // RETURNS + // ----------------------------------------------------------------------- + + /// Returns a new `Panel` with the per-period simple return of each column. + /// + /// $$r_t = \frac{x_t - x_{t-1}}{x_{t-1}}$$ + /// + /// Delegates to [`TimeSeries::simple_return`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn simple_return(&self) -> Result { + self.panel_map(|ts| ts.simple_return()) + } + + /// Returns a new `Panel` with the per-period logarithmic return of each column. + /// + /// $$r_t^{log} = \ln\!\left(\frac{x_t}{x_{t-1}}\right)$$ + /// + /// Delegates to [`TimeSeries::log_return`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn log_return(&self) -> Result { + self.panel_map(|ts| ts.log_return()) + } + + /// Returns the total cumulative return of each column. + /// + /// $$R_{cum} = \frac{x_T - x_0}{x_0}$$ + /// + /// Delegates to [`TimeSeries::cumulative_return`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn cumulative_return(&self) -> Result, TemporalSeriesError> { + self.scalar_map(|ts| ts.cumulative_return()) + } + + /// Returns a new `Panel` with the first-order difference of each column. + /// + /// Delegates to [`TimeSeries::diff`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn diff(&self) -> Result { + self.panel_map(|ts| ts.diff()) + } + + /// Returns a new `Panel` with the percentage change of each column. + /// + /// Delegates to [`TimeSeries::pct_change`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn pct_change(&self) -> Result { + self.panel_map(|ts| ts.pct_change()) + } + + /// Returns a new `Panel` with each column shifted forward by `periods`. + /// + /// Delegates to [`TimeSeries::shift`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn shift(&self, periods: usize) -> Result { + self.panel_map(|ts| ts.shift(periods)) + } + + // ----------------------------------------------------------------------- + // MOVING AVERAGES + // ----------------------------------------------------------------------- + + /// Returns a new `Panel` with the n-period simple moving average of each column. + /// + /// $$MA_t^{(n)} = \frac{1}{n} \sum_{i=0}^{n-1} x_{t-i}$$ + /// + /// Delegates to [`TimeSeries::moving_average`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn moving_average(&self, n: usize) -> Result { + self.panel_map(|ts| ts.moving_average(n)) + } + + /// Returns a new `Panel` with the exponential moving average of each column. + /// + /// $$\alpha = \frac{2}{span + 1}, \qquad EMA_t = \alpha \cdot x_t + (1 - \alpha) \cdot EMA_{t-1}$$ + /// + /// Delegates to [`TimeSeries::exponential_moving_average`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn exponential_moving_average(&self, span: usize) -> Result { + self.panel_map(|ts| ts.exponential_moving_average(span)) + } + + /// Returns a new `Panel` with the MA crossover signal of each column. + /// + /// - `+1.0` — fast MA crosses **above** slow MA (bullish) + /// - `-1.0` — fast MA crosses **below** slow MA (bearish) + /// - `0.0` — no crossover + /// + /// Delegates to [`TimeSeries::crossover_signal`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn crossover_signal(&self, fast: usize, slow: usize) -> Result { + self.panel_map(|ts| ts.crossover_signal(fast, slow)) + } + + // ----------------------------------------------------------------------- + // VOLATILITY + // ----------------------------------------------------------------------- + + /// Returns a new `Panel` with the n-period rolling standard deviation of each column + /// (Bessel-corrected). + /// + /// $$\sigma_t^{(n)} = \sqrt{\frac{1}{n-1} \sum_{i=0}^{n-1} \left(x_{t-i} - \bar{x}_t^{(n)}\right)^2}$$ + /// + /// Delegates to [`TimeSeries::rolling_standard_deviation`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn rolling_standard_deviation(&self, n: usize) -> Result { + self.panel_map(|ts| ts.rolling_standard_deviation(n)) + } + + /// Returns a new `Panel` with the per-period true range of each column. + /// + /// $$TR_t = \left|x_t - x_{t-1}\right|$$ + /// + /// Delegates to [`TimeSeries::true_range`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn true_range(&self) -> Result { + self.panel_map(|ts| ts.true_range()) + } + + /// Returns a new `Panel` with the n-period average true range of each column. + /// + /// $$ATR_t^{(n)} = \frac{1}{n} \sum_{i=0}^{n-1} TR_{t-i}$$ + /// + /// Delegates to [`TimeSeries::average_true_range`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn average_true_range(&self, n: usize) -> Result { + self.panel_map(|ts| ts.average_true_range(n)) + } + + /// Returns Bollinger Bands across all columns as `(upper, middle, lower)` panels. + /// + /// $$BB_{upper}(t) = MA_t^{(w)} + k \cdot \sigma_t^{(w)}, \quad BB_{lower}(t) = MA_t^{(w)} - k \cdot \sigma_t^{(w)}$$ + /// + /// Delegates to [`TimeSeries::bollinger_bands`] for every column. The result + /// is three `Panel` values sharing the same index and symbols as the source. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn bollinger_bands( + &self, + window: usize, + k: f64, + ) -> Result<(Panel, Panel, Panel), TemporalSeriesError> { + let mut upper_vals = Vec::with_capacity(self.n_series()); + let mut mid_vals = Vec::with_capacity(self.n_series()); + let mut lower_vals = Vec::with_capacity(self.n_series()); + + for i in 0..self.n_series() { + let (u, m, l) = self.col_series(i).bollinger_bands(window, k)?; + upper_vals.push(u.values); + mid_vals.push(m.values); + lower_vals.push(l.values); + } + + let upper = Panel::new(self.index.clone(), self.symbols.clone(), upper_vals)?; + let mid = Panel::new(self.index.clone(), self.symbols.clone(), mid_vals)?; + let lower = Panel::new(self.index.clone(), self.symbols.clone(), lower_vals)?; + Ok((upper, mid, lower)) + } + + // ----------------------------------------------------------------------- + // AUTOCORRELATION + // ----------------------------------------------------------------------- + + /// Returns the autocorrelation function (ACF) at `lag` for each column. + /// + /// $$\rho(k) = \frac{\sum_{t=k}^{n-1}(x_t - \bar{x})(x_{t-k} - \bar{x})}{\sum_{t=0}^{n-1}(x_t - \bar{x})^2}$$ + /// + /// Delegates to [`TimeSeries::autocorrelation_function`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn autocorrelation_function( + &self, + lag: usize, + ) -> Result, TemporalSeriesError> { + self.scalar_map(|ts| ts.autocorrelation_function(lag)) + } + + /// Returns the partial autocorrelation function (PACF) at `lag` for each column + /// via Levinson-Durbin recursion. + /// + /// Delegates to [`TimeSeries::partial_autocorrelation_function`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn partial_autocorrelation_function( + &self, + lag: usize, + ) -> Result, TemporalSeriesError> { + self.scalar_map(|ts| ts.partial_autocorrelation_function(lag)) + } + + // ----------------------------------------------------------------------- + // STATIONARITY + // ----------------------------------------------------------------------- + + /// Returns the Dickey-Fuller test statistic for each column. + /// + /// Delegates to [`TimeSeries::stationary_dickey_fuller_statistics`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn stationary_dickey_fuller_statistics( + &self, + ) -> Result, TemporalSeriesError> { + self.scalar_map(|ts| ts.stationary_dickey_fuller_statistics()) + } + + /// Tests for stationarity using the Dickey-Fuller test for each column. + /// + /// Returns `true` per column when the unit-root null is rejected at `alpha`. + /// + /// | `alpha` | Critical value | + /// |---------|---------------| + /// | 0.01 | −2.60 | + /// | 0.05 | −1.95 | + /// | 0.10 | −1.61 | + /// + /// Delegates to [`TimeSeries::stationary_dickey_fuller_test`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn stationary_dickey_fuller_test( + &self, + alpha: f32, + ) -> Result, TemporalSeriesError> { + self.bool_map(|ts| ts.stationary_dickey_fuller_test(alpha)) + } + + // ----------------------------------------------------------------------- + // DISTRIBUTION ANALYSIS + // ----------------------------------------------------------------------- + + /// Returns the Fisher-Pearson skewness of each column. + /// + /// $$\text{Skew} = \frac{m_3}{m_2^{3/2}}, \quad m_k = \frac{1}{n}\sum_{i=1}^{n}(x_i - \bar{x})^k$$ + /// + /// Delegates to [`TimeSeries::skewness`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn skewness(&self) -> Result, TemporalSeriesError> { + self.scalar_map(|ts| ts.skewness()) + } + + /// Returns the excess kurtosis of each column. + /// + /// $$\kappa_{excess} = \frac{m_4}{m_2^2} - 3$$ + /// + /// Delegates to [`TimeSeries::excess_kurtosis`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn excess_kurtosis(&self) -> Result, TemporalSeriesError> { + self.scalar_map(|ts| ts.excess_kurtosis()) + } + + /// Computes the Jarque-Bera test statistic for each column. + /// + /// $$JB = n\!\left(\frac{S^2}{6} + \frac{K^2}{24}\right) \sim \chi^2(2)$$ + /// + /// Delegates to [`TimeSeries::jacque_bera_statistics`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn jacque_bera_statistics(&self) -> Result, TemporalSeriesError> { + self.scalar_map(|ts| ts.jacque_bera_statistics()) + } + + /// Tests for normality using the Jarque-Bera test for each column. + /// + /// Returns `true` per column when consistent with normality (null not rejected). + /// + /// | `alpha` | χ²(2) critical value | + /// |---------|---------------------| + /// | 0.01 | 9.210 | + /// | 0.05 | 5.991 | + /// | 0.10 | 4.605 | + /// + /// Delegates to [`TimeSeries::jacque_bera_test`] for every column. + /// + /// # Errors + /// + /// Returns the first [`TemporalSeriesError`] encountered across columns. + pub fn jacque_bera_test( + &self, + alpha: f32, + ) -> Result, TemporalSeriesError> { + self.bool_map(|ts| ts.jacque_bera_test(alpha)) + } } diff --git a/tests/panel/mod.rs b/tests/panel/mod.rs index f9e13fb..a90f923 100644 --- a/tests/panel/mod.rs +++ b/tests/panel/mod.rs @@ -1 +1,2 @@ mod test_panel; +mod test_panel_methods; diff --git a/tests/panel/test_panel_methods.rs b/tests/panel/test_panel_methods.rs new file mode 100644 index 0000000..fd6eaf9 --- /dev/null +++ b/tests/panel/test_panel_methods.rs @@ -0,0 +1,633 @@ +use temporalseries::panel::Panel; + +/// Compares two f64 slices element-wise, treating NaN as equal. +/// IEEE 754 mandates NaN != NaN, so plain `assert_eq!` would fail on any +/// series that uses NaN as a sentinel for "no value" (e.g. the first element +/// of `diff`, `simple_return`, or any rolling window result). +fn assert_f64_vecs_eq(left: &[f64], right: &[f64]) { + assert_eq!(left.len(), right.len(), "slice lengths differ"); + for (i, (l, r)) in left.iter().zip(right.iter()).enumerate() { + if l.is_nan() && r.is_nan() { + continue; + } + assert_eq!(l, r, "mismatch at index {i}"); + } +} + +/// 10-point panel with two symbols. Long enough for all analytical methods. +fn sample_panel() -> Panel { + Panel::new( + vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + vec!["AAPL".into(), "MSFT".into()], + vec![ + vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0], + vec![2.0, 1.0, 3.0, 0.5, 4.0, -1.0, 5.0, 2.5, 3.5, 1.5], + ], + ) + .unwrap() +} + +// --------------------------------------------------------------------------- +// Statistics +// --------------------------------------------------------------------------- + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_mean__then_matches_time_series_mean() { + // Given + let panel = sample_panel(); + // When + let result = panel.mean(); + // Then + assert_eq!(result["AAPL"], panel.get_series("AAPL").unwrap().mean()); + assert_eq!(result["MSFT"], panel.get_series("MSFT").unwrap().mean()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_std_deviation__then_matches_time_series_std_deviation() { + // Given + let panel = sample_panel(); + // When + let result = panel.std_deviation(); + // Then + assert_eq!( + result["AAPL"], + panel.get_series("AAPL").unwrap().std_deviation() + ); + assert_eq!( + result["MSFT"], + panel.get_series("MSFT").unwrap().std_deviation() + ); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_quantile__then_matches_time_series_quantile() { + // Given + let panel = sample_panel(); + // When + let result = panel.quantile(0.5).unwrap(); + // Then + assert_eq!( + result["AAPL"], + panel.get_series("AAPL").unwrap().quantile(0.5).unwrap() + ); + assert_eq!( + result["MSFT"], + panel.get_series("MSFT").unwrap().quantile(0.5).unwrap() + ); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_iqr__then_matches_time_series_iqr() { + // Given + let panel = sample_panel(); + // When + let result = panel.iqr().unwrap(); + // Then + assert_eq!( + result["AAPL"], + panel.get_series("AAPL").unwrap().iqr().unwrap() + ); + assert_eq!( + result["MSFT"], + panel.get_series("MSFT").unwrap().iqr().unwrap() + ); +} + +// --------------------------------------------------------------------------- +// Returns +// --------------------------------------------------------------------------- + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_simple_return__then_matches_time_series_simple_return() { + // Given + let panel = sample_panel(); + // When + let result = panel.simple_return().unwrap(); + // Then + let aapl_expected = panel.get_series("AAPL").unwrap().simple_return().unwrap(); + assert_f64_vecs_eq( + &result.get_series("AAPL").unwrap().values, + &aapl_expected.values, + ); + let msft_expected = panel.get_series("MSFT").unwrap().simple_return().unwrap(); + assert_f64_vecs_eq( + &result.get_series("MSFT").unwrap().values, + &msft_expected.values, + ); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_log_return__then_matches_time_series_log_return() { + // Given — use positive values only so log_return is well-defined + let panel = Panel::new( + vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + vec!["AAPL".into(), "MSFT".into()], + vec![ + vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0], + vec![2.0, 3.0, 5.0, 4.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0], + ], + ) + .unwrap(); + // When + let result = panel.log_return().unwrap(); + // Then + let aapl_expected = panel.get_series("AAPL").unwrap().log_return().unwrap(); + assert_f64_vecs_eq( + &result.get_series("AAPL").unwrap().values, + &aapl_expected.values, + ); + let msft_expected = panel.get_series("MSFT").unwrap().log_return().unwrap(); + assert_f64_vecs_eq( + &result.get_series("MSFT").unwrap().values, + &msft_expected.values, + ); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_cumulative_return__then_matches_time_series_cumulative_return() { + // Given + let panel = sample_panel(); + // When + let result = panel.cumulative_return().unwrap(); + // Then + assert_eq!( + result["AAPL"], + panel + .get_series("AAPL") + .unwrap() + .cumulative_return() + .unwrap() + ); + assert_eq!( + result["MSFT"], + panel + .get_series("MSFT") + .unwrap() + .cumulative_return() + .unwrap() + ); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_diff__then_matches_time_series_diff() { + // Given + let panel = sample_panel(); + // When + let result = panel.diff().unwrap(); + // Then + let aapl_expected = panel.get_series("AAPL").unwrap().diff().unwrap(); + assert_f64_vecs_eq( + &result.get_series("AAPL").unwrap().values, + &aapl_expected.values, + ); + let msft_expected = panel.get_series("MSFT").unwrap().diff().unwrap(); + assert_f64_vecs_eq( + &result.get_series("MSFT").unwrap().values, + &msft_expected.values, + ); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_pct_change__then_matches_time_series_pct_change() { + // Given + let panel = sample_panel(); + // When + let result = panel.pct_change().unwrap(); + // Then + let aapl_expected = panel.get_series("AAPL").unwrap().pct_change().unwrap(); + assert_f64_vecs_eq( + &result.get_series("AAPL").unwrap().values, + &aapl_expected.values, + ); + let msft_expected = panel.get_series("MSFT").unwrap().pct_change().unwrap(); + assert_f64_vecs_eq( + &result.get_series("MSFT").unwrap().values, + &msft_expected.values, + ); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_shift__then_matches_time_series_shift() { + // Given + let panel = sample_panel(); + // When + let result = panel.shift(2).unwrap(); + // Then + let aapl_expected = panel.get_series("AAPL").unwrap().shift(2).unwrap(); + assert_f64_vecs_eq( + &result.get_series("AAPL").unwrap().values, + &aapl_expected.values, + ); + let msft_expected = panel.get_series("MSFT").unwrap().shift(2).unwrap(); + assert_f64_vecs_eq( + &result.get_series("MSFT").unwrap().values, + &msft_expected.values, + ); +} + +// --------------------------------------------------------------------------- +// Moving averages +// --------------------------------------------------------------------------- + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_moving_average__then_matches_time_series_moving_average() { + // Given + let panel = sample_panel(); + // When + let result = panel.moving_average(3).unwrap(); + // Then + let aapl_expected = panel.get_series("AAPL").unwrap().moving_average(3).unwrap(); + assert_f64_vecs_eq( + &result.get_series("AAPL").unwrap().values, + &aapl_expected.values, + ); + let msft_expected = panel.get_series("MSFT").unwrap().moving_average(3).unwrap(); + assert_f64_vecs_eq( + &result.get_series("MSFT").unwrap().values, + &msft_expected.values, + ); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_exponential_moving_average__then_matches_time_series_ema() { + // Given + let panel = sample_panel(); + // When + let result = panel.exponential_moving_average(3).unwrap(); + // Then + let aapl_expected = panel + .get_series("AAPL") + .unwrap() + .exponential_moving_average(3) + .unwrap(); + assert_f64_vecs_eq( + &result.get_series("AAPL").unwrap().values, + &aapl_expected.values, + ); + let msft_expected = panel + .get_series("MSFT") + .unwrap() + .exponential_moving_average(3) + .unwrap(); + assert_f64_vecs_eq( + &result.get_series("MSFT").unwrap().values, + &msft_expected.values, + ); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_crossover_signal__then_matches_time_series_crossover_signal() { + // Given + let panel = sample_panel(); + // When + let result = panel.crossover_signal(2, 4).unwrap(); + // Then + let aapl_expected = panel + .get_series("AAPL") + .unwrap() + .crossover_signal(2, 4) + .unwrap(); + assert_f64_vecs_eq( + &result.get_series("AAPL").unwrap().values, + &aapl_expected.values, + ); + let msft_expected = panel + .get_series("MSFT") + .unwrap() + .crossover_signal(2, 4) + .unwrap(); + assert_f64_vecs_eq( + &result.get_series("MSFT").unwrap().values, + &msft_expected.values, + ); +} + +// --------------------------------------------------------------------------- +// Volatility +// --------------------------------------------------------------------------- + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_rolling_standard_deviation__then_matches_time_series_rolling_std() { + // Given + let panel = sample_panel(); + // When + let result = panel.rolling_standard_deviation(3).unwrap(); + // Then + let aapl_expected = panel + .get_series("AAPL") + .unwrap() + .rolling_standard_deviation(3) + .unwrap(); + assert_f64_vecs_eq( + &result.get_series("AAPL").unwrap().values, + &aapl_expected.values, + ); + let msft_expected = panel + .get_series("MSFT") + .unwrap() + .rolling_standard_deviation(3) + .unwrap(); + assert_f64_vecs_eq( + &result.get_series("MSFT").unwrap().values, + &msft_expected.values, + ); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_true_range__then_matches_time_series_true_range() { + // Given + let panel = sample_panel(); + // When + let result = panel.true_range().unwrap(); + // Then + let aapl_expected = panel.get_series("AAPL").unwrap().true_range().unwrap(); + assert_f64_vecs_eq( + &result.get_series("AAPL").unwrap().values, + &aapl_expected.values, + ); + let msft_expected = panel.get_series("MSFT").unwrap().true_range().unwrap(); + assert_f64_vecs_eq( + &result.get_series("MSFT").unwrap().values, + &msft_expected.values, + ); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_average_true_range__then_matches_time_series_average_true_range() { + // Given + let panel = sample_panel(); + // When + let result = panel.average_true_range(3).unwrap(); + // Then + let aapl_expected = panel + .get_series("AAPL") + .unwrap() + .average_true_range(3) + .unwrap(); + assert_f64_vecs_eq( + &result.get_series("AAPL").unwrap().values, + &aapl_expected.values, + ); + let msft_expected = panel + .get_series("MSFT") + .unwrap() + .average_true_range(3) + .unwrap(); + assert_f64_vecs_eq( + &result.get_series("MSFT").unwrap().values, + &msft_expected.values, + ); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_bollinger_bands__then_matches_time_series_bollinger_bands() { + // Given + let panel = sample_panel(); + // When + let (upper, mid, lower) = panel.bollinger_bands(3, 2.0).unwrap(); + // Then — AAPL + let (ts_upper, ts_mid, ts_lower) = panel + .get_series("AAPL") + .unwrap() + .bollinger_bands(3, 2.0) + .unwrap(); + assert_f64_vecs_eq(&upper.get_series("AAPL").unwrap().values, &ts_upper.values); + assert_f64_vecs_eq(&mid.get_series("AAPL").unwrap().values, &ts_mid.values); + assert_f64_vecs_eq(&lower.get_series("AAPL").unwrap().values, &ts_lower.values); + // Then — MSFT + let (ts_upper_m, ts_mid_m, ts_lower_m) = panel + .get_series("MSFT") + .unwrap() + .bollinger_bands(3, 2.0) + .unwrap(); + assert_f64_vecs_eq( + &upper.get_series("MSFT").unwrap().values, + &ts_upper_m.values, + ); + assert_f64_vecs_eq(&mid.get_series("MSFT").unwrap().values, &ts_mid_m.values); + assert_f64_vecs_eq( + &lower.get_series("MSFT").unwrap().values, + &ts_lower_m.values, + ); +} + +// --------------------------------------------------------------------------- +// Autocorrelation +// --------------------------------------------------------------------------- + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_autocorrelation_function__then_matches_time_series_acf() { + // Given + let panel = sample_panel(); + // When + let result = panel.autocorrelation_function(1).unwrap(); + // Then + assert_eq!( + result["AAPL"], + panel + .get_series("AAPL") + .unwrap() + .autocorrelation_function(1) + .unwrap() + ); + assert_eq!( + result["MSFT"], + panel + .get_series("MSFT") + .unwrap() + .autocorrelation_function(1) + .unwrap() + ); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_partial_autocorrelation_function__then_matches_time_series_pacf() { + // Given + let panel = sample_panel(); + // When + let result = panel.partial_autocorrelation_function(2).unwrap(); + // Then + assert_eq!( + result["AAPL"], + panel + .get_series("AAPL") + .unwrap() + .partial_autocorrelation_function(2) + .unwrap() + ); + assert_eq!( + result["MSFT"], + panel + .get_series("MSFT") + .unwrap() + .partial_autocorrelation_function(2) + .unwrap() + ); +} + +// --------------------------------------------------------------------------- +// Stationarity +// --------------------------------------------------------------------------- + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_stationary_dickey_fuller_statistics__then_matches_time_series_df_stat() { + // Given + let panel = sample_panel(); + // When + let result = panel.stationary_dickey_fuller_statistics().unwrap(); + // Then + assert_eq!( + result["AAPL"], + panel + .get_series("AAPL") + .unwrap() + .stationary_dickey_fuller_statistics() + .unwrap() + ); + assert_eq!( + result["MSFT"], + panel + .get_series("MSFT") + .unwrap() + .stationary_dickey_fuller_statistics() + .unwrap() + ); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_stationary_dickey_fuller_test__then_matches_time_series_df_test() { + // Given + let panel = sample_panel(); + // When + let result = panel.stationary_dickey_fuller_test(0.05).unwrap(); + // Then + assert_eq!( + result["AAPL"], + panel + .get_series("AAPL") + .unwrap() + .stationary_dickey_fuller_test(0.05) + .unwrap() + ); + assert_eq!( + result["MSFT"], + panel + .get_series("MSFT") + .unwrap() + .stationary_dickey_fuller_test(0.05) + .unwrap() + ); +} + +// --------------------------------------------------------------------------- +// Distribution analysis +// --------------------------------------------------------------------------- + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_skewness__then_matches_time_series_skewness() { + // Given + let panel = sample_panel(); + // When + let result = panel.skewness().unwrap(); + // Then + assert_eq!( + result["AAPL"], + panel.get_series("AAPL").unwrap().skewness().unwrap() + ); + assert_eq!( + result["MSFT"], + panel.get_series("MSFT").unwrap().skewness().unwrap() + ); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_excess_kurtosis__then_matches_time_series_excess_kurtosis() { + // Given + let panel = sample_panel(); + // When + let result = panel.excess_kurtosis().unwrap(); + // Then + assert_eq!( + result["AAPL"], + panel.get_series("AAPL").unwrap().excess_kurtosis().unwrap() + ); + assert_eq!( + result["MSFT"], + panel.get_series("MSFT").unwrap().excess_kurtosis().unwrap() + ); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_jacque_bera_statistics__then_matches_time_series_jb_stat() { + // Given + let panel = sample_panel(); + // When + let result = panel.jacque_bera_statistics().unwrap(); + // Then + assert_eq!( + result["AAPL"], + panel + .get_series("AAPL") + .unwrap() + .jacque_bera_statistics() + .unwrap() + ); + assert_eq!( + result["MSFT"], + panel + .get_series("MSFT") + .unwrap() + .jacque_bera_statistics() + .unwrap() + ); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_jacque_bera_test__then_matches_time_series_jb_test() { + // Given + let panel = sample_panel(); + // When + let result = panel.jacque_bera_test(0.05).unwrap(); + // Then + assert_eq!( + result["AAPL"], + panel + .get_series("AAPL") + .unwrap() + .jacque_bera_test(0.05) + .unwrap() + ); + assert_eq!( + result["MSFT"], + panel + .get_series("MSFT") + .unwrap() + .jacque_bera_test(0.05) + .unwrap() + ); +} From f68b780e482c6fff143794f1d1187f87549dfb95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 19 Jun 2026 17:14:53 +0200 Subject: [PATCH 16/19] update example and its documentation --- README.md | 2 +- examples/panel.rs | 190 ++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 175 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 3c595dc..ff298b1 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ output length equal to the input length and preserves index alignment. | `input_output` | `cargo run --example input_output` | Reads a series from `examples/input.csv`, writes it to `examples/output.csv`, and reads it back | | `temporal_series_with_columnar_backend` | `cargo run --example temporal_series_with_columnar_backend` | Builds a `TemporalSeries` with a `ColumnarBackend` and demonstrates access and iteration | | `temporal_series_with_row_backend` | `cargo run --example temporal_series_with_row_backend` | Builds a `TemporalSeries` with a `RowBackend` and demonstrates access and iteration | -| `panel` | `cargo run --example panel` | Builds a `Panel` of named series on a shared index and extracts one series for analysis | +| `panel` | `cargo run --example panel` | Builds a `Panel` of named series on a shared index and demonstrates all analytical methods (statistics, returns, moving averages, volatility, autocorrelation, stationarity, and normality) applied column-by-column | | `temporal_series_with_chrono` | `cargo run --example temporal_series_with_chrono --features chrono` | Builds a `TemporalSeries` from `DateTime` values and round-trips the index back to calendar dates | | `dickey_fuller_test` | `cargo run --example dickey_fuller_test` | Contrasts a trending vs. alternating series with the Dickey-Fuller test, shown for both `TimeSeries` and `TemporalSeries` | | `jarque_bera_test` | `cargo run --example jarque_bera_test` | Contrasts a near-symmetric vs. heavily skewed series with the Jarque-Bera test, shown for both `TimeSeries` and `TemporalSeries` | diff --git a/examples/panel.rs b/examples/panel.rs index c6027fd..7f4c119 100644 --- a/examples/panel.rs +++ b/examples/panel.rs @@ -1,15 +1,16 @@ -//! Demonstrates building and querying a [`Panel`]. +//! Demonstrates building, querying, and analysing a [`Panel`]. //! //! A `Panel` aligns multiple named time series on a single shared index. -//! This example models three trading days of closing prices for two stocks, -//! then extracts one of the series and runs `pct_change` on it. +//! Every analytical method available on [`TimeSeries`] is exposed on `Panel` +//! and applied column-by-column. Scalar results are returned as +//! `HashMap` (or `bool`), series results as a new `Panel`. //! //! # Layout //! //! ```text -//! index: [ 1, 2, 3 ] -//! AAPL: [ 150.0, 152.0, 149.0 ] -//! MSFT: [ 300.0, 305.0, 298.0 ] +//! index: [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ] +//! AAPL: [ 150.0, 152.0, 149.0, 153.0, 155.0, 154.0, 156.0, 158.0, 157.0, 160.0 ] +//! MSFT: [ 300.0, 305.0, 298.0, 310.0, 308.0, 312.0, 315.0, 313.0, 318.0, 320.0 ] //! ``` //! //! # Run @@ -21,24 +22,181 @@ use temporalseries::panel::Panel; fn main() { - let index = vec![1_i64, 2, 3]; + let index: Vec = (1..=10).collect(); let symbols = vec!["AAPL".to_string(), "MSFT".to_string()]; - let values = vec![vec![150.0, 152.0, 149.0], vec![300.0, 305.0, 298.0]]; + let values = vec![ + vec![ + 150.0, 152.0, 149.0, 153.0, 155.0, 154.0, 156.0, 158.0, 157.0, 160.0, + ], + vec![ + 300.0, 305.0, 298.0, 310.0, 308.0, 312.0, 315.0, 313.0, 318.0, 320.0, + ], + ]; - // Construction validates that every series length matches the index. let panel = Panel::new(index, symbols, values).unwrap(); - println!("Shape: {:?}", panel.shape()); // (n_timestamps, n_series) + println!("Shape: {:?}", panel.shape()); println!("Symbols: {:?}", panel.symbols()); - // Extract a named series and compute its daily returns. - let aapl = panel.get_series("AAPL").unwrap(); - let returns = aapl.pct_change().unwrap(); + // ----------------------------------------------------------------------- + // Basic access + // ----------------------------------------------------------------------- - println!("AAPL prices: {:?}", aapl.values); - // The first return is NaN — no prior observation. - println!("AAPL returns: {:?}", returns.values); + let aapl = panel.get_series("AAPL").unwrap(); + println!("\nAAPL prices: {:?}", aapl.values); // Unknown symbols return None. println!("GOOG present: {}", panel.get_series("GOOG").is_some()); + + // ----------------------------------------------------------------------- + // Statistics + // ----------------------------------------------------------------------- + + let means = panel.mean(); + println!("\n--- mean ---"); + println!("AAPL: {:.4}", means["AAPL"]); + println!("MSFT: {:.4}", means["MSFT"]); + + let stds = panel.std_deviation(); + println!("\n--- std_deviation ---"); + println!("AAPL: {:.4}", stds["AAPL"]); + println!("MSFT: {:.4}", stds["MSFT"]); + + let medians = panel.quantile(0.5).unwrap(); + println!("\n--- quantile(0.5) ---"); + println!("AAPL: {:.4}", medians["AAPL"]); + println!("MSFT: {:.4}", medians["MSFT"]); + + let iqrs = panel.iqr().unwrap(); + println!("\n--- iqr ---"); + println!("AAPL: {:.4}", iqrs["AAPL"]); + println!("MSFT: {:.4}", iqrs["MSFT"]); + + // ----------------------------------------------------------------------- + // Returns + // ----------------------------------------------------------------------- + + let simple = panel.simple_return().unwrap(); + println!("\n--- simple_return ---"); + println!("AAPL: {:?}", simple.get_series("AAPL").unwrap().values); + + let log_ret = panel.log_return().unwrap(); + println!("\n--- log_return ---"); + println!("AAPL: {:?}", log_ret.get_series("AAPL").unwrap().values); + + let cum_ret = panel.cumulative_return().unwrap(); + println!("\n--- cumulative_return ---"); + println!("AAPL: {:.4}", cum_ret["AAPL"]); + println!("MSFT: {:.4}", cum_ret["MSFT"]); + + let diff = panel.diff().unwrap(); + println!("\n--- diff ---"); + println!("AAPL: {:?}", diff.get_series("AAPL").unwrap().values); + + let pct = panel.pct_change().unwrap(); + println!("\n--- pct_change ---"); + println!("AAPL: {:?}", pct.get_series("AAPL").unwrap().values); + + let shifted = panel.shift(1).unwrap(); + println!("\n--- shift(1) ---"); + println!("AAPL: {:?}", shifted.get_series("AAPL").unwrap().values); + + // ----------------------------------------------------------------------- + // Moving averages + // ----------------------------------------------------------------------- + + let ma = panel.moving_average(3).unwrap(); + println!("\n--- moving_average(3) ---"); + println!("AAPL: {:?}", ma.get_series("AAPL").unwrap().values); + + let ema = panel.exponential_moving_average(3).unwrap(); + println!("\n--- exponential_moving_average(3) ---"); + println!("AAPL: {:?}", ema.get_series("AAPL").unwrap().values); + + let signals = panel.crossover_signal(2, 5).unwrap(); + println!("\n--- crossover_signal(fast=2, slow=5) ---"); + println!("AAPL: {:?}", signals.get_series("AAPL").unwrap().values); + + // ----------------------------------------------------------------------- + // Volatility + // ----------------------------------------------------------------------- + + let rolling_std = panel.rolling_standard_deviation(3).unwrap(); + println!("\n--- rolling_standard_deviation(3) ---"); + println!("AAPL: {:?}", rolling_std.get_series("AAPL").unwrap().values); + + let tr = panel.true_range().unwrap(); + println!("\n--- true_range ---"); + println!("AAPL: {:?}", tr.get_series("AAPL").unwrap().values); + + let atr = panel.average_true_range(3).unwrap(); + println!("\n--- average_true_range(3) ---"); + println!("AAPL: {:?}", atr.get_series("AAPL").unwrap().values); + + let (bb_upper, bb_mid, bb_lower) = panel.bollinger_bands(3, 2.0).unwrap(); + println!("\n--- bollinger_bands(window=3, k=2.0) ---"); + println!( + "AAPL upper: {:?}", + bb_upper.get_series("AAPL").unwrap().values + ); + println!( + "AAPL mid: {:?}", + bb_mid.get_series("AAPL").unwrap().values + ); + println!( + "AAPL lower: {:?}", + bb_lower.get_series("AAPL").unwrap().values + ); + + // ----------------------------------------------------------------------- + // Autocorrelation + // ----------------------------------------------------------------------- + + let acf = panel.autocorrelation_function(1).unwrap(); + println!("\n--- autocorrelation_function(lag=1) ---"); + println!("AAPL: {:.4}", acf["AAPL"]); + println!("MSFT: {:.4}", acf["MSFT"]); + + let pacf = panel.partial_autocorrelation_function(2).unwrap(); + println!("\n--- partial_autocorrelation_function(lag=2) ---"); + println!("AAPL: {:.4}", pacf["AAPL"]); + println!("MSFT: {:.4}", pacf["MSFT"]); + + // ----------------------------------------------------------------------- + // Stationarity (Dickey-Fuller) + // ----------------------------------------------------------------------- + + let df_stat = panel.stationary_dickey_fuller_statistics().unwrap(); + println!("\n--- stationary_dickey_fuller_statistics ---"); + println!("AAPL stat: {:.4}", df_stat["AAPL"]); + println!("MSFT stat: {:.4}", df_stat["MSFT"]); + + let df_test = panel.stationary_dickey_fuller_test(0.05).unwrap(); + println!("\n--- stationary_dickey_fuller_test(alpha=0.05) ---"); + println!("AAPL stationary: {}", df_test["AAPL"]); + println!("MSFT stationary: {}", df_test["MSFT"]); + + // ----------------------------------------------------------------------- + // Distribution analysis (Jarque-Bera) + // ----------------------------------------------------------------------- + + let skew = panel.skewness().unwrap(); + println!("\n--- skewness ---"); + println!("AAPL: {:.4}", skew["AAPL"]); + println!("MSFT: {:.4}", skew["MSFT"]); + + let kurt = panel.excess_kurtosis().unwrap(); + println!("\n--- excess_kurtosis ---"); + println!("AAPL: {:.4}", kurt["AAPL"]); + println!("MSFT: {:.4}", kurt["MSFT"]); + + let jb_stat = panel.jacque_bera_statistics().unwrap(); + println!("\n--- jacque_bera_statistics ---"); + println!("AAPL JB stat: {:.4}", jb_stat["AAPL"]); + println!("MSFT JB stat: {:.4}", jb_stat["MSFT"]); + + let jb_test = panel.jacque_bera_test(0.05).unwrap(); + println!("\n--- jacque_bera_test(alpha=0.05) ---"); + println!("AAPL normal: {}", jb_test["AAPL"]); + println!("MSFT normal: {}", jb_test["MSFT"]); } From 86b677196a00ea3aa75bd1f8c064cbbf4af2144d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 19 Jun 2026 17:15:53 +0200 Subject: [PATCH 17/19] update list of features --- README.md | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ff298b1..4a59b01 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,47 @@ A Rust library for quantitative time-series analysis. ## Features -- Percentage change (`pct_change`) +**Series types** +- `TimeSeries` — concrete series with a `Vec` index and `Vec` values +- `TemporalSeries` — generic over value type and storage backend (`ColumnarBackend`, `RowBackend`) +- `Panel` — collection of named series on a shared index; all analytical methods applied column-by-column + +**Statistics** +- Arithmetic mean (`mean`) +- Sample standard deviation (`std_deviation`) +- Quantile with linear interpolation (`quantile`) +- Interquartile range (`iqr`) + +**Returns & transformations** +- Simple return (`simple_return`) +- Logarithmic return (`log_return`) +- Cumulative return (`cumulative_return`) - First-order difference (`diff`) +- Percentage change (`pct_change`) - Lag / forward shift (`shift`) -- Rolling window mean (`rolling().mean()`) + +**Moving averages** +- Simple moving average (`moving_average`) +- Exponential moving average (`exponential_moving_average`) +- MA crossover signal (`crossover_signal`) + +**Volatility** +- Rolling standard deviation (`rolling_standard_deviation`) +- True range (`true_range`) +- Average true range (`average_true_range`) +- Bollinger Bands (`bollinger_bands`) + +**Autocorrelation** +- Autocorrelation function (`autocorrelation_function`) +- Partial autocorrelation via Levinson-Durbin (`partial_autocorrelation_function`) + +**Statistical tests** +- Augmented Dickey-Fuller stationarity test (`stationary_dickey_fuller_test`) +- Jarque-Bera normality test (`jacque_bera_test`) + +**Distribution** +- Fisher-Pearson skewness (`skewness`) +- Excess kurtosis (`excess_kurtosis`) ## Usage From 1f80fb83588c05dd56090d5ce205653ba62dce4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 19 Jun 2026 17:22:58 +0200 Subject: [PATCH 18/19] update documents for next version (v.0.1.2) --- ARCHITECTURE.md | 268 ++++++++++++++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 86 ++++++++++++++++ CONTRIBUTING.md | 64 ++++++++++++ ROADMAP.md | 1 + 4 files changed, 419 insertions(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e69de29..00c9353 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -0,0 +1,268 @@ +# Architecture + +## Overview + +`temporalseries` is a single Rust crate that exposes three public types — +`TimeSeries`, `TemporalSeries`, and `Panel` — and a shared analytical +method surface that works identically across all three. The design is built +around two principles: **separation of concerns at every layer**, and +**testability as a first-class constraint**. + +--- + +## Module layout + +``` +crates/ +├── lib.rs # public re-exports only; no logic +├── errors/ +│ └── types.rs # TemporalSeriesError — all error variants live here +├── storage/ +│ ├── backend.rs # StorageBackend trait +│ ├── columnar.rs # ColumnarBackend — contiguous Vec +│ ├── row.rs # RowBackend — Vec> +│ └── chunked.rs # ChunkedBackend — future work +├── series/ +│ ├── time_series.rs # TimeSeries — concrete Vec/Vec series +│ └── temporal_series.rs # TemporalSeries — generic over T and backend +├── panel/ +│ └── core.rs # Panel — named columns on a shared index +├── rolling/ +│ └── core.rs # RollingSeries — lazy rolling-window handle +├── io/ +│ └── csv.rs # read_csv / write_csv +└── time/ + └── unit.rs # TimeUnit enum (chrono feature) + +tests/ +├── integration.rs # single test binary entry point +├── errors/ # one file per TemporalSeriesError variant +├── io/ # read_csv / write_csv +├── rolling/ # rolling().mean() +├── series/ +│ ├── time_series/ # one file per TimeSeries method +│ └── temporal_series/ # one file per TemporalSeries method +├── panel/ +│ ├── test_panel.rs # Panel construction and structural methods +│ └── test_panel_methods.rs # all analytical methods on Panel +├── storage/ # ColumnarBackend and RowBackend +└── time/ # TimeUnit (chrono feature) +``` + +The test tree is a direct mirror of the source tree. Every crate module has a +corresponding test directory; every type has a corresponding test file per +method. Finding the test for a method is always a predictable path lookup, never +a search. + +--- + +## Layer diagram + +``` +┌─────────────────────────────────────────────────────┐ +│ Panel │ +│ delegates column-by-column to TimeSeries │ +└───────────────────────────┬─────────────────────────┘ + │ calls + ┌──────────────────┴──────────────────┐ + │ TimeSeries │ + │ Vec index + Vec values │ + │ owns all analytical logic │ + └──────────────────┬──────────────────┘ + │ mirrors API via + ┌──────────────────┴──────────────────┐ + │ TemporalSeries │ + │ generic over StorageBackend │ + │ delegates to TimeSeries internally │ + └──────────────────┬──────────────────┘ + │ backed by + ┌──────────────────┴──────────────────┐ + │ StorageBackend trait │ + │ ColumnarBackend │ RowBackend │ + └─────────────────────────────────────┘ +``` + +Each layer only depends on the layer immediately below it. `Panel` never touches +`StorageBackend`. `TemporalSeries` never knows about `Panel`. Errors flow up +through `TemporalSeriesError`, which is the only cross-cutting dependency. + +--- + +## `StorageBackend` — the decoupling seam + +```rust +pub trait StorageBackend: Send + Sync { + fn len(&self) -> usize; + fn get(&self, index: usize) -> Option<&T>; + fn iter(&self) -> impl Iterator; + // ... +} +``` + +`TemporalSeries` is generic over any `B: StorageBackend`. This means: + +- `ColumnarBackend` — contiguous `Vec`, best for column-scan workloads. +- `RowBackend` — `Vec>`, natural for record-at-a-time ingestion. +- Any future backend (chunked, memory-mapped, lazy) drops in without touching + any analytical code or any test. + +The trait boundary is the only contract between the storage layer and the series +layer. Nothing else bleeds across. + +--- + +## Analytical method ownership + +All analytical logic lives in exactly one place: `TimeSeries`. The other two +types are **delegation wrappers**, not reimplementations. + +### `TemporalSeries` + +Methods are defined on `impl> TemporalSeries`. +Because `TemporalSeries` has no `Vec` field, it extracts values once via +`self.iter().copied().collect()` and forwards to the same algorithm as +`TimeSeries`. Series-returning methods always produce a concrete `ColSeries` +(= `TemporalSeries>`) rather than a generic `B`, +since there is no way to construct an arbitrary backend from a `Vec` +without coupling the trait to its implementors. + +### `Panel` + +Three private helpers keep every public method a one-liner: + +| Helper | Purpose | +|---|---| +| `col_series(i)` | wraps column `i` as a `TimeSeries` | +| `scalar_map(f)` | applies `f` to each column, collects into `HashMap` | +| `bool_map(f)` | same for `bool` results | +| `panel_map(f)` | applies `f` to each column, assembles results into a new `Panel` | + +`bollinger_bands` is the only exception — it returns three panels and handles +the triple manually. Every other method is a single `self.scalar_map(...)` or +`self.panel_map(...)` call. + +--- + +## Error handling + +`TemporalSeriesError` is the single error type for the entire library: + +``` +TemporalSeriesError +├── LengthMismatch { index_len, values_len } +├── EmptySeries +├── InvalidWindow { window, series_len } +├── IoError(std::io::Error) +└── ParseError(String) +``` + +All fallible public methods return `Result<_, TemporalSeriesError>`. There are +no panics in library code — all `unwrap` calls are confined to test bodies and +examples. This makes the error surface predictable and exhaustively testable: +each variant has its own test file under `tests/errors/`. + +--- + +## How the architecture enables full testing + +### One-to-one source/test mapping + +Every source module has a direct test counterpart: + +``` +crates/series/time_series.rs + → tests/series/time_series/test_mean.rs + → tests/series/time_series/test_std_deviation.rs + → ... (one file per method) + +crates/series/temporal_series.rs + → tests/series/temporal_series/test_mean.rs + → ... + +crates/panel/core.rs + → tests/panel/test_panel.rs (structural) + → tests/panel/test_panel_methods.rs (analytical) +``` + +Adding a new method means adding a new test file with a known name in a known +location. There is no discovery problem. + +### `Panel` tests verify delegation, not logic + +`Panel` holds no analytical logic of its own. Its tests therefore do not +duplicate the mathematical assertions from the `TimeSeries` tests — they verify +only that delegation is wired correctly: + +```rust +assert_eq!( + panel.mean()["AAPL"], + panel.get_series("AAPL").unwrap().mean() +); +``` + +If the underlying `TimeSeries::mean` is correct (verified by its own tests) and +the Panel's routing is correct (verified by this assertion), the system is +correct. No mathematical property needs to be tested twice. + +### `TemporalSeries` tests verify the generic path + +`TemporalSeries` tests use `ColumnarBackend` as the concrete backend — not +because the tests are backend-specific, but because any `StorageBackend` +implementation exercises the same generic path. If a new backend is added, it +only needs to satisfy the `StorageBackend` contract; all analytical correctness +is already covered. + +### Isolated error tests + +Each `TemporalSeriesError` variant is tested in isolation: + +``` +tests/errors/test_length_mismatch.rs → LengthMismatch +tests/errors/test_empty_series.rs → EmptySeries +tests/errors/test_invalid_window.rs → InvalidWindow +tests/errors/test_io_error.rs → IoError +tests/errors/test_parse_error.rs → ParseError +``` + +Each file covers `Display`, `Debug`, and `std::error::Error::source()`. Error +behaviour is a contract, and it is tested like one. + +### Single integration binary + +All tests compile into a single binary via `tests/integration.rs`, which +declares one `mod` per test directory. This keeps compilation fast, avoids +linker overhead from many small test binaries, and makes `cargo test` output a +flat list that mirrors the module hierarchy: + +``` +test series::time_series::test_mean::... +test series::temporal_series::test_mean::... +test panel::test_panel_methods::... +``` + +--- + +## Optional features + +The `chrono` feature is the only optional dependency. It gates two methods on +`TemporalSeries` (`from_datetimes`, `datetimes`) and one test module +(`tests/time/`). The `TimeUnit` type and `with_unit`/`time_unit` accessors are +always compiled in, so the feature boundary is narrow: only the `DateTime` +conversion crosses it. This keeps the default build free of heavy dependencies +while making the feature easy to test in isolation with `--features chrono`. + +--- + +## Invariants enforced at the boundary + +- `TimeSeries::new` — rejects `index.len() != values.len()`. +- `Panel::new` — rejects `symbols.len() != values.len()` and any series whose + length differs from the index. +- `TemporalSeries::new` — rejects `index.len() != backend.len()`. + +Once constructed, all three types are internally consistent. No method needs to +re-validate lengths; the invariant is established once and trusted everywhere +downstream. This is the standard Rust pattern of making invalid state +unrepresentable, and it is what allows test bodies to call `.unwrap()` on +construction without it being sloppy — the construction is the test of the +boundary, and the rest of the test exercises behaviour, not validation. diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ca0cc..85cb1fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,92 @@ All notable changes to this project will be documented in this file. The format is loosely based on Keep a Changelog and Semantic Versioning. +## [0.1.2] - 2026-06-19 + +### Added + +#### `TemporalSeries` analytical methods + +- Full analytical impl block on `TemporalSeries` (for any `StorageBackend`), + matching every method on `TimeSeries`: + `mean`, `std_deviation`, `quantile`, `iqr`, + `simple_return`, `log_return`, `cumulative_return`, + `moving_average`, `exponential_moving_average`, `crossover_signal`, + `rolling_standard_deviation`, `true_range`, `average_true_range`, `bollinger_bands`, + `autocorrelation_function`, `partial_autocorrelation_function`, + `stationary_dickey_fuller_statistics`, `stationary_dickey_fuller_test`, + `skewness`, `excess_kurtosis`, `jacque_bera_statistics`, `jacque_bera_test`. +- `ColSeries` type alias (`TemporalSeries>`) — the concrete + return type for series-returning methods on `TemporalSeries`. +- `# Examples` doc sections added to `stationary_dickey_fuller_test` and + `jacque_bera_test` on `TemporalSeries`. + +#### `Panel` analytical methods + +- All analytical methods implemented on `Panel`, delegating column-by-column to + `TimeSeries`. Scalar results returned as `HashMap` (or `bool`); + series results returned as a new `Panel`: + `mean`, `std_deviation`, `quantile`, `iqr`, + `simple_return`, `log_return`, `cumulative_return`, `diff`, `pct_change`, `shift`, + `moving_average`, `exponential_moving_average`, `crossover_signal`, + `rolling_standard_deviation`, `true_range`, `average_true_range`, + `bollinger_bands` (returns `(Panel, Panel, Panel)`), + `autocorrelation_function`, `partial_autocorrelation_function`, + `stationary_dickey_fuller_statistics`, `stationary_dickey_fuller_test`, + `skewness`, `excess_kurtosis`, `jacque_bera_statistics`, `jacque_bera_test`. +- Private helpers `col_series`, `scalar_map`, `bool_map`, `panel_map` on `Panel` + to eliminate boilerplate across all method implementations. + +#### Test suite + +- `tests/series/temporal_series/` — 20 unit test files covering every analytical + method on `TemporalSeries` with `ColumnarBackend`. +- `tests/panel/test_panel_methods.rs` — 25 unit tests verifying that every + `Panel` method produces results identical to calling the corresponding + `TimeSeries` method directly on each column. +- `assert_f64_vecs_eq` helper in the panel test module — element-wise `Vec` + comparison that treats `NaN` as equal (needed for methods that use `NaN` as + a no-value sentinel at leading positions). + +#### Examples + +- `dickey_fuller_test` — expanded to show the Dickey-Fuller test on both + `TimeSeries` and `TemporalSeries` (with `ColumnarBackend`). +- `jarque_bera_test` — expanded to show the Jarque-Bera test on both + `TimeSeries` and `TemporalSeries` (with `ColumnarBackend`). +- `panel` — expanded to demonstrate all 25 analytical methods grouped by + category (statistics, returns, moving averages, volatility, autocorrelation, + stationarity, distribution analysis). + +#### Documentation + +- `README.md` Features section rewritten to enumerate all methods organised + by category across all three types. +- `README.md` Statistical Tests section added, documenting the Dickey-Fuller + and Jarque-Bera tests with formulas, critical-value tables, and examples. +- Examples table updated with expanded descriptions for `panel`, + `dickey_fuller_test`, and `jarque_bera_test`. + +### Changed + +- Test directory restructured to mirror crate layout: + `tests/series/time_series/` for `TimeSeries` tests and + `tests/series/temporal_series/` for `TemporalSeries` tests, + both nested under `tests/series/`. + +### Fixed + +- Five `cargo clippy` warnings in `crates/series/time_series.rs`: + - `needless_return` in `std_deviation` — converted `if/else` to expression form. + - `manual_range_contains` in `quantile` — `p < 0.0 || p > 1.0` replaced with + `!(0.0..=1.0).contains(&p)`. + - `needless_range_loop` in `crossover_signal` — index loop replaced with + `signals.iter_mut().enumerate().skip(1)`. + - `needless_range_loop` in `rolling_standard_deviation` — index loop replaced + with `result.iter_mut().enumerate().skip(n - 1)`. + +--- + ## [0.1.1] - 2026-06-05 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e69de29..c57cc06 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -0,0 +1,64 @@ +# Contributing + +Thank you for your interest in contributing to `temporalseries`. + +## Getting started + +```bash +git clone https://github.com/manuelgijon/temporalseries +cd temporalseries +cargo build +cargo test +``` + +## Before opening a pull request + +```bash +# All tests must pass +cargo test + +# No clippy warnings +cargo clippy -- -D warnings + +# Doc examples must compile and pass +cargo test --doc + +# If you touched any public API, check the rendered docs +cargo doc --open +``` + +## Adding a new method + +`TimeSeries`, `TemporalSeries`, and `Panel` share the same analytical surface. +When you add a method to one, add it to the others too: + +1. **`crates/series/time_series.rs`** — implement the method on `TimeSeries`. +2. **`crates/series/temporal_series.rs`** — implement the same method on + `impl> TemporalSeries`. +3. **`crates/panel/core.rs`** — delegate to `TimeSeries` via `self.col_series(i)` + and the appropriate private helper (`scalar_map`, `bool_map`, `panel_map`). +4. Add a `# Examples` doc section to each new public method. +5. Add unit tests under `tests/series/time_series/`, `tests/series/temporal_series/`, + and `tests/panel/` following the existing `test__given_X__when_Y__then_Z` naming + convention and `// Given / // When / // Then` comment structure. + +## Test conventions + +- Test function names follow `test__given_X__when_Y__then_Z`. +- Each test file is named `test_.rs` and declared as a `mod` in the + parent `mod.rs`. +- All test functions are annotated with `#[allow(non_snake_case)]`. +- Use `// Given`, `// When`, `// Then` comments to structure each test body. +- When comparing `Vec` results that may contain `NaN` sentinels, use the + `assert_f64_vecs_eq` helper (see `tests/panel/test_panel_methods.rs`) rather + than plain `assert_eq!`. + +## Commit style + +Short imperative subject line (≤ 72 characters), no trailing period. +Add a blank line and a brief body when the motivation is not obvious from the +diff. Reference issues with `Fixes #N` or `Closes #N`. + +## License + +By contributing you agree that your work will be released under the MIT licence. diff --git a/ROADMAP.md b/ROADMAP.md index e69de29..437766d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -0,0 +1 @@ +# Roadmap From 5adadefe0af519cdacf04ecd9a4be401b769c7e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 19 Jun 2026 17:24:27 +0200 Subject: [PATCH 19/19] avoid trigger when only *.md are updated --- .github/workflows/bench.yml | 2 ++ .github/workflows/ci.yml | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index e438b52..79f86f8 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -3,6 +3,8 @@ name: Benchmarks on: push: branches: [main] + paths-ignore: + - "**/*.md" env: CARGO_TERM_COLOR: always diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5bf819..8266430 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,12 @@ name: CI on: push: branches: [main] + paths-ignore: + - "**/*.md" pull_request: branches: [main] + paths-ignore: + - "**/*.md" env: CARGO_TERM_COLOR: always