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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[build]
rustdocflags = ["--html-in-header", "docs/katex-header.html"]
2 changes: 2 additions & 0 deletions .github/workflows/bench.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ name: Benchmarks
on:
push:
branches: [main]
paths-ignore:
- "**/*.md"

env:
CARGO_TERM_COLOR: always
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
268 changes: 268 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
# Architecture

## Overview

`temporalseries` is a single Rust crate that exposes three public types —
`TimeSeries`, `TemporalSeries<T, B>`, 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<T> trait
│ ├── columnar.rs # ColumnarBackend<T> — contiguous Vec<T>
│ ├── row.rs # RowBackend<T> — Vec<RowRecord<T>>
│ └── chunked.rs # ChunkedBackend<T> — future work
├── series/
│ ├── time_series.rs # TimeSeries — concrete Vec<i64>/Vec<f64> series
│ └── temporal_series.rs # TemporalSeries<T,B> — 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<i64> index + Vec<f64> values │
│ owns all analytical logic │
└──────────────────┬──────────────────┘
│ mirrors API via
┌──────────────────┴──────────────────┐
│ TemporalSeries<f64, B> │
│ generic over StorageBackend<f64> │
│ delegates to TimeSeries internally │
└──────────────────┬──────────────────┘
│ backed by
┌──────────────────┴──────────────────┐
│ StorageBackend<T> 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<T>` — the decoupling seam

```rust
pub trait StorageBackend<T>: Send + Sync {
fn len(&self) -> usize;
fn get(&self, index: usize) -> Option<&T>;
fn iter(&self) -> impl Iterator<Item = &T>;
// ...
}
```

`TemporalSeries<T, B>` is generic over any `B: StorageBackend<T>`. This means:

- `ColumnarBackend<T>` — contiguous `Vec<T>`, best for column-scan workloads.
- `RowBackend<T>` — `Vec<RowRecord<T>>`, 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<f64, B>`

Methods are defined on `impl<B: StorageBackend<f64>> TemporalSeries<f64, B>`.
Because `TemporalSeries` has no `Vec<f64>` 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<f64, ColumnarBackend<f64>>`) rather than a generic `B`,
since there is no way to construct an arbitrary backend from a `Vec<f64>`
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<String, f64>` |
| `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<f64>`
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<Utc>`
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.
86 changes: 86 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<f64, B>` (for any `StorageBackend<f64>`),
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<f64, ColumnarBackend<f64>>`) — 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<String, f64>` (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<f64>`
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
Expand Down
Loading
Loading