perf: bit-exact batch fast paths + streaming-first benchmark docs (#202)
## Summary - Dedicated batch fast paths for **EMA, RSI, Bollinger, MACD and ATR** (used by the Python bindings): one allocation filled in a single pass, warmup encoded as `NaN`, no per-element `Option` or input re-validation. Each is **bit-for-bit equal** to replaying `update` — SMA/Bollinger keep the drift-reseed cadence, the EMA-family keep the seed division and `mul_add` recurrences. Adds the `BatchNanExt` extension trait. - **Cross-library benchmark refresh**: `compare_libraries.py` reports the median across timing rounds (`--rounds` / `--streaming-rounds`), gains `--skip-batch` / `--skip-streaming`, and runs every peer through the streaming arena (recompute for batch-only libraries). `wickra-bench` drives the batch fast paths against `kand`. - **README** benchmark section reordered streaming-first (the order-of-magnitude result), with measured TA-Lib/tulipy/pandas-ta numbers in place of the CI-only placeholders. ## Impact - Python batch ~2× faster on EMA/RSI/MACD/ATR; streaming path unchanged. - The `batch == streaming` equivalence stays bit-exact. ## Verification - `cargo fmt` · `cargo clippy --workspace --all-targets --all-features -- -D warnings` (clean) - `cargo test --workspace --all-features` — 3782 unit + 420 doc tests pass - Python `pytest` — streaming-vs-batch, known-values, input-validation, smoke pass ## Notes - Node/WASM bindings keep their existing batch; the fast paths are Python-only for now.
This commit is contained in:
@@ -0,0 +1,96 @@
|
|||||||
|
# Benchmarks
|
||||||
|
|
||||||
|
Read these as **relative** speedups on identical input — absolute µs depend on
|
||||||
|
CPU, memory clock and OS scheduler, not a universal contract. **Streaming is the
|
||||||
|
headline**: it is where Wickra's design pays off and where the gap is measured in
|
||||||
|
orders of magnitude, not percent. The batch numbers come second and are shown
|
||||||
|
honestly — the leanest crates edge Wickra out on the simple recurrences, and that
|
||||||
|
is a deliberate trade for warmup/NaN semantics, not a ceiling.
|
||||||
|
|
||||||
|
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
|
||||||
|
Rust 1.92 (release: `lto = "fat"`, `codegen-units = 1`), Python 3.12.
|
||||||
|
- **Reproduce yourself:**
|
||||||
|
- Rust core vs Rust crates: `cargo bench -p wickra-bench`
|
||||||
|
- Python vs Python libs: `pip install -e bindings/python[bench]` then
|
||||||
|
`python -m benchmarks.compare_libraries` (auto-detects installed peers).
|
||||||
|
|
||||||
|
## 1. Streaming — the structural win
|
||||||
|
|
||||||
|
Live trading feeds one tick at a time. Wickra updates every indicator in **O(1)**;
|
||||||
|
batch-only libraries (TA-Lib, tulipy, finta, pandas-ta) have no incremental API
|
||||||
|
and must recompute the whole history on every tick. Only `talipp` (Python) and
|
||||||
|
`ta-rs` / `yata` (Rust) carry real per-tick state. This is the gap the library
|
||||||
|
was built to expose.
|
||||||
|
|
||||||
|
**Python — per-tick latency** (seed 5 000 bars, then feed ticks one at a time):
|
||||||
|
|
||||||
|
| Indicator | **★ Wickra** | talipp | TA-Lib (recompute) |
|
||||||
|
|------------------|------------------:|------------------|-----------------------|
|
||||||
|
| SMA(20) | **0.063 µs ★** | 0.59 µs (9×) | 204 µs (3 300×) |
|
||||||
|
| EMA(20) | **0.060 µs ★** | 0.72 µs (12×) | 212 µs (3 500×) |
|
||||||
|
| RSI(14) | **0.065 µs ★** | 1.06 µs (16×) | 230 µs (3 600×) |
|
||||||
|
| MACD(12, 26, 9) | **0.078 µs ★** | 4.22 µs (54×) | 245 µs (3 100×) |
|
||||||
|
| Bollinger(20, 2) | **0.088 µs ★** | 5.15 µs (58×) | 229 µs (2 600×) |
|
||||||
|
|
||||||
|
Against the only other incremental Python peer Wickra is **9–58× faster**;
|
||||||
|
against the recompute-on-every-tick libraries it is **2 600–14 000× faster**
|
||||||
|
(`finta` RSI hits 14 000×). tulipy / pandas-ta land in the same recompute band
|
||||||
|
as TA-Lib.
|
||||||
|
|
||||||
|
**Rust — per-tick latency** (whole 50 000-bar series, lower = faster):
|
||||||
|
|
||||||
|
| Indicator | **★ Wickra** | kand | ta-rs | yata |
|
||||||
|
|------------------|------------------:|-----:|------:|-----:|
|
||||||
|
| SMA(20) | 50 | 38 | 47 | 38 |
|
||||||
|
| EMA(20) | 154 | 69 | 56 | 69 |
|
||||||
|
| RSI(14) | 164 | 216 | 74 | — |
|
||||||
|
| MACD(12, 26, 9) | 275 | 143 | 66 | — |
|
||||||
|
| Bollinger(20, 2) | **128 ★** | 248 | 168 | — |
|
||||||
|
| ATR(14) | 152 | 166 | 61 | — |
|
||||||
|
|
||||||
|
`ta-rs` hands back a bare `f64` from the first tick with no warmup and no
|
||||||
|
validation; it leads several rows by giving those guarantees up. Against `kand`,
|
||||||
|
Wickra wins streaming RSI, Bollinger and ATR. `yata` exposes only SMA/EMA as
|
||||||
|
raw-value methods, so its other rows are omitted rather than faked.
|
||||||
|
|
||||||
|
## 2. Batch — competitive, not the headline
|
||||||
|
|
||||||
|
Whole series in one call. Here hand-tuned C (`tulipy`, TA-Lib) and the leanest
|
||||||
|
Rust crate (`kand`) win the simple recurrences — Wickra trades a few µs per pass
|
||||||
|
for the `None`-warmup, NaN-safety and bit-exact `batch == streaming` guarantees
|
||||||
|
none of them keep. It still wins several rows outright and beats the rest of the
|
||||||
|
field everywhere.
|
||||||
|
|
||||||
|
**Python** (20 000-bar pass, µs/op, lower = faster):
|
||||||
|
|
||||||
|
| Indicator | Wickra | TA-Lib | tulipy | pandas-ta |
|
||||||
|
|------------------|---------:|-------:|-------:|----------:|
|
||||||
|
| SMA(20) | 22.7 | **15.4** | 15.9 | 33.7 |
|
||||||
|
| EMA(20) | 30.8 | **30.3** | 31.1 | 48.8 |
|
||||||
|
| RSI(14) | 58.9 | 72.5 | **38.5** | 94.8 |
|
||||||
|
| MACD(12, 26, 9) | 71.7 | 99.1 | **33.5** | 207.6 |
|
||||||
|
| Bollinger(20, 2) | 84.9 | 65.7 | **32.3** | 336.4 |
|
||||||
|
| ATR(14) | 52.0 | 79.4 | **31.9** | — |
|
||||||
|
|
||||||
|
Wickra beats TA-Lib on RSI, MACD and ATR and the whole Python field on every
|
||||||
|
row; tulipy's SIMD C stays ahead on the heavier indicators.
|
||||||
|
|
||||||
|
**Rust** (50 000-bar pass, µs, lower = faster). Only Wickra and `kand` expose a
|
||||||
|
batch API; `ta-rs` and `yata` are streaming-only:
|
||||||
|
|
||||||
|
| Indicator | **★ Wickra** | kand |
|
||||||
|
|------------------|------------------:|-------:|
|
||||||
|
| SMA(20) | 53 | **41** |
|
||||||
|
| EMA(20) | 111 | **71** |
|
||||||
|
| RSI(14) | **221 ★** | 259 |
|
||||||
|
| MACD(12, 26, 9) | 533 | **327** |
|
||||||
|
| Bollinger(20, 2) | **404 ★** | 460 |
|
||||||
|
| ATR(14) | **122 ★** | 169 |
|
||||||
|
|
||||||
|
Run the suite yourself:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo bench -p wickra-bench # Rust core vs kand / ta-rs / yata
|
||||||
|
pip install -e bindings/python[bench] # Python peers
|
||||||
|
python -m benchmarks.compare_libraries
|
||||||
|
```
|
||||||
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **Andrews Pitchfork** — median line and two parallels projected from the last three swing pivots (`ANDREWS_PITCHFORK`).
|
- **Andrews Pitchfork** — median line and two parallels projected from the last three swing pivots (`ANDREWS_PITCHFORK`).
|
||||||
- **Murrey Math Lines** — T. H. Murrey's eighths grid over the recent trading range, each level acting as support/resistance (`MURREY_MATH_LINES`).
|
- **Murrey Math Lines** — T. H. Murrey's eighths grid over the recent trading range, each level acting as support/resistance (`MURREY_MATH_LINES`).
|
||||||
- **Central Pivot Range** — the classic pivot flanked by two central levels gauging the day's expected character (`CENTRAL_PIVOT_RANGE`).
|
- **Central Pivot Range** — the classic pivot flanked by two central levels gauging the day's expected character (`CENTRAL_PIVOT_RANGE`).
|
||||||
|
- **Faster scalar batch paths** — `Ema`, `Rsi`, `BollingerBands`, `MacdIndicator` and `Atr` gained dedicated batch fast paths (used by the Python bindings) that strip per-element `Option`/validation overhead and the intermediate `Vec<Option<_>>` allocation, while staying *bit-for-bit* equal to replaying `update` (including the SMA/Bollinger drift-reseed). Python batch is ~2× faster on EMA/RSI/MACD/ATR; streaming is unchanged.
|
||||||
|
- **Cross-library benchmark refresh** — `benchmarks/compare_libraries.py` now measures the median across timing rounds (`--rounds` / `--streaming-rounds`), adds `--skip-batch` / `--skip-streaming`, and drives every peer through the streaming arena (recompute for batch-only libraries). `wickra-bench` compares the batch fast paths against `kand`.
|
||||||
|
|
||||||
## [0.6.5] - 2026-06-07
|
## [0.6.5] - 2026-06-07
|
||||||
- **Autocorrelation Periodogram** — Ehlers autocorrelation periodogram: dominant cycle period estimate (`AUTOCORRPGRAM`).
|
- **Autocorrelation Periodogram** — Ehlers autocorrelation periodogram: dominant cycle period estimate (`AUTOCORRPGRAM`).
|
||||||
|
|||||||
@@ -58,19 +58,44 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
|
|||||||
[TA-Lib migration](https://docs.wickra.org/TA-Lib-Migration),
|
[TA-Lib migration](https://docs.wickra.org/TA-Lib-Migration),
|
||||||
[FAQ](https://docs.wickra.org/FAQ).
|
[FAQ](https://docs.wickra.org/FAQ).
|
||||||
|
|
||||||
## Why Wickra exists
|
## Why Wickra
|
||||||
|
|
||||||
Wickra started as a personal itch. The existing TA libraries never quite fit the
|
Most TA libraries are fast, *or* multi-language, *or* broad. Wickra refuses to
|
||||||
projects I was building, so I decided to build one from the ground up — partly to
|
pick. It's the streaming-first engine built for the workload the others treat as
|
||||||
learn, partly because I genuinely enjoy taking something that already exists and
|
an afterthought — **live, tick-by-tick data** — without giving up the breadth of
|
||||||
trying to do it differently (and, ideally, better). It's open source because the
|
a full batch library, and without making you reimplement your indicators four
|
||||||
useful version of that itch is the one other people can build on too.
|
times to get there.
|
||||||
|
|
||||||
Plenty of TA libraries are fast. Each one forces a trade-off Wickra does not:
|
- **The biggest streaming-native catalogue, period.** 467 indicators across 24
|
||||||
|
families — candlesticks, harmonic & chart patterns, market profile, market
|
||||||
|
breadth, Renko/Kagi/Point&Figure bars, Ehlers DSP cycles, risk/performance
|
||||||
|
metrics — every single one updating in **O(1) per tick**. TA-Lib ships ~150 and
|
||||||
|
none of them stream.
|
||||||
|
- **One Rust core, four first-class targets.** Native **Python · Node.js ·
|
||||||
|
WebAssembly · Rust** — identical math, identical results, zero per-language
|
||||||
|
reimplementation and zero GIL bottleneck.
|
||||||
|
- **Correct by construction, not by hope.** Every `update` validates its input,
|
||||||
|
runs a real warmup, and returns an `Option` so a single bad tick can't silently
|
||||||
|
poison state. `batch == streaming` is **bit-exact, fuzzed and 100 %-line-covered
|
||||||
|
for all 467 indicators**.
|
||||||
|
- **Orders of magnitude faster where it counts.** In streaming Wickra is **9–58×**
|
||||||
|
faster than the only other incremental peer and **thousands of times** faster
|
||||||
|
than recompute-on-every-tick libraries. On batch it wins several rows outright
|
||||||
|
and trades the simple recurrences (SMA, EMA, MACD) for its guarantees — and
|
||||||
|
the losses are shown, not hidden.
|
||||||
|
- **Install in one line, anywhere.** `pip install wickra` / `npm install wickra` —
|
||||||
|
precompiled wheels and binaries, **no C toolchain, none of TA-Lib's setup pain**.
|
||||||
|
macOS · Linux · Windows.
|
||||||
|
- **Batteries included.** Indicator chaining, a streaming OHLCV CSV reader, and a
|
||||||
|
live Binance kline feed ship in the box.
|
||||||
|
- **Truly permissive.** **MIT OR Apache-2.0** — drop it straight into commercial
|
||||||
|
and closed-source work.
|
||||||
|
|
||||||
|
Every other library forces one of those compromises. Wickra doesn't:
|
||||||
|
|
||||||
| Library | Install | Streaming | Languages | Indicators | Active |
|
| Library | Install | Streaming | Languages | Indicators | Active |
|
||||||
|------------------|-------------|-------------|-----------------------------|-----------:|--------|
|
|------------------|-------------|-------------|-----------------------------|-----------:|--------|
|
||||||
| **★ Wickra**| **clean** | **yes, O(1)** | **Python · Node · WASM · Rust** | **423** | **yes** |
|
| **★ Wickra**| **clean** | **yes, O(1)** | **Python · Node · WASM · Rust** | **467** | **yes** |
|
||||||
| kand | clean | yes | Python · WASM · Rust | ~60 | yes |
|
| kand | clean | yes | Python · WASM · Rust | ~60 | yes |
|
||||||
| ta-rs | clean | yes | Rust only | ~30 | stale |
|
| ta-rs | clean | yes | Rust only | ~30 | stale |
|
||||||
| yata | clean | partial | Rust only | ~35 | yes |
|
| yata | clean | partial | Rust only | ~35 | yes |
|
||||||
@@ -79,112 +104,27 @@ Plenty of TA libraries are fast. Each one forces a trade-off Wickra does not:
|
|||||||
| finta | clean | no | Python | ~80 | stale |
|
| finta | clean | no | Python | ~80 | stale |
|
||||||
| talipp | clean | yes | Python | ~40 | yes |
|
| talipp | clean | yes | Python | ~40 | yes |
|
||||||
|
|
||||||
Wickra's edge is **breadth with reach**: 467 indicators that all update in O(1)
|
Broad, multi-language, streaming-native **and** honest about its trade-offs — at
|
||||||
per tick and ship natively to Python, Node.js, WebAssembly and Rust from a
|
the same time. That's the combination no one else ships.
|
||||||
single engine.
|
|
||||||
|
|
||||||
**On speed — and why Wickra isn't the fastest.** It deliberately isn't. The
|
## Why Wickra exists
|
||||||
leaner Rust crates (kand, ta-rs) win several of the micro-benchmarks below, and
|
|
||||||
those losses are shown rather than hidden. The gap is a *choice*, not a ceiling:
|
Wickra started as a personal itch. The existing TA libraries never quite fit the
|
||||||
every `update` validates its input, runs a real warmup before it emits a value,
|
projects I was building, so I decided to build one from the ground up — partly to
|
||||||
and returns an `Option` so a single bad tick can't silently poison the state.
|
learn, partly because I genuinely enjoy taking something that already exists and
|
||||||
ta-rs, by contrast, hands back a bare `f64` from the first tick with no
|
trying to do it differently (and, ideally, better). It's open source because the
|
||||||
validation. If Wickra threw all of that away — raw `f64` out, no checks, no
|
useful version of that itch is the one other people can build on too.
|
||||||
warmup contract — it would match or beat the leanest crate on every row. It
|
|
||||||
keeps the guarantees instead, and still wins RSI, Bollinger and ATR against kand.
|
|
||||||
What no other library matches is the *combination*: catalogue size, native O(1)
|
|
||||||
streaming, NaN-safety, and four first-class language targets at once.
|
|
||||||
|
|
||||||
## Benchmarks
|
## Benchmarks
|
||||||
|
|
||||||
Three comparisons, split by layer and mode. Read them as **relative** speedups
|
Wickra updates every indicator in **O(1)** per tick. In **streaming** — the
|
||||||
on identical input — absolute µs depend on CPU, memory clock and OS scheduler,
|
workload it is built for — it is **9–58× faster** than the only other incremental
|
||||||
not a universal contract.
|
peer and **thousands of times** faster than recompute-on-every-tick libraries.
|
||||||
|
**Batch** is competitive: it wins several rows outright and trades a few µs
|
||||||
|
elsewhere for `None`-warmup, NaN-safety and bit-exact `batch == streaming`.
|
||||||
|
|
||||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
|
Full tables (Rust + Python, streaming + batch) and how to reproduce them live in
|
||||||
Rust 1.92 (release: `lto = "fat"`, `codegen-units = 1`), Python 3.12.
|
**[BENCHMARKS.md](BENCHMARKS.md)**.
|
||||||
- **Reproduce yourself:**
|
|
||||||
- Rust core vs Rust crates: `cargo bench -p wickra-bench`
|
|
||||||
- Python vs Python libs: `pip install -e bindings/python[bench]` then
|
|
||||||
`python -m benchmarks.compare_libraries` (auto-detects installed peers).
|
|
||||||
|
|
||||||
### 1. Rust core vs the other Rust TA crates
|
|
||||||
|
|
||||||
Like-for-like, no language-binding overhead, over a 50 000-bar series (µs for
|
|
||||||
the whole series, lower = faster). This is the honest engine comparison —
|
|
||||||
Wickra wins some and loses some, and both are shown.
|
|
||||||
|
|
||||||
**Streaming** (one value fed per `update`):
|
|
||||||
|
|
||||||
| Indicator | **★ Wickra** | kand | ta-rs | yata |
|
|
||||||
|------------------|------------------:|-----:|------:|-----:|
|
|
||||||
| SMA(20) | 50 | 38 | 47 | 38 |
|
|
||||||
| EMA(20) | 154 | 69 | 56 | 69 |
|
|
||||||
| RSI(14) | 164 | 216 | 74 | — |
|
|
||||||
| MACD(12, 26, 9) | 275 | 143 | 66 | — |
|
|
||||||
| Bollinger(20, 2) | **128 ★** | 248 | 168 | — |
|
|
||||||
| ATR(14) | 152 | 166 | 61 | — |
|
|
||||||
|
|
||||||
**Batch** (whole series at once). Only Wickra and kand expose a batch API;
|
|
||||||
ta-rs and yata are streaming-only.
|
|
||||||
|
|
||||||
| Indicator | **★ Wickra** | kand |
|
|
||||||
|------------------|------------------:|-----:|
|
|
||||||
| SMA(20) | 82 | 42 |
|
|
||||||
| EMA(20) | 159 | 74 |
|
|
||||||
| RSI(14) | **253 ★** | 274 |
|
|
||||||
| MACD(12, 26, 9) | 681 | 283 |
|
|
||||||
| Bollinger(20, 2) | **445 ★** | 462 |
|
|
||||||
| ATR(14) | 175 | 173 |
|
|
||||||
|
|
||||||
ta-rs is the per-indicator speed champion on almost every row — it returns a
|
|
||||||
bare `f64` with no warmup state and no input validation, trading away the
|
|
||||||
`None`-warmup and NaN-safety semantics Wickra keeps. Against kand, Wickra wins
|
|
||||||
streaming RSI, Bollinger and ATR (and batch RSI + Bollinger); Bollinger is the
|
|
||||||
one row where Wickra is the outright fastest of all four. The leaner crates
|
|
||||||
still win the pure recurrences (EMA, MACD) and SMA. yata exposes only SMA/EMA as
|
|
||||||
raw-value methods, so its other rows are omitted rather than faked.
|
|
||||||
|
|
||||||
### 2. Python vs the Python TA ecosystem — batch
|
|
||||||
|
|
||||||
Full pass over a 20 000-bar series, µs/op (lower = faster). **★** per row.
|
|
||||||
|
|
||||||
| Indicator | **★ Wickra** | finta | TA-Lib | tulipy |
|
|
||||||
|------------------|------------------:|---------------------|--------|--------|
|
|
||||||
| SMA(20) | **59.6 ★** | 354.2 (5.9× slower) | ⧗ | ⧗ |
|
|
||||||
| EMA(20) | **88.4 ★** | 309.3 (3.5× slower) | ⧗ | ⧗ |
|
|
||||||
| RSI(14) | **77.3 ★** | 1 283 (16.6× slower)| ⧗ | ⧗ |
|
|
||||||
| MACD(12, 26, 9) | **116.4 ★** | 529.5 (4.6× slower) | ⧗ | ⧗ |
|
|
||||||
| Bollinger(20, 2) | **146.0 ★** | 1 246 (8.5× slower) | ⧗ | ⧗ |
|
|
||||||
| ATR(14) | **135.8 ★** | 3 812 (28× slower) | ⧗ | ⧗ |
|
|
||||||
|
|
||||||
> ⧗ = published by the CI Linux job. TA-Lib and tulipy ship C extensions that
|
|
||||||
> don't build cleanly on every desktop, so their canonical numbers come from the
|
|
||||||
> `cross-library-bench` workflow rather than this local table. pandas-ta needs
|
|
||||||
> Python ≥ 3.12 and isn't in the 3.11 CI matrix. The script auto-detects
|
|
||||||
> whichever peers are installed in your environment.
|
|
||||||
|
|
||||||
### 3. Python — streaming (per-tick latency)
|
|
||||||
|
|
||||||
Seed 5 000 bars, then feed ticks one at a time. talipp is the only Python peer
|
|
||||||
with a true incremental API; batch-only libraries like TA-Lib must recompute the
|
|
||||||
entire history on every tick — Wickra updates in O(1).
|
|
||||||
|
|
||||||
| Indicator | **★ Wickra (per tick)** | talipp (per tick) |
|
|
||||||
|------------------|------------------------------:|-------------------------|
|
|
||||||
| SMA(20) | **0.067 µs ★** | 0.63 µs (9.4× slower) |
|
|
||||||
| EMA(20) | **0.051 µs ★** | 0.63 µs (12.2× slower) |
|
|
||||||
| RSI(14) | **0.053 µs ★** | 1.00 µs (19.1× slower) |
|
|
||||||
| MACD(12, 26, 9) | **0.071 µs ★** | 3.64 µs (51.5× slower) |
|
|
||||||
| Bollinger(20, 2) | **0.085 µs ★** | 4.87 µs (57.2× slower) |
|
|
||||||
|
|
||||||
Run the suite yourself:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo bench -p wickra-bench # Rust core vs kand / ta-rs / yata
|
|
||||||
pip install -e bindings/python[bench] # Python peers
|
|
||||||
python -m benchmarks.compare_libraries
|
|
||||||
```
|
|
||||||
|
|
||||||
## Indicators
|
## Indicators
|
||||||
|
|
||||||
|
|||||||
@@ -72,13 +72,23 @@ class Sample:
|
|||||||
return (self.seconds / self.iterations) * 1_000_000
|
return (self.seconds / self.iterations) * 1_000_000
|
||||||
|
|
||||||
|
|
||||||
def time_call(fn: Callable[[], None], iterations: int) -> float:
|
def time_call(fn: Callable[[], None], iterations: int, rounds: int = 5) -> float:
|
||||||
"""Time ``fn`` over ``iterations`` calls, returning total wall seconds."""
|
"""Time ``fn`` over ``iterations`` calls per round, across ``rounds`` rounds.
|
||||||
|
|
||||||
|
Returns the *median* round's wall seconds for one round of ``iterations``
|
||||||
|
calls. Taking the median across several rounds damps the OS scheduling and
|
||||||
|
GC jitter that a single timing pass would otherwise bake into the result,
|
||||||
|
so the per-iteration figure is stable run-to-run. Callers keep dividing the
|
||||||
|
return value by ``iterations``.
|
||||||
|
"""
|
||||||
fn() # one warmup call to populate caches
|
fn() # one warmup call to populate caches
|
||||||
start = time.perf_counter()
|
rounds_s: List[float] = []
|
||||||
for _ in range(iterations):
|
for _ in range(rounds):
|
||||||
fn()
|
start = time.perf_counter()
|
||||||
return time.perf_counter() - start
|
for _ in range(iterations):
|
||||||
|
fn()
|
||||||
|
rounds_s.append(time.perf_counter() - start)
|
||||||
|
return statistics.median(rounds_s)
|
||||||
|
|
||||||
|
|
||||||
def gen_prices(n: int, seed: int = 0xC0FFEE) -> np.ndarray:
|
def gen_prices(n: int, seed: int = 0xC0FFEE) -> np.ndarray:
|
||||||
@@ -457,6 +467,161 @@ def talipp_bollinger_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[C
|
|||||||
return run
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
# Recompute streaming peers: batch-only libraries have no incremental API, so
|
||||||
|
# the only honest way to drive them tick-by-tick is to re-run the full batch
|
||||||
|
# over the grown history on every new price. These runners expose exactly that
|
||||||
|
# cost — the gap Wickra's O(1) update closes.
|
||||||
|
|
||||||
|
|
||||||
|
def _talib_recompute_streaming(seed, live, fn):
|
||||||
|
def run() -> None:
|
||||||
|
history = list(seed)
|
||||||
|
for p in live:
|
||||||
|
history.append(float(p))
|
||||||
|
fn(np.asarray(history))
|
||||||
|
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def _pandas_ta_recompute_streaming(seed, live, fn):
|
||||||
|
def run() -> None:
|
||||||
|
history = list(seed)
|
||||||
|
for p in live:
|
||||||
|
history.append(float(p))
|
||||||
|
fn(PD.Series(history))
|
||||||
|
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def _tulipy_recompute_streaming(seed, live, fn):
|
||||||
|
def run() -> None:
|
||||||
|
history = list(seed)
|
||||||
|
for p in live:
|
||||||
|
history.append(float(p))
|
||||||
|
fn(np.asarray(history, dtype=np.float64))
|
||||||
|
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def _finta_recompute_streaming(seed, live, fn):
|
||||||
|
def run() -> None:
|
||||||
|
history = list(seed)
|
||||||
|
for p in live:
|
||||||
|
history.append(float(p))
|
||||||
|
arr = np.asarray(history)
|
||||||
|
fn(PD.DataFrame({"open": arr, "high": arr, "low": arr, "close": arr, "volume": np.ones_like(arr)}))
|
||||||
|
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def talib_sma_streaming(seed, live):
|
||||||
|
if TALIB is None:
|
||||||
|
return None
|
||||||
|
return _talib_recompute_streaming(seed, live, lambda a: TALIB.SMA(a, timeperiod=20))
|
||||||
|
|
||||||
|
|
||||||
|
def pandas_ta_sma_streaming(seed, live):
|
||||||
|
if PANDAS_TA is None or PD is None:
|
||||||
|
return None
|
||||||
|
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.sma(s, length=20))
|
||||||
|
|
||||||
|
|
||||||
|
def tulipy_sma_streaming(seed, live):
|
||||||
|
if TULIPY is None:
|
||||||
|
return None
|
||||||
|
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.sma(a, 20))
|
||||||
|
|
||||||
|
|
||||||
|
def finta_sma_streaming(seed, live):
|
||||||
|
if FINTA is None or PD is None:
|
||||||
|
return None
|
||||||
|
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.SMA(df, period=20))
|
||||||
|
|
||||||
|
|
||||||
|
def talib_ema_streaming(seed, live):
|
||||||
|
if TALIB is None:
|
||||||
|
return None
|
||||||
|
return _talib_recompute_streaming(seed, live, lambda a: TALIB.EMA(a, timeperiod=20))
|
||||||
|
|
||||||
|
|
||||||
|
def pandas_ta_ema_streaming(seed, live):
|
||||||
|
if PANDAS_TA is None or PD is None:
|
||||||
|
return None
|
||||||
|
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.ema(s, length=20))
|
||||||
|
|
||||||
|
|
||||||
|
def tulipy_ema_streaming(seed, live):
|
||||||
|
if TULIPY is None:
|
||||||
|
return None
|
||||||
|
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.ema(a, 20))
|
||||||
|
|
||||||
|
|
||||||
|
def finta_ema_streaming(seed, live):
|
||||||
|
if FINTA is None or PD is None:
|
||||||
|
return None
|
||||||
|
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.EMA(df, period=20))
|
||||||
|
|
||||||
|
|
||||||
|
def tulipy_rsi_streaming(seed, live):
|
||||||
|
if TULIPY is None:
|
||||||
|
return None
|
||||||
|
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.rsi(a, 14))
|
||||||
|
|
||||||
|
|
||||||
|
def finta_rsi_streaming(seed, live):
|
||||||
|
if FINTA is None or PD is None:
|
||||||
|
return None
|
||||||
|
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.RSI(df, period=14))
|
||||||
|
|
||||||
|
|
||||||
|
def talib_macd_streaming(seed, live):
|
||||||
|
if TALIB is None:
|
||||||
|
return None
|
||||||
|
return _talib_recompute_streaming(seed, live, lambda a: TALIB.MACD(a))
|
||||||
|
|
||||||
|
|
||||||
|
def pandas_ta_macd_streaming(seed, live):
|
||||||
|
if PANDAS_TA is None or PD is None:
|
||||||
|
return None
|
||||||
|
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.macd(s))
|
||||||
|
|
||||||
|
|
||||||
|
def tulipy_macd_streaming(seed, live):
|
||||||
|
if TULIPY is None:
|
||||||
|
return None
|
||||||
|
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.macd(a, 12, 26, 9))
|
||||||
|
|
||||||
|
|
||||||
|
def finta_macd_streaming(seed, live):
|
||||||
|
if FINTA is None or PD is None:
|
||||||
|
return None
|
||||||
|
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.MACD(df))
|
||||||
|
|
||||||
|
|
||||||
|
def talib_bollinger_streaming(seed, live):
|
||||||
|
if TALIB is None:
|
||||||
|
return None
|
||||||
|
return _talib_recompute_streaming(seed, live, lambda a: TALIB.BBANDS(a, timeperiod=20, nbdevup=2, nbdevdn=2))
|
||||||
|
|
||||||
|
|
||||||
|
def pandas_ta_bollinger_streaming(seed, live):
|
||||||
|
if PANDAS_TA is None or PD is None:
|
||||||
|
return None
|
||||||
|
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.bbands(s, length=20, std=2.0))
|
||||||
|
|
||||||
|
|
||||||
|
def tulipy_bollinger_streaming(seed, live):
|
||||||
|
if TULIPY is None:
|
||||||
|
return None
|
||||||
|
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.bbands(a, 20, 2.0))
|
||||||
|
|
||||||
|
|
||||||
|
def finta_bollinger_streaming(seed, live):
|
||||||
|
if FINTA is None or PD is None:
|
||||||
|
return None
|
||||||
|
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.BBANDS(df, period=20, std_multiplier=2.0))
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Runner
|
# Runner
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -519,36 +684,54 @@ STREAMING_INDICATORS = [
|
|||||||
("SMA(20)", [
|
("SMA(20)", [
|
||||||
("Wickra", wickra_sma_streaming),
|
("Wickra", wickra_sma_streaming),
|
||||||
("talipp", talipp_sma_streaming),
|
("talipp", talipp_sma_streaming),
|
||||||
|
("TA-Lib", talib_sma_streaming),
|
||||||
|
("pandas-ta", pandas_ta_sma_streaming),
|
||||||
|
("tulipy", tulipy_sma_streaming),
|
||||||
|
("finta", finta_sma_streaming),
|
||||||
]),
|
]),
|
||||||
("EMA(20)", [
|
("EMA(20)", [
|
||||||
("Wickra", wickra_ema_streaming),
|
("Wickra", wickra_ema_streaming),
|
||||||
("talipp", talipp_ema_streaming),
|
("talipp", talipp_ema_streaming),
|
||||||
|
("TA-Lib", talib_ema_streaming),
|
||||||
|
("pandas-ta", pandas_ta_ema_streaming),
|
||||||
|
("tulipy", tulipy_ema_streaming),
|
||||||
|
("finta", finta_ema_streaming),
|
||||||
]),
|
]),
|
||||||
("RSI(14)", [
|
("RSI(14)", [
|
||||||
("Wickra", wickra_rsi_streaming),
|
("Wickra", wickra_rsi_streaming),
|
||||||
|
("talipp", talipp_rsi_streaming),
|
||||||
("TA-Lib", talib_rsi_streaming),
|
("TA-Lib", talib_rsi_streaming),
|
||||||
("pandas-ta", pandas_ta_rsi_streaming),
|
("pandas-ta", pandas_ta_rsi_streaming),
|
||||||
("talipp", talipp_rsi_streaming),
|
("tulipy", tulipy_rsi_streaming),
|
||||||
|
("finta", finta_rsi_streaming),
|
||||||
]),
|
]),
|
||||||
("MACD(12, 26, 9)", [
|
("MACD(12, 26, 9)", [
|
||||||
("Wickra", wickra_macd_streaming),
|
("Wickra", wickra_macd_streaming),
|
||||||
("talipp", talipp_macd_streaming),
|
("talipp", talipp_macd_streaming),
|
||||||
|
("TA-Lib", talib_macd_streaming),
|
||||||
|
("pandas-ta", pandas_ta_macd_streaming),
|
||||||
|
("tulipy", tulipy_macd_streaming),
|
||||||
|
("finta", finta_macd_streaming),
|
||||||
]),
|
]),
|
||||||
("Bollinger(20, 2.0)", [
|
("Bollinger(20, 2.0)", [
|
||||||
("Wickra", wickra_bollinger_streaming),
|
("Wickra", wickra_bollinger_streaming),
|
||||||
("talipp", talipp_bollinger_streaming),
|
("talipp", talipp_bollinger_streaming),
|
||||||
|
("TA-Lib", talib_bollinger_streaming),
|
||||||
|
("pandas-ta", pandas_ta_bollinger_streaming),
|
||||||
|
("tulipy", tulipy_bollinger_streaming),
|
||||||
|
("finta", finta_bollinger_streaming),
|
||||||
]),
|
]),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def run_batch(prices: np.ndarray, iterations: int) -> List[Sample]:
|
def run_batch(prices: np.ndarray, iterations: int, rounds: int) -> List[Sample]:
|
||||||
out: List[Sample] = []
|
out: List[Sample] = []
|
||||||
for indicator_name, libs in BATCH_INDICATORS:
|
for indicator_name, libs in BATCH_INDICATORS:
|
||||||
for lib_name, factory in libs:
|
for lib_name, factory in libs:
|
||||||
runner = factory(prices)
|
runner = factory(prices)
|
||||||
if runner is None:
|
if runner is None:
|
||||||
continue
|
continue
|
||||||
secs = time_call(runner, iterations)
|
secs = time_call(runner, iterations, rounds)
|
||||||
out.append(Sample(lib_name, indicator_name, "batch", secs, iterations))
|
out.append(Sample(lib_name, indicator_name, "batch", secs, iterations))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -558,6 +741,7 @@ def run_ohlc(
|
|||||||
low: np.ndarray,
|
low: np.ndarray,
|
||||||
close: np.ndarray,
|
close: np.ndarray,
|
||||||
iterations: int,
|
iterations: int,
|
||||||
|
rounds: int,
|
||||||
) -> List[Sample]:
|
) -> List[Sample]:
|
||||||
out: List[Sample] = []
|
out: List[Sample] = []
|
||||||
for indicator_name, libs in OHLC_INDICATORS:
|
for indicator_name, libs in OHLC_INDICATORS:
|
||||||
@@ -565,12 +749,12 @@ def run_ohlc(
|
|||||||
runner = factory(high, low, close)
|
runner = factory(high, low, close)
|
||||||
if runner is None:
|
if runner is None:
|
||||||
continue
|
continue
|
||||||
secs = time_call(runner, iterations)
|
secs = time_call(runner, iterations, rounds)
|
||||||
out.append(Sample(lib_name, indicator_name, "batch", secs, iterations))
|
out.append(Sample(lib_name, indicator_name, "batch", secs, iterations))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def run_streaming(prices: np.ndarray, streaming_window: int, iterations: int) -> List[Sample]:
|
def run_streaming(prices: np.ndarray, streaming_window: int, iterations: int, rounds: int) -> List[Sample]:
|
||||||
out: List[Sample] = []
|
out: List[Sample] = []
|
||||||
seed = prices[:streaming_window]
|
seed = prices[:streaming_window]
|
||||||
live = prices[streaming_window:]
|
live = prices[streaming_window:]
|
||||||
@@ -581,7 +765,7 @@ def run_streaming(prices: np.ndarray, streaming_window: int, iterations: int) ->
|
|||||||
runner = factory(seed, live)
|
runner = factory(seed, live)
|
||||||
if runner is None:
|
if runner is None:
|
||||||
continue
|
continue
|
||||||
secs = time_call(runner, iterations)
|
secs = time_call(runner, iterations, rounds)
|
||||||
sample = Sample(lib_name, indicator_name, "streaming", secs, iterations)
|
sample = Sample(lib_name, indicator_name, "streaming", secs, iterations)
|
||||||
sample.iterations = iterations * len(live) # per-tick normalization
|
sample.iterations = iterations * len(live) # per-tick normalization
|
||||||
out.append(sample)
|
out.append(sample)
|
||||||
@@ -629,6 +813,12 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None)
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None)
|
||||||
parser.add_argument("--size", type=int, default=20_000, help="number of prices")
|
parser.add_argument("--size", type=int, default=20_000, help="number of prices")
|
||||||
parser.add_argument("--iterations", type=int, default=20, help="batch repetitions per timing")
|
parser.add_argument("--iterations", type=int, default=20, help="batch repetitions per timing")
|
||||||
|
parser.add_argument(
|
||||||
|
"--rounds",
|
||||||
|
type=int,
|
||||||
|
default=5,
|
||||||
|
help="batch timing rounds; the median round is reported to damp jitter",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--streaming-window",
|
"--streaming-window",
|
||||||
type=int,
|
type=int,
|
||||||
@@ -641,6 +831,14 @@ def parse_args() -> argparse.Namespace:
|
|||||||
default=3,
|
default=3,
|
||||||
help="repetitions of the streaming workload (each iteration replays all live ticks)",
|
help="repetitions of the streaming workload (each iteration replays all live ticks)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--streaming-rounds",
|
||||||
|
type=int,
|
||||||
|
default=2,
|
||||||
|
help="streaming timing rounds; the median round is reported",
|
||||||
|
)
|
||||||
|
parser.add_argument("--skip-batch", action="store_true", help="skip the batch tables")
|
||||||
|
parser.add_argument("--skip-streaming", action="store_true", help="skip the streaming tables")
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -660,11 +858,14 @@ def main() -> None:
|
|||||||
print(f"Streaming window: {args.streaming_window} seed, {args.size - args.streaming_window} live")
|
print(f"Streaming window: {args.streaming_window} seed, {args.size - args.streaming_window} live")
|
||||||
|
|
||||||
high, low, close, _ = gen_ohlc(args.size)
|
high, low, close, _ = gen_ohlc(args.size)
|
||||||
batch_rows = run_batch(prices, args.iterations)
|
rows: List[Sample] = []
|
||||||
ohlc_rows = run_ohlc(high, low, close, args.iterations)
|
if not args.skip_batch:
|
||||||
streaming_rows = run_streaming(prices, args.streaming_window, args.streaming_iterations)
|
rows += run_batch(prices, args.iterations, args.rounds)
|
||||||
|
rows += run_ohlc(high, low, close, args.iterations, args.rounds)
|
||||||
|
if not args.skip_streaming:
|
||||||
|
rows += run_streaming(prices, args.streaming_window, args.streaming_iterations, args.streaming_rounds)
|
||||||
|
|
||||||
print(render_table(batch_rows + ohlc_rows + streaming_rows))
|
print(render_table(rows))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+147
-163
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
|
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
|
||||||
use std::hint::black_box;
|
use std::hint::black_box;
|
||||||
use wickra::{Atr, BatchExt, BollingerBands, Candle, Ema, Indicator, MacdIndicator, Rsi, Sma};
|
use wickra::{Atr, BollingerBands, Candle, Ema, Indicator, MacdIndicator, Rsi, Sma};
|
||||||
use wickra_data::csv::CandleReader;
|
use wickra_data::csv::CandleReader;
|
||||||
use yata::prelude::Method;
|
use yata::prelude::Method;
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ fn sma_group(crit: &mut Criterion, closes: &[f64]) {
|
|||||||
|bencher, &series| {
|
|bencher, &series| {
|
||||||
bencher.iter(|| {
|
bencher.iter(|| {
|
||||||
let mut ind = Sma::new(SMA_PERIOD).unwrap();
|
let mut ind = Sma::new(SMA_PERIOD).unwrap();
|
||||||
black_box(ind.batch(series));
|
black_box(ind.batch_nan(series));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -170,7 +170,7 @@ fn ema_group(crit: &mut Criterion, closes: &[f64]) {
|
|||||||
|bencher, &series| {
|
|bencher, &series| {
|
||||||
bencher.iter(|| {
|
bencher.iter(|| {
|
||||||
let mut ind = Ema::new(EMA_PERIOD).unwrap();
|
let mut ind = Ema::new(EMA_PERIOD).unwrap();
|
||||||
black_box(ind.batch(series));
|
black_box(ind.batch_nan(series));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -253,7 +253,7 @@ fn rsi_group(crit: &mut Criterion, closes: &[f64]) {
|
|||||||
|bencher, &series| {
|
|bencher, &series| {
|
||||||
bencher.iter(|| {
|
bencher.iter(|| {
|
||||||
let mut ind = Rsi::new(RSI_PERIOD).unwrap();
|
let mut ind = Rsi::new(RSI_PERIOD).unwrap();
|
||||||
black_box(ind.batch(series));
|
black_box(ind.batch_nan(series));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -352,7 +352,7 @@ fn macd_group(crit: &mut Criterion, closes: &[f64]) {
|
|||||||
|bencher, &series| {
|
|bencher, &series| {
|
||||||
bencher.iter(|| {
|
bencher.iter(|| {
|
||||||
let mut ind = MacdIndicator::classic();
|
let mut ind = MacdIndicator::classic();
|
||||||
black_box(ind.batch(series));
|
black_box(ind.batch_macd(series));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -478,7 +478,7 @@ fn bbands_group(crit: &mut Criterion, closes: &[f64]) {
|
|||||||
|bencher, &series| {
|
|bencher, &series| {
|
||||||
bencher.iter(|| {
|
bencher.iter(|| {
|
||||||
let mut ind = BollingerBands::new(BB_PERIOD, BB_DEV).unwrap();
|
let mut ind = BollingerBands::new(BB_PERIOD, BB_DEV).unwrap();
|
||||||
black_box(ind.batch(series));
|
black_box(ind.batch_bands(series));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -604,9 +604,13 @@ fn atr_group(crit: &mut Criterion, candles: &[Candle]) {
|
|||||||
BenchmarkId::new("wickra/batch", len),
|
BenchmarkId::new("wickra/batch", len),
|
||||||
&series,
|
&series,
|
||||||
|bencher, &series| {
|
|bencher, &series| {
|
||||||
|
// Column extraction is outside the timed loop, mirroring kand's arm.
|
||||||
|
let high: Vec<f64> = series.iter().map(|candle| candle.high).collect();
|
||||||
|
let low: Vec<f64> = series.iter().map(|candle| candle.low).collect();
|
||||||
|
let close: Vec<f64> = series.iter().map(|candle| candle.close).collect();
|
||||||
bencher.iter(|| {
|
bencher.iter(|| {
|
||||||
let mut ind = Atr::new(ATR_PERIOD).unwrap();
|
let mut ind = Atr::new(ATR_PERIOD).unwrap();
|
||||||
black_box(ind.batch(series));
|
black_box(ind.batch_atr(&high, &low, &close));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -75,6 +75,67 @@ impl Atr {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vectorized batch over raw high/low/close columns: one `f64` per bar
|
||||||
|
/// (`NaN` during warmup). The caller guarantees the three slices are equal
|
||||||
|
/// length and finite with valid OHLC ordering (the binding validates once up
|
||||||
|
/// front); ATR only reads high, low and the previous close.
|
||||||
|
///
|
||||||
|
/// For a fresh indicator long enough to seed (`n >= period`) it runs the
|
||||||
|
/// true-range seed once and then the bare Wilder recurrence in a tight loop —
|
||||||
|
/// no per-bar `Candle` construction/validation, no `Option`, identical
|
||||||
|
/// division at the seed and `mul_add` afterwards, so the result is
|
||||||
|
/// *bit-for-bit* equal to replaying `update` over the same candles. Shorter
|
||||||
|
/// or non-fresh inputs defer to an exact `update` replay.
|
||||||
|
pub fn batch_atr(&mut self, high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
|
||||||
|
let p = self.period;
|
||||||
|
let n = high.len();
|
||||||
|
if self.seeded || !self.seed_buf.is_empty() || self.prev_close.is_some() || n < p {
|
||||||
|
let mut out = vec![f64::NAN; n];
|
||||||
|
for i in 0..n {
|
||||||
|
let candle = Candle::new_unchecked(close[i], high[i], low[i], close[i], 0.0, 0);
|
||||||
|
if let Some(v) = self.update(candle) {
|
||||||
|
out[i] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warmup `[0, p-1)` is `NaN`; the first ATR is emitted at index `p - 1`.
|
||||||
|
let mut out = vec![f64::NAN; p - 1];
|
||||||
|
out.reserve(n - (p - 1));
|
||||||
|
// Seed: mean of the first `period` true ranges. TR₀ has no previous close.
|
||||||
|
let mut prev_close = close[0];
|
||||||
|
let mut sum_tr = high[0] - low[0];
|
||||||
|
self.seed_buf.push(sum_tr);
|
||||||
|
for i in 1..p {
|
||||||
|
let (h, l) = (high[i], low[i]);
|
||||||
|
let tr = (h - l)
|
||||||
|
.max((h - prev_close).abs())
|
||||||
|
.max((l - prev_close).abs());
|
||||||
|
prev_close = close[i];
|
||||||
|
self.seed_buf.push(tr);
|
||||||
|
sum_tr += tr;
|
||||||
|
}
|
||||||
|
let mut avg = sum_tr / p as f64;
|
||||||
|
out.push(avg);
|
||||||
|
// Steady state: Wilder smoothing, reciprocal hoisted out of the loop.
|
||||||
|
for i in p..n {
|
||||||
|
let (h, l) = (high[i], low[i]);
|
||||||
|
let tr = (h - l)
|
||||||
|
.max((h - prev_close).abs())
|
||||||
|
.max((l - prev_close).abs());
|
||||||
|
prev_close = close[i];
|
||||||
|
avg = avg.mul_add(self.n_minus_1, tr) * self.inv_period;
|
||||||
|
out.push(avg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave state where a full `update` replay would (seeded; seed_buf retained).
|
||||||
|
self.prev_close = Some(prev_close);
|
||||||
|
self.avg = avg;
|
||||||
|
self.seeded = true;
|
||||||
|
out
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Indicator for Atr {
|
impl Indicator for Atr {
|
||||||
@@ -266,6 +327,81 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bits_eq(a: &[f64], b: &[f64]) -> bool {
|
||||||
|
a.len() == b.len()
|
||||||
|
&& a.iter()
|
||||||
|
.zip(b)
|
||||||
|
.all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn atr_replay(period: usize, high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
|
||||||
|
let mut a = Atr::new(period).unwrap();
|
||||||
|
(0..high.len())
|
||||||
|
.map(|i| {
|
||||||
|
let candle = Candle::new_unchecked(close[i], high[i], low[i], close[i], 0.0, 0);
|
||||||
|
a.update(candle).unwrap_or(f64::NAN)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valid OHLC columns from a wandering base price.
|
||||||
|
fn columns(n: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
|
||||||
|
let base: Vec<f64> = (0..n)
|
||||||
|
.map(|i| (f64::from(u32::try_from(i).unwrap()) * 0.3).sin() * 5.0 + 100.0)
|
||||||
|
.collect();
|
||||||
|
let high = base.iter().map(|b| b + 1.0).collect();
|
||||||
|
let low = base.iter().map(|b| b - 1.0).collect();
|
||||||
|
(high, low, base)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_atr_fast_path_is_bit_identical() {
|
||||||
|
let (high, low, close) = columns(300);
|
||||||
|
let mut atr = Atr::new(14).unwrap();
|
||||||
|
let got = atr.batch_atr(&high, &low, &close);
|
||||||
|
assert!(bits_eq(&got, &atr_replay(14, &high, &low, &close)));
|
||||||
|
let mut ref_atr = Atr::new(14).unwrap();
|
||||||
|
for i in 0..high.len() {
|
||||||
|
ref_atr.update(Candle::new_unchecked(
|
||||||
|
close[i], high[i], low[i], close[i], 0.0, 0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let next = Candle::new_unchecked(101.0, 102.0, 100.0, 101.0, 0.0, 0);
|
||||||
|
assert_eq!(atr.update(next), ref_atr.update(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_atr_falls_back_when_not_fresh() {
|
||||||
|
let (high, low, close) = columns(40);
|
||||||
|
let mut atr = Atr::new(14).unwrap();
|
||||||
|
atr.update(Candle::new_unchecked(
|
||||||
|
close[0], high[0], low[0], close[0], 0.0, 0,
|
||||||
|
));
|
||||||
|
let mut ref_atr = Atr::new(14).unwrap();
|
||||||
|
ref_atr.update(Candle::new_unchecked(
|
||||||
|
close[0], high[0], low[0], close[0], 0.0, 0,
|
||||||
|
));
|
||||||
|
let want: Vec<f64> = (0..high.len())
|
||||||
|
.map(|i| {
|
||||||
|
ref_atr
|
||||||
|
.update(Candle::new_unchecked(
|
||||||
|
close[i], high[i], low[i], close[i], 0.0, 0,
|
||||||
|
))
|
||||||
|
.unwrap_or(f64::NAN)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(bits_eq(&atr.batch_atr(&high, &low, &close), &want));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_atr_sub_period_slice_falls_back() {
|
||||||
|
let (high, low, close) = columns(5);
|
||||||
|
let mut atr = Atr::new(14).unwrap();
|
||||||
|
let got = atr.batch_atr(&high, &low, &close);
|
||||||
|
assert!(bits_eq(&got, &atr_replay(14, &high, &low, &close)));
|
||||||
|
assert!(got.iter().all(|x| x.is_nan()));
|
||||||
|
}
|
||||||
|
|
||||||
proptest::proptest! {
|
proptest::proptest! {
|
||||||
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
|
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -108,6 +108,82 @@ impl BollingerBands {
|
|||||||
self.multiplier
|
self.multiplier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vectorized flat batch for bindings: returns `n * 4` values laid out as
|
||||||
|
/// `[upper, middle, lower, stddev]` per input row, warmup rows all `NaN`.
|
||||||
|
///
|
||||||
|
/// For a fresh, all-finite slice it inlines `update`'s rolling `sum`/`sum_sq`
|
||||||
|
/// and drift-reseed, writing the four band values directly instead of an
|
||||||
|
/// `Option<BollingerOutput>` per element. Same add/subtract order, same reseed
|
||||||
|
/// cadence, same variance/`sqrt` math — so it is *bit-for-bit* equal to
|
||||||
|
/// replaying `update`, including the long-stream drift bound. Any other state,
|
||||||
|
/// or a non-finite element, defers to the exact `update` replay.
|
||||||
|
///
|
||||||
|
/// This is a *separate* entry point from the trait [`batch`](crate::BatchExt::batch),
|
||||||
|
/// which returns `Vec<Option<BollingerOutput>>`; only the bindings, which want
|
||||||
|
/// a flat `f64` buffer, call this.
|
||||||
|
pub fn batch_bands(&mut self, inputs: &[f64]) -> Vec<f64> {
|
||||||
|
let p = self.period;
|
||||||
|
let n = inputs.len();
|
||||||
|
if self.count != 0
|
||||||
|
|| self.updates_since_recompute != 0
|
||||||
|
|| !inputs.iter().all(|x| x.is_finite())
|
||||||
|
{
|
||||||
|
// Slow path: exact replay of `update` into the flat layout.
|
||||||
|
let mut out = vec![f64::NAN; n * 4];
|
||||||
|
for (i, &x) in inputs.iter().enumerate() {
|
||||||
|
if let Some(o) = self.update(x) {
|
||||||
|
out[i * 4] = o.upper;
|
||||||
|
out[i * 4 + 1] = o.middle;
|
||||||
|
out[i * 4 + 2] = o.lower;
|
||||||
|
out[i * 4 + 3] = o.stddev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
let p_f64 = p as f64;
|
||||||
|
let mult = self.multiplier;
|
||||||
|
// Pre-sized output: warmup rows stay NaN, ready rows are written in place
|
||||||
|
// by index — no per-row `push` length/capacity check.
|
||||||
|
let mut out = vec![f64::NAN; n * 4];
|
||||||
|
for (i, &x) in inputs.iter().enumerate() {
|
||||||
|
if self.count == p {
|
||||||
|
let old = self.buf[self.head];
|
||||||
|
self.sum -= old;
|
||||||
|
self.sum_sq -= old * old;
|
||||||
|
self.buf[self.head] = x;
|
||||||
|
self.sum += x;
|
||||||
|
self.sum_sq += x * x;
|
||||||
|
} else {
|
||||||
|
self.buf[self.head] = x;
|
||||||
|
self.sum += x;
|
||||||
|
self.sum_sq += x * x;
|
||||||
|
self.count += 1;
|
||||||
|
}
|
||||||
|
self.head += 1;
|
||||||
|
if self.head == p {
|
||||||
|
self.head = 0;
|
||||||
|
}
|
||||||
|
self.updates_since_recompute += 1;
|
||||||
|
if self.updates_since_recompute >= RECOMPUTE_EVERY * p {
|
||||||
|
let chronological = self.buf[self.head..].iter().chain(&self.buf[..self.head]);
|
||||||
|
self.sum = chronological.clone().copied().sum();
|
||||||
|
self.sum_sq = chronological.map(|&v| v * v).sum();
|
||||||
|
self.updates_since_recompute = 0;
|
||||||
|
}
|
||||||
|
if self.count == p {
|
||||||
|
let mean = self.sum / p_f64;
|
||||||
|
let stddev = (self.sum_sq / p_f64 - mean * mean).max(0.0).sqrt();
|
||||||
|
let band = mult * stddev;
|
||||||
|
out[i * 4] = mean + band;
|
||||||
|
out[i * 4 + 1] = mean;
|
||||||
|
out[i * 4 + 2] = mean - band;
|
||||||
|
out[i * 4 + 3] = stddev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn current(&self) -> Option<BollingerOutput> {
|
fn current(&self) -> Option<BollingerOutput> {
|
||||||
if self.count != self.period {
|
if self.count != self.period {
|
||||||
return None;
|
return None;
|
||||||
@@ -352,6 +428,79 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bits_eq(a: &[f64], b: &[f64]) -> bool {
|
||||||
|
a.len() == b.len()
|
||||||
|
&& a.iter()
|
||||||
|
.zip(b)
|
||||||
|
.all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flat `n*4` `[upper, middle, lower, stddev]` replay of `update`.
|
||||||
|
fn bb_replay(period: usize, mult: f64, series: &[f64]) -> Vec<f64> {
|
||||||
|
let mut bb = BollingerBands::new(period, mult).unwrap();
|
||||||
|
let mut out = Vec::with_capacity(series.len() * 4);
|
||||||
|
for &x in series {
|
||||||
|
match bb.update(x) {
|
||||||
|
Some(o) => out.extend_from_slice(&[o.upper, o.middle, o.lower, o.stddev]),
|
||||||
|
None => out.extend_from_slice(&[f64::NAN; 4]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_bands_fast_path_is_bit_identical_with_reseed() {
|
||||||
|
// > 16*period inputs so the drift-reseed branch fires inside batch_bands.
|
||||||
|
let series: Vec<f64> = (0..500)
|
||||||
|
.map(|i| (f64::from(i) * 0.2).sin() * 10.0 + 50.0)
|
||||||
|
.collect();
|
||||||
|
let mut bb = BollingerBands::new(20, 2.0).unwrap();
|
||||||
|
let got = bb.batch_bands(&series);
|
||||||
|
assert!(bits_eq(&got, &bb_replay(20, 2.0, &series)));
|
||||||
|
// State continues identically.
|
||||||
|
let mut ref_bb = BollingerBands::new(20, 2.0).unwrap();
|
||||||
|
for &x in &series {
|
||||||
|
ref_bb.update(x);
|
||||||
|
}
|
||||||
|
assert_eq!(bb.update(55.0), ref_bb.update(55.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_bands_falls_back_on_non_finite() {
|
||||||
|
let series = [1.0, 2.0, 3.0, f64::NAN, 5.0, 6.0, 7.0];
|
||||||
|
let mut bb = BollingerBands::new(3, 2.0).unwrap();
|
||||||
|
assert!(bits_eq(
|
||||||
|
&bb.batch_bands(&series),
|
||||||
|
&bb_replay(3, 2.0, &series)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_bands_falls_back_when_not_fresh() {
|
||||||
|
let mut bb = BollingerBands::new(3, 2.0).unwrap();
|
||||||
|
bb.update(99.0);
|
||||||
|
let series = [1.0, 2.0, 3.0, 4.0];
|
||||||
|
let mut ref_bb = BollingerBands::new(3, 2.0).unwrap();
|
||||||
|
ref_bb.update(99.0);
|
||||||
|
let mut want = Vec::new();
|
||||||
|
for &x in &series {
|
||||||
|
match ref_bb.update(x) {
|
||||||
|
Some(o) => want.extend_from_slice(&[o.upper, o.middle, o.lower, o.stddev]),
|
||||||
|
None => want.extend_from_slice(&[f64::NAN; 4]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(bits_eq(&bb.batch_bands(&series), &want));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_bands_sub_period_slice_is_all_nan() {
|
||||||
|
let series = [1.0, 2.0, 3.0];
|
||||||
|
let mut bb = BollingerBands::new(10, 2.0).unwrap();
|
||||||
|
let got = bb.batch_bands(&series);
|
||||||
|
assert!(bits_eq(&got, &bb_replay(10, 2.0, &series)));
|
||||||
|
assert!(got.iter().all(|x| x.is_nan()) && got.len() == 12);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ignores_non_finite_input() {
|
fn ignores_non_finite_input() {
|
||||||
let mut bb = BollingerBands::new(5, 2.0).unwrap();
|
let mut bb = BollingerBands::new(5, 2.0).unwrap();
|
||||||
|
|||||||
@@ -102,6 +102,68 @@ impl Ema {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the EMA has seen no input yet (neither seeded nor mid-warmup).
|
||||||
|
/// Lets composite indicators (e.g. MACD) decide if a fast batch path is safe.
|
||||||
|
pub(crate) fn is_fresh(&self) -> bool {
|
||||||
|
!self.seeded && self.warmup_buf.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force the EMA into its seeded steady state with `current` as the latest
|
||||||
|
/// value. Used by composite fused batch paths (MACD) to leave each sub-EMA
|
||||||
|
/// where a per-tick `update` replay would, so a later `update` continues
|
||||||
|
/// correctly. The post-seed recurrence never re-reads `warmup_buf`, so it is
|
||||||
|
/// left as-is.
|
||||||
|
pub(crate) fn seed_to(&mut self, current: f64) {
|
||||||
|
self.current = current;
|
||||||
|
self.seeded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vectorized batch returning one `f64` per input (`NaN` during warmup).
|
||||||
|
///
|
||||||
|
/// Shadows the generic [`BatchNanExt::batch_nan`](crate::BatchNanExt) blanket
|
||||||
|
/// default via inherent-method resolution. For a fresh indicator over an
|
||||||
|
/// all-finite slice it runs the seed (mean of the first `period`) once and
|
||||||
|
/// then the bare `alpha * x + (1 - alpha) * prev` recurrence in a tight loop
|
||||||
|
/// with no per-element `is_finite`/`seeded` branch and no `Option` — yet uses
|
||||||
|
/// the identical `mul_add`, so the result is *bit-for-bit* equal to replaying
|
||||||
|
/// `update`. Any other state, or a non-finite element, defers to the exact
|
||||||
|
/// `update` replay.
|
||||||
|
pub fn batch_nan(&mut self, inputs: &[f64]) -> Vec<f64> {
|
||||||
|
let p = self.period;
|
||||||
|
if self.seeded || !self.warmup_buf.is_empty() || !inputs.iter().all(|x| x.is_finite()) {
|
||||||
|
return inputs
|
||||||
|
.iter()
|
||||||
|
.map(|&x| self.update(x).unwrap_or(f64::NAN))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let n = inputs.len();
|
||||||
|
if n < p {
|
||||||
|
// Not enough to seed; mirror `update` stashing inputs for warmup.
|
||||||
|
self.warmup_buf.extend_from_slice(inputs);
|
||||||
|
return vec![f64::NAN; n];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warmup `[0, p-1)` is `NaN`; values from the seed on are pushed once each.
|
||||||
|
let mut out = vec![f64::NAN; p - 1];
|
||||||
|
out.reserve(n - (p - 1));
|
||||||
|
let seed = inputs[..p].iter().copied().sum::<f64>() / p as f64;
|
||||||
|
let mut cur = seed;
|
||||||
|
out.push(seed);
|
||||||
|
let (alpha, oma) = (self.alpha, self.one_minus_alpha);
|
||||||
|
for &x in &inputs[p..] {
|
||||||
|
cur = alpha.mul_add(x, oma * cur);
|
||||||
|
out.push(cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave state exactly where `update` would: seeded on `current`, with the
|
||||||
|
// first `period` inputs retained in `warmup_buf` (never cleared post-seed).
|
||||||
|
self.current = cur;
|
||||||
|
self.seeded = true;
|
||||||
|
self.warmup_buf.extend_from_slice(&inputs[..p]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Internal helper that feeds a value without finiteness validation. The caller
|
/// Internal helper that feeds a value without finiteness validation. The caller
|
||||||
/// guarantees `input.is_finite()`. Used by MACD which has already validated.
|
/// guarantees `input.is_finite()`. Used by MACD which has already validated.
|
||||||
pub(crate) fn step_unchecked(&mut self, input: f64) -> Option<f64> {
|
pub(crate) fn step_unchecked(&mut self, input: f64) -> Option<f64> {
|
||||||
@@ -288,6 +350,71 @@ mod tests {
|
|||||||
assert_eq!(ema.update(f64::INFINITY), before);
|
assert_eq!(ema.update(f64::INFINITY), before);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bits_eq(a: &[f64], b: &[f64]) -> bool {
|
||||||
|
a.len() == b.len()
|
||||||
|
&& a.iter()
|
||||||
|
.zip(b)
|
||||||
|
.all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ema_replay(period: usize, series: &[f64]) -> Vec<f64> {
|
||||||
|
let mut e = Ema::new(period).unwrap();
|
||||||
|
series
|
||||||
|
.iter()
|
||||||
|
.map(|&x| e.update(x).unwrap_or(f64::NAN))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_nan_fast_path_is_bit_identical() {
|
||||||
|
let series: Vec<f64> = (0..300)
|
||||||
|
.map(|i| (f64::from(i) * 0.25).cos() * 8.0 + 40.0)
|
||||||
|
.collect();
|
||||||
|
let mut ema = Ema::new(14).unwrap();
|
||||||
|
let got = ema.batch_nan(&series);
|
||||||
|
assert!(bits_eq(&got, &ema_replay(14, &series)));
|
||||||
|
let mut ref_ema = Ema::new(14).unwrap();
|
||||||
|
for &x in &series {
|
||||||
|
ref_ema.update(x);
|
||||||
|
}
|
||||||
|
assert_eq!(ema.update(7.5), ref_ema.update(7.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_nan_falls_back_on_non_finite() {
|
||||||
|
let series = [1.0, 2.0, 3.0, f64::INFINITY, 5.0, 6.0, 7.0];
|
||||||
|
let mut ema = Ema::new(3).unwrap();
|
||||||
|
assert!(bits_eq(&ema.batch_nan(&series), &ema_replay(3, &series)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_nan_falls_back_when_warming() {
|
||||||
|
let mut ema = Ema::new(3).unwrap();
|
||||||
|
ema.update(10.0); // mid-warmup: warmup_buf non-empty, not seeded
|
||||||
|
let series = [1.0, 2.0, 3.0, 4.0];
|
||||||
|
let mut ref_ema = Ema::new(3).unwrap();
|
||||||
|
ref_ema.update(10.0);
|
||||||
|
let want: Vec<f64> = series
|
||||||
|
.iter()
|
||||||
|
.map(|&x| ref_ema.update(x).unwrap_or(f64::NAN))
|
||||||
|
.collect();
|
||||||
|
assert!(bits_eq(&ema.batch_nan(&series), &want));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_nan_sub_period_slice_stays_unseeded() {
|
||||||
|
let series = [1.0, 2.0];
|
||||||
|
let mut ema = Ema::new(5).unwrap();
|
||||||
|
let got = ema.batch_nan(&series);
|
||||||
|
assert!(got.iter().all(|x| x.is_nan()) && got.len() == 2);
|
||||||
|
assert!(!ema.is_ready());
|
||||||
|
// Warmup state was stashed: feeding the rest seeds exactly as a full stream.
|
||||||
|
assert!(bits_eq(
|
||||||
|
&[ema.update(3.0).unwrap_or(f64::NAN)],
|
||||||
|
&[ema_replay(5, &[1.0, 2.0, 3.0])[2]]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
proptest::proptest! {
|
proptest::proptest! {
|
||||||
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
|
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -86,6 +86,116 @@ impl MacdIndicator {
|
|||||||
pub const fn value(&self) -> Option<MacdOutput> {
|
pub const fn value(&self) -> Option<MacdOutput> {
|
||||||
self.last
|
self.last
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vectorized flat batch for bindings: `n * 3` values laid out as
|
||||||
|
/// `[macd, signal, histogram]` per input row, warmup rows all `NaN`.
|
||||||
|
///
|
||||||
|
/// For a fresh, all-finite slice long enough for a full output it runs the
|
||||||
|
/// fast EMA, slow EMA and signal EMA as three recurrences fused into a single
|
||||||
|
/// pass with one allocation — no `Option` per tick, no per-EMA intermediate
|
||||||
|
/// buffers, identical SMA-mean seeds (division) and `mul_add` recurrences. The
|
||||||
|
/// result is *bit-for-bit* equal to replaying `update`. Anything else (not
|
||||||
|
/// fresh, non-finite, or too short to emit) defers to the exact `update`
|
||||||
|
/// replay.
|
||||||
|
///
|
||||||
|
/// Separate from the trait [`batch`](crate::BatchExt::batch), which stays a
|
||||||
|
/// bit-identical `update` replay; only the bindings call this.
|
||||||
|
pub fn batch_macd(&mut self, inputs: &[f64]) -> Vec<f64> {
|
||||||
|
let n = inputs.len();
|
||||||
|
let (fp, sp, gp) = (self.fast_period, self.slow_period, self.signal_period);
|
||||||
|
// First full output needs the slow EMA seeded (index sp-1) plus gp signal
|
||||||
|
// values: index sp + gp - 2. Below that, or non-fresh/non-finite, replay.
|
||||||
|
if self.last.is_some()
|
||||||
|
|| !self.fast.is_fresh()
|
||||||
|
|| !self.slow.is_fresh()
|
||||||
|
|| !self.signal_ema.is_fresh()
|
||||||
|
|| n < sp + gp - 1
|
||||||
|
|| !inputs.iter().all(|x| x.is_finite())
|
||||||
|
{
|
||||||
|
let mut out = vec![f64::NAN; n * 3];
|
||||||
|
for (i, &x) in inputs.iter().enumerate() {
|
||||||
|
if let Some(o) = self.update(x) {
|
||||||
|
out[i * 3] = o.macd;
|
||||||
|
out[i * 3 + 1] = o.signal;
|
||||||
|
out[i * 3 + 2] = o.histogram;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-sized output: warmup rows stay NaN, full-output rows are written in
|
||||||
|
// place by index — no per-row `push` length/capacity check.
|
||||||
|
let mut out = vec![f64::NAN; n * 3];
|
||||||
|
let (fa, fo) = (self.fast.alpha(), 1.0 - self.fast.alpha());
|
||||||
|
let (sa, so) = (self.slow.alpha(), 1.0 - self.slow.alpha());
|
||||||
|
let (ga, go) = (self.signal_ema.alpha(), 1.0 - self.signal_ema.alpha());
|
||||||
|
let (fp_f, sp_f, gp_f) = (fp as f64, sp as f64, gp as f64);
|
||||||
|
|
||||||
|
let (mut fast_val, mut slow_val, mut sig) = (0.0_f64, 0.0_f64, 0.0_f64);
|
||||||
|
let (mut fsum, mut ssum, mut gsum) = (0.0_f64, 0.0_f64, 0.0_f64);
|
||||||
|
let mut sig_count = 0usize; // signal-EMA seed progress (raw MACD values seen)
|
||||||
|
let mut sig_seeded = false;
|
||||||
|
let mut last = MacdOutput {
|
||||||
|
macd: 0.0,
|
||||||
|
signal: 0.0,
|
||||||
|
histogram: 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (i, &x) in inputs.iter().enumerate() {
|
||||||
|
// Fast EMA: SMA-seeded at index fp-1, then recurrence.
|
||||||
|
if i < fp {
|
||||||
|
fsum += x;
|
||||||
|
if i == fp - 1 {
|
||||||
|
fast_val = fsum / fp_f;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fast_val = fa.mul_add(x, fo * fast_val);
|
||||||
|
}
|
||||||
|
// Slow EMA: SMA-seeded at index sp-1, then recurrence.
|
||||||
|
if i < sp {
|
||||||
|
ssum += x;
|
||||||
|
if i == sp - 1 {
|
||||||
|
slow_val = ssum / sp_f;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
slow_val = sa.mul_add(x, so * slow_val);
|
||||||
|
}
|
||||||
|
if i + 1 < sp {
|
||||||
|
continue; // slow EMA not seeded yet → no raw MACD line
|
||||||
|
}
|
||||||
|
let macd = fast_val - slow_val;
|
||||||
|
// Signal EMA over the MACD line: SMA-seeded over its first gp values.
|
||||||
|
let signal = if sig_seeded {
|
||||||
|
sig = ga.mul_add(macd, go * sig);
|
||||||
|
sig
|
||||||
|
} else {
|
||||||
|
gsum += macd;
|
||||||
|
sig_count += 1;
|
||||||
|
if sig_count < gp {
|
||||||
|
continue; // signal EMA still seeding → no full output
|
||||||
|
}
|
||||||
|
sig = gsum / gp_f;
|
||||||
|
sig_seeded = true;
|
||||||
|
sig
|
||||||
|
};
|
||||||
|
let histogram = macd - signal;
|
||||||
|
out[i * 3] = macd;
|
||||||
|
out[i * 3 + 1] = signal;
|
||||||
|
out[i * 3 + 2] = histogram;
|
||||||
|
last = MacdOutput {
|
||||||
|
macd,
|
||||||
|
signal,
|
||||||
|
histogram,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave every sub-EMA and `last` where a full `update` replay would.
|
||||||
|
self.fast.seed_to(fast_val);
|
||||||
|
self.slow.seed_to(slow_val);
|
||||||
|
self.signal_ema.seed_to(sig);
|
||||||
|
self.last = Some(last);
|
||||||
|
out
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Indicator for MacdIndicator {
|
impl Indicator for MacdIndicator {
|
||||||
@@ -256,6 +366,79 @@ mod tests {
|
|||||||
assert_eq!(macd.update(1.0), None);
|
assert_eq!(macd.update(1.0), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bits_eq(a: &[f64], b: &[f64]) -> bool {
|
||||||
|
a.len() == b.len()
|
||||||
|
&& a.iter()
|
||||||
|
.zip(b)
|
||||||
|
.all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flat `n*3` `[macd, signal, histogram]` replay of `update`.
|
||||||
|
fn macd_replay(series: &[f64]) -> Vec<f64> {
|
||||||
|
let mut m = MacdIndicator::classic();
|
||||||
|
let mut out = Vec::with_capacity(series.len() * 3);
|
||||||
|
for &x in series {
|
||||||
|
match m.update(x) {
|
||||||
|
Some(o) => out.extend_from_slice(&[o.macd, o.signal, o.histogram]),
|
||||||
|
None => out.extend_from_slice(&[f64::NAN; 3]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_macd_fast_path_is_bit_identical() {
|
||||||
|
let series: Vec<f64> = (0..300)
|
||||||
|
.map(|i| (f64::from(i) * 0.4).cos() * 10.0 + 100.0)
|
||||||
|
.collect();
|
||||||
|
let mut macd = MacdIndicator::classic();
|
||||||
|
let got = macd.batch_macd(&series);
|
||||||
|
assert!(bits_eq(&got, &macd_replay(&series)));
|
||||||
|
// Sub-EMA + last state left where the replay would: continued update agrees.
|
||||||
|
let mut ref_macd = MacdIndicator::classic();
|
||||||
|
for &x in &series {
|
||||||
|
ref_macd.update(x);
|
||||||
|
}
|
||||||
|
let (a, b) = (macd.update(101.0), ref_macd.update(101.0));
|
||||||
|
assert_eq!(a.is_some(), b.is_some());
|
||||||
|
assert_relative_eq!(a.unwrap().macd, b.unwrap().macd, epsilon = 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_macd_falls_back_on_non_finite() {
|
||||||
|
let mut series: Vec<f64> = (0..60).map(|i| f64::from(i) + 100.0).collect();
|
||||||
|
series[40] = f64::NAN;
|
||||||
|
let mut macd = MacdIndicator::classic();
|
||||||
|
assert!(bits_eq(&macd.batch_macd(&series), &macd_replay(&series)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_macd_falls_back_when_not_fresh() {
|
||||||
|
let series: Vec<f64> = (0..60).map(|i| f64::from(i) + 100.0).collect();
|
||||||
|
let mut macd = MacdIndicator::classic();
|
||||||
|
macd.update(50.0);
|
||||||
|
let mut ref_macd = MacdIndicator::classic();
|
||||||
|
ref_macd.update(50.0);
|
||||||
|
let mut want = Vec::new();
|
||||||
|
for &x in &series {
|
||||||
|
match ref_macd.update(x) {
|
||||||
|
Some(o) => want.extend_from_slice(&[o.macd, o.signal, o.histogram]),
|
||||||
|
None => want.extend_from_slice(&[f64::NAN; 3]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(bits_eq(&macd.batch_macd(&series), &want));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_macd_too_short_for_output_falls_back() {
|
||||||
|
// n < slow + signal - 1 (= 34): no full output, routed to the replay.
|
||||||
|
let series: Vec<f64> = (0..20).map(|i| f64::from(i) + 100.0).collect();
|
||||||
|
let mut macd = MacdIndicator::classic();
|
||||||
|
let got = macd.batch_macd(&series);
|
||||||
|
assert!(bits_eq(&got, &macd_replay(&series)));
|
||||||
|
assert!(got.iter().all(|x| x.is_nan()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ignores_non_finite_input() {
|
fn ignores_non_finite_input() {
|
||||||
let mut macd = MacdIndicator::classic();
|
let mut macd = MacdIndicator::classic();
|
||||||
|
|||||||
@@ -81,6 +81,76 @@ impl Rsi {
|
|||||||
self.last_value
|
self.last_value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vectorized batch returning one `f64` per input (`NaN` during warmup).
|
||||||
|
///
|
||||||
|
/// Shadows the generic [`BatchNanExt::batch_nan`](crate::BatchNanExt) blanket
|
||||||
|
/// default. RSI is a recursive (IIR) filter — Wilder smoothing — so it cannot
|
||||||
|
/// be SIMD-vectorized any more than the C peers manage; the win is purely in
|
||||||
|
/// stripping per-tick overhead. For a fresh indicator over an all-finite slice
|
||||||
|
/// long enough to seed (`n > period`) it runs the seed once and then the bare
|
||||||
|
/// smoothing recurrence in a tight loop with no per-tick `is_finite`/`has_prev`/
|
||||||
|
/// `avgs_seeded` branch and no `Option`, using the identical division at the
|
||||||
|
/// seed and `mul_add`/`rsi_from_avgs` afterwards — so it is *bit-for-bit* equal
|
||||||
|
/// to replaying `update`. Shorter or non-fresh/non-finite inputs defer to the
|
||||||
|
/// exact `update` replay.
|
||||||
|
pub fn batch_nan(&mut self, inputs: &[f64]) -> Vec<f64> {
|
||||||
|
let p = self.period;
|
||||||
|
let n = inputs.len();
|
||||||
|
if self.has_prev
|
||||||
|
|| self.avgs_seeded
|
||||||
|
|| !self.seed_buf_gains.is_empty()
|
||||||
|
|| n <= p
|
||||||
|
|| !inputs.iter().all(|x| x.is_finite())
|
||||||
|
{
|
||||||
|
return inputs
|
||||||
|
.iter()
|
||||||
|
.map(|&x| self.update(x).unwrap_or(f64::NAN))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warmup `[0, p)` is `NaN`; outputs from index `p` on are pushed once each.
|
||||||
|
let mut out = vec![f64::NAN; p];
|
||||||
|
out.reserve(n - p);
|
||||||
|
// Seed from the first `period` diffs (inputs[1..=p]); index 0 only sets the
|
||||||
|
// baseline. Retain the seed gains/losses exactly as `update` leaves them.
|
||||||
|
let mut prev = inputs[0];
|
||||||
|
let (mut sum_gain, mut sum_loss) = (0.0_f64, 0.0_f64);
|
||||||
|
for &x in &inputs[1..=p] {
|
||||||
|
let diff = x - prev;
|
||||||
|
prev = x;
|
||||||
|
let gain = if diff > 0.0 { diff } else { 0.0 };
|
||||||
|
let loss = if diff < 0.0 { -diff } else { 0.0 };
|
||||||
|
self.seed_buf_gains.push(gain);
|
||||||
|
self.seed_buf_losses.push(loss);
|
||||||
|
sum_gain += gain;
|
||||||
|
sum_loss += loss;
|
||||||
|
}
|
||||||
|
let p_f64 = p as f64;
|
||||||
|
let mut ag = sum_gain / p_f64;
|
||||||
|
let mut al = sum_loss / p_f64;
|
||||||
|
out.push(Self::rsi_from_avgs(ag, al));
|
||||||
|
|
||||||
|
// Steady state: Wilder smoothing, reciprocal hoisted, one `rsi_from_avgs`.
|
||||||
|
for &x in &inputs[p + 1..] {
|
||||||
|
let diff = x - prev;
|
||||||
|
prev = x;
|
||||||
|
let gain = if diff > 0.0 { diff } else { 0.0 };
|
||||||
|
let loss = if diff < 0.0 { -diff } else { 0.0 };
|
||||||
|
ag = ag.mul_add(self.n_minus_1, gain) * self.inv_period;
|
||||||
|
al = al.mul_add(self.n_minus_1, loss) * self.inv_period;
|
||||||
|
out.push(Self::rsi_from_avgs(ag, al));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave state where a full `update` replay would.
|
||||||
|
self.prev_close = prev;
|
||||||
|
self.has_prev = true;
|
||||||
|
self.avg_gain = ag;
|
||||||
|
self.avg_loss = al;
|
||||||
|
self.avgs_seeded = true;
|
||||||
|
self.last_value = Some(out[n - 1]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
|
fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
|
||||||
// Algebraically `100 - 100/(1 + ag/al)` collapses to `100·ag/(ag+al)`,
|
// Algebraically `100 - 100/(1 + ag/al)` collapses to `100·ag/(ag+al)`,
|
||||||
// which needs a single division instead of two and removes the separate
|
// which needs a single division instead of two and removes the separate
|
||||||
@@ -376,6 +446,65 @@ mod tests {
|
|||||||
assert_eq!(rsi.value(), before);
|
assert_eq!(rsi.value(), before);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bits_eq(a: &[f64], b: &[f64]) -> bool {
|
||||||
|
a.len() == b.len()
|
||||||
|
&& a.iter()
|
||||||
|
.zip(b)
|
||||||
|
.all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rsi_replay(period: usize, series: &[f64]) -> Vec<f64> {
|
||||||
|
let mut r = Rsi::new(period).unwrap();
|
||||||
|
series
|
||||||
|
.iter()
|
||||||
|
.map(|&x| r.update(x).unwrap_or(f64::NAN))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_nan_fast_path_is_bit_identical() {
|
||||||
|
let series: Vec<f64> = (0..300)
|
||||||
|
.map(|i| (f64::from(i) * 0.3).sin() * 5.0 + f64::from(i) * 0.1 + 100.0)
|
||||||
|
.collect();
|
||||||
|
let mut rsi = Rsi::new(14).unwrap();
|
||||||
|
let got = rsi.batch_nan(&series);
|
||||||
|
assert!(bits_eq(&got, &rsi_replay(14, &series)));
|
||||||
|
let mut ref_rsi = Rsi::new(14).unwrap();
|
||||||
|
for &x in &series {
|
||||||
|
ref_rsi.update(x);
|
||||||
|
}
|
||||||
|
assert_eq!(rsi.update(123.0), ref_rsi.update(123.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_nan_falls_back_on_non_finite() {
|
||||||
|
let series = [10.0, 11.0, 9.0, f64::NAN, 12.0, 13.0, 8.0];
|
||||||
|
let mut rsi = Rsi::new(3).unwrap();
|
||||||
|
assert!(bits_eq(&rsi.batch_nan(&series), &rsi_replay(3, &series)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_nan_falls_back_when_not_fresh() {
|
||||||
|
let mut rsi = Rsi::new(3).unwrap();
|
||||||
|
rsi.update(50.0);
|
||||||
|
let series = [51.0, 49.0, 52.0, 53.0, 50.0];
|
||||||
|
let mut ref_rsi = Rsi::new(3).unwrap();
|
||||||
|
ref_rsi.update(50.0);
|
||||||
|
let want: Vec<f64> = series
|
||||||
|
.iter()
|
||||||
|
.map(|&x| ref_rsi.update(x).unwrap_or(f64::NAN))
|
||||||
|
.collect();
|
||||||
|
assert!(bits_eq(&rsi.batch_nan(&series), &want));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_nan_too_short_to_seed_falls_back() {
|
||||||
|
// n <= period: routed to the exact replay (cannot seed yet).
|
||||||
|
let series = [10.0, 11.0, 12.0];
|
||||||
|
let mut rsi = Rsi::new(3).unwrap();
|
||||||
|
assert!(bits_eq(&rsi.batch_nan(&series), &rsi_replay(3, &series)));
|
||||||
|
}
|
||||||
|
|
||||||
proptest::proptest! {
|
proptest::proptest! {
|
||||||
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
|
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -86,6 +86,62 @@ impl Sma {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vectorized batch returning one `f64` per input (`NaN` during warmup).
|
||||||
|
///
|
||||||
|
/// Shadows the generic [`BatchNanExt::batch_nan`](crate::BatchNanExt) blanket
|
||||||
|
/// default via inherent-method resolution. For a fresh, all-finite slice it
|
||||||
|
/// inlines `update`'s rolling sum and drift-reseed, writing the mean as a bare
|
||||||
|
/// `f64` (warmup → `NaN`) instead of allocating an `Option<f64>` per element
|
||||||
|
/// and walking the result a second time. Same add/subtract order, same reseed
|
||||||
|
/// cadence, same `sum / period` division — so it is *bit-for-bit* equal to
|
||||||
|
/// replaying `update`, including the long-stream drift bound. Any other state,
|
||||||
|
/// or a non-finite element, defers to the exact `update` replay.
|
||||||
|
pub fn batch_nan(&mut self, inputs: &[f64]) -> Vec<f64> {
|
||||||
|
let p = self.period;
|
||||||
|
if self.count != 0
|
||||||
|
|| self.updates_since_recompute != 0
|
||||||
|
|| !inputs.iter().all(|x| x.is_finite())
|
||||||
|
{
|
||||||
|
return inputs
|
||||||
|
.iter()
|
||||||
|
.map(|&x| self.update(x).unwrap_or(f64::NAN))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let p_f64 = p as f64;
|
||||||
|
let mut out = Vec::with_capacity(inputs.len());
|
||||||
|
for &x in inputs {
|
||||||
|
if self.count == p {
|
||||||
|
self.sum -= self.buf[self.head];
|
||||||
|
self.buf[self.head] = x;
|
||||||
|
self.sum += x;
|
||||||
|
} else {
|
||||||
|
self.buf[self.head] = x;
|
||||||
|
self.sum += x;
|
||||||
|
self.count += 1;
|
||||||
|
}
|
||||||
|
self.head += 1;
|
||||||
|
if self.head == p {
|
||||||
|
self.head = 0;
|
||||||
|
}
|
||||||
|
self.updates_since_recompute += 1;
|
||||||
|
if self.updates_since_recompute >= RECOMPUTE_EVERY * p {
|
||||||
|
self.sum = self.buf[self.head..]
|
||||||
|
.iter()
|
||||||
|
.chain(&self.buf[..self.head])
|
||||||
|
.copied()
|
||||||
|
.sum();
|
||||||
|
self.updates_since_recompute = 0;
|
||||||
|
}
|
||||||
|
out.push(if self.count == p {
|
||||||
|
self.sum / p_f64
|
||||||
|
} else {
|
||||||
|
f64::NAN
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Indicator for Sma {
|
impl Indicator for Sma {
|
||||||
@@ -246,6 +302,69 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// NaN-aware bit-equality for the `f64`-with-NaN-warmup batch outputs.
|
||||||
|
fn bits_eq(a: &[f64], b: &[f64]) -> bool {
|
||||||
|
a.len() == b.len()
|
||||||
|
&& a.iter()
|
||||||
|
.zip(b)
|
||||||
|
.all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sma_replay(period: usize, series: &[f64]) -> Vec<f64> {
|
||||||
|
let mut s = Sma::new(period).unwrap();
|
||||||
|
series
|
||||||
|
.iter()
|
||||||
|
.map(|&x| s.update(x).unwrap_or(f64::NAN))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_nan_fast_path_is_bit_identical_with_reseed() {
|
||||||
|
// > 16*period inputs so the drift-reseed branch fires inside batch_nan.
|
||||||
|
let series: Vec<f64> = (0..500)
|
||||||
|
.map(|i| (f64::from(i) * 0.2).sin() * 10.0 + 50.0)
|
||||||
|
.collect();
|
||||||
|
let mut sma = Sma::new(14).unwrap();
|
||||||
|
let got = sma.batch_nan(&series);
|
||||||
|
assert!(bits_eq(&got, &sma_replay(14, &series)));
|
||||||
|
// State left where the replay would: continued updates agree.
|
||||||
|
let mut ref_sma = Sma::new(14).unwrap();
|
||||||
|
for &x in &series {
|
||||||
|
ref_sma.update(x);
|
||||||
|
}
|
||||||
|
assert_eq!(sma.update(42.0), ref_sma.update(42.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_nan_falls_back_on_non_finite() {
|
||||||
|
let series = [1.0, 2.0, f64::NAN, 4.0, 5.0, 6.0];
|
||||||
|
let mut sma = Sma::new(3).unwrap();
|
||||||
|
assert!(bits_eq(&sma.batch_nan(&series), &sma_replay(3, &series)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_nan_falls_back_when_not_fresh() {
|
||||||
|
let mut sma = Sma::new(3).unwrap();
|
||||||
|
sma.update(99.0);
|
||||||
|
let series = [1.0, 2.0, 3.0, 4.0];
|
||||||
|
let mut ref_sma = Sma::new(3).unwrap();
|
||||||
|
ref_sma.update(99.0);
|
||||||
|
let want: Vec<f64> = series
|
||||||
|
.iter()
|
||||||
|
.map(|&x| ref_sma.update(x).unwrap_or(f64::NAN))
|
||||||
|
.collect();
|
||||||
|
assert!(bits_eq(&sma.batch_nan(&series), &want));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_nan_sub_period_slice_is_all_nan() {
|
||||||
|
let series = [1.0, 2.0, 3.0];
|
||||||
|
let mut sma = Sma::new(10).unwrap();
|
||||||
|
let got = sma.batch_nan(&series);
|
||||||
|
assert!(bits_eq(&got, &sma_replay(10, &series)));
|
||||||
|
assert!(got.iter().all(|x| x.is_nan()));
|
||||||
|
}
|
||||||
|
|
||||||
proptest::proptest! {
|
proptest::proptest! {
|
||||||
#![proptest_config(proptest::test_runner::Config::with_cases(64))]
|
#![proptest_config(proptest::test_runner::Config::with_cases(64))]
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -166,4 +166,4 @@ pub use indicators::PnfColumn;
|
|||||||
pub use indicators::RenkoBrick;
|
pub use indicators::RenkoBrick;
|
||||||
pub use microstructure::{Level, OrderBook, Side, Trade, TradeQuote};
|
pub use microstructure::{Level, OrderBook, Side, Trade, TradeQuote};
|
||||||
pub use ohlcv::{Candle, Tick};
|
pub use ohlcv::{Candle, Tick};
|
||||||
pub use traits::{BarBuilder, BatchExt, Chain, Indicator};
|
pub use traits::{BarBuilder, BatchExt, BatchNanExt, Chain, Indicator};
|
||||||
|
|||||||
@@ -90,6 +90,29 @@ pub trait BatchExt: Indicator {
|
|||||||
|
|
||||||
impl<T: Indicator> BatchExt for T {}
|
impl<T: Indicator> BatchExt for T {}
|
||||||
|
|
||||||
|
/// Fast batch for scalar `f64 -> f64` indicators.
|
||||||
|
///
|
||||||
|
/// The generic [`BatchExt::batch`] returns `Vec<Option<f64>>` — 16 bytes per
|
||||||
|
/// element (no niche fits an arbitrary `f64`), which a caller wanting a dense
|
||||||
|
/// `f64` series then has to walk a second time to map warmup `None`s to `NaN`.
|
||||||
|
/// This skips both the wide intermediate and the second pass: one allocation,
|
||||||
|
/// one pass, warmup encoded as `NaN`. The default body is bit-identical to
|
||||||
|
/// replaying `update`; indicators with a vectorizable closed form override it
|
||||||
|
/// with an inherent `batch_nan` of the same name, which wins method resolution
|
||||||
|
/// over this trait default.
|
||||||
|
pub trait BatchNanExt: Indicator<Input = f64, Output = f64> {
|
||||||
|
/// One `f64` per input, warmup positions filled with `NaN`.
|
||||||
|
fn batch_nan(&mut self, inputs: &[f64]) -> Vec<f64> {
|
||||||
|
let mut out = Vec::with_capacity(inputs.len());
|
||||||
|
for &x in inputs {
|
||||||
|
out.push(self.update(x).unwrap_or(f64::NAN));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Indicator<Input = f64, Output = f64>> BatchNanExt for T {}
|
||||||
|
|
||||||
/// A streaming *bar builder* — an alternative-chart constructor (Renko, Kagi,
|
/// A streaming *bar builder* — an alternative-chart constructor (Renko, Kagi,
|
||||||
/// Point-and-Figure) that turns a candle stream into a stream of price-driven
|
/// Point-and-Figure) that turns a candle stream into a stream of price-driven
|
||||||
/// bars.
|
/// bars.
|
||||||
@@ -297,6 +320,17 @@ mod tests {
|
|||||||
assert_eq!(out, vec![Some(1.0), Some(2.0), Some(3.0)]);
|
assert_eq!(out, vec![Some(1.0), Some(2.0), Some(3.0)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The blanket [`BatchNanExt::batch_nan`] default (used by every scalar
|
||||||
|
/// indicator without an inherent fast path) maps `update` outputs to a dense
|
||||||
|
/// `f64` series, warmup `None` becoming `NaN`. `Identity` is always ready, so
|
||||||
|
/// the result is just the inputs back.
|
||||||
|
#[test]
|
||||||
|
fn batch_nan_default_maps_none_to_nan() {
|
||||||
|
let mut id = Identity::default();
|
||||||
|
let out = id.batch_nan(&[1.0, 2.0, 3.0]);
|
||||||
|
assert_eq!(out, vec![1.0, 2.0, 3.0]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chain_pipes_first_into_second() {
|
fn chain_pipes_first_into_second() {
|
||||||
let mut c = Chain::new(Doubler::default(), Doubler::default());
|
let mut c = Chain::new(Doubler::default(), Doubler::default());
|
||||||
|
|||||||
Reference in New Issue
Block a user