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`).
|
||||
- **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`).
|
||||
- **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
|
||||
- **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),
|
||||
[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
|
||||
projects I was building, so I decided to build one from the ground up — partly to
|
||||
learn, partly because I genuinely enjoy taking something that already exists and
|
||||
trying to do it differently (and, ideally, better). It's open source because the
|
||||
useful version of that itch is the one other people can build on too.
|
||||
Most TA libraries are fast, *or* multi-language, *or* broad. Wickra refuses to
|
||||
pick. It's the streaming-first engine built for the workload the others treat as
|
||||
an afterthought — **live, tick-by-tick data** — without giving up the breadth of
|
||||
a full batch library, and without making you reimplement your indicators four
|
||||
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 |
|
||||
|------------------|-------------|-------------|-----------------------------|-----------:|--------|
|
||||
| **★ 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 |
|
||||
| ta-rs | clean | yes | Rust only | ~30 | stale |
|
||||
| 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 |
|
||||
| talipp | clean | yes | Python | ~40 | yes |
|
||||
|
||||
Wickra's edge is **breadth with reach**: 467 indicators that all update in O(1)
|
||||
per tick and ship natively to Python, Node.js, WebAssembly and Rust from a
|
||||
single engine.
|
||||
Broad, multi-language, streaming-native **and** honest about its trade-offs — at
|
||||
the same time. That's the combination no one else ships.
|
||||
|
||||
**On speed — and why Wickra isn't the fastest.** It deliberately isn't. The
|
||||
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:
|
||||
every `update` validates its input, runs a real warmup before it emits a value,
|
||||
and returns an `Option` so a single bad tick can't silently poison the state.
|
||||
ta-rs, by contrast, hands back a bare `f64` from the first tick with no
|
||||
validation. If Wickra threw all of that away — raw `f64` out, no checks, no
|
||||
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.
|
||||
## Why Wickra exists
|
||||
|
||||
Wickra started as a personal itch. The existing TA libraries never quite fit the
|
||||
projects I was building, so I decided to build one from the ground up — partly to
|
||||
learn, partly because I genuinely enjoy taking something that already exists and
|
||||
trying to do it differently (and, ideally, better). It's open source because the
|
||||
useful version of that itch is the one other people can build on too.
|
||||
|
||||
## Benchmarks
|
||||
|
||||
Three comparisons, split by layer and mode. Read them as **relative** speedups
|
||||
on identical input — absolute µs depend on CPU, memory clock and OS scheduler,
|
||||
not a universal contract.
|
||||
Wickra updates every indicator in **O(1)** per tick. In **streaming** — the
|
||||
workload it is built for — it is **9–58× faster** than the only other incremental
|
||||
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,
|
||||
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. 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
|
||||
```
|
||||
Full tables (Rust + Python, streaming + batch) and how to reproduce them live in
|
||||
**[BENCHMARKS.md](BENCHMARKS.md)**.
|
||||
|
||||
## Indicators
|
||||
|
||||
|
||||
@@ -72,13 +72,23 @@ class Sample:
|
||||
return (self.seconds / self.iterations) * 1_000_000
|
||||
|
||||
|
||||
def time_call(fn: Callable[[], None], iterations: int) -> float:
|
||||
"""Time ``fn`` over ``iterations`` calls, returning total wall seconds."""
|
||||
def time_call(fn: Callable[[], None], iterations: int, rounds: int = 5) -> float:
|
||||
"""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
|
||||
start = time.perf_counter()
|
||||
for _ in range(iterations):
|
||||
fn()
|
||||
return time.perf_counter() - start
|
||||
rounds_s: List[float] = []
|
||||
for _ in range(rounds):
|
||||
start = time.perf_counter()
|
||||
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:
|
||||
@@ -457,6 +467,161 @@ def talipp_bollinger_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[C
|
||||
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
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -519,36 +684,54 @@ STREAMING_INDICATORS = [
|
||||
("SMA(20)", [
|
||||
("Wickra", wickra_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)", [
|
||||
("Wickra", wickra_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)", [
|
||||
("Wickra", wickra_rsi_streaming),
|
||||
("talipp", talipp_rsi_streaming),
|
||||
("TA-Lib", talib_rsi_streaming),
|
||||
("pandas-ta", pandas_ta_rsi_streaming),
|
||||
("talipp", talipp_rsi_streaming),
|
||||
("tulipy", tulipy_rsi_streaming),
|
||||
("finta", finta_rsi_streaming),
|
||||
]),
|
||||
("MACD(12, 26, 9)", [
|
||||
("Wickra", wickra_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)", [
|
||||
("Wickra", wickra_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] = []
|
||||
for indicator_name, libs in BATCH_INDICATORS:
|
||||
for lib_name, factory in libs:
|
||||
runner = factory(prices)
|
||||
if runner is None:
|
||||
continue
|
||||
secs = time_call(runner, iterations)
|
||||
secs = time_call(runner, iterations, rounds)
|
||||
out.append(Sample(lib_name, indicator_name, "batch", secs, iterations))
|
||||
return out
|
||||
|
||||
@@ -558,6 +741,7 @@ def run_ohlc(
|
||||
low: np.ndarray,
|
||||
close: np.ndarray,
|
||||
iterations: int,
|
||||
rounds: int,
|
||||
) -> List[Sample]:
|
||||
out: List[Sample] = []
|
||||
for indicator_name, libs in OHLC_INDICATORS:
|
||||
@@ -565,12 +749,12 @@ def run_ohlc(
|
||||
runner = factory(high, low, close)
|
||||
if runner is None:
|
||||
continue
|
||||
secs = time_call(runner, iterations)
|
||||
secs = time_call(runner, iterations, rounds)
|
||||
out.append(Sample(lib_name, indicator_name, "batch", secs, iterations))
|
||||
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] = []
|
||||
seed = 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)
|
||||
if runner is None:
|
||||
continue
|
||||
secs = time_call(runner, iterations)
|
||||
secs = time_call(runner, iterations, rounds)
|
||||
sample = Sample(lib_name, indicator_name, "streaming", secs, iterations)
|
||||
sample.iterations = iterations * len(live) # per-tick normalization
|
||||
out.append(sample)
|
||||
@@ -629,6 +813,12 @@ def parse_args() -> argparse.Namespace:
|
||||
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("--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(
|
||||
"--streaming-window",
|
||||
type=int,
|
||||
@@ -641,6 +831,14 @@ def parse_args() -> argparse.Namespace:
|
||||
default=3,
|
||||
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()
|
||||
|
||||
|
||||
@@ -660,11 +858,14 @@ def main() -> None:
|
||||
print(f"Streaming window: {args.streaming_window} seed, {args.size - args.streaming_window} live")
|
||||
|
||||
high, low, close, _ = gen_ohlc(args.size)
|
||||
batch_rows = run_batch(prices, args.iterations)
|
||||
ohlc_rows = run_ohlc(high, low, close, args.iterations)
|
||||
streaming_rows = run_streaming(prices, args.streaming_window, args.streaming_iterations)
|
||||
rows: List[Sample] = []
|
||||
if not args.skip_batch:
|
||||
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__":
|
||||
|
||||
+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 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 yata::prelude::Method;
|
||||
|
||||
@@ -82,7 +82,7 @@ fn sma_group(crit: &mut Criterion, closes: &[f64]) {
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
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.iter(|| {
|
||||
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.iter(|| {
|
||||
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.iter(|| {
|
||||
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.iter(|| {
|
||||
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),
|
||||
&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(|| {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -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_config(proptest::test_runner::Config::with_cases(48))]
|
||||
#[test]
|
||||
|
||||
@@ -108,6 +108,82 @@ impl BollingerBands {
|
||||
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> {
|
||||
if self.count != self.period {
|
||||
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]
|
||||
fn ignores_non_finite_input() {
|
||||
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
|
||||
/// guarantees `input.is_finite()`. Used by MACD which has already validated.
|
||||
pub(crate) fn step_unchecked(&mut self, input: f64) -> Option<f64> {
|
||||
@@ -288,6 +350,71 @@ mod tests {
|
||||
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_config(proptest::test_runner::Config::with_cases(48))]
|
||||
#[test]
|
||||
|
||||
@@ -86,6 +86,116 @@ impl MacdIndicator {
|
||||
pub const fn value(&self) -> Option<MacdOutput> {
|
||||
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 {
|
||||
@@ -256,6 +366,79 @@ mod tests {
|
||||
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]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut macd = MacdIndicator::classic();
|
||||
|
||||
@@ -81,6 +81,76 @@ impl Rsi {
|
||||
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 {
|
||||
// Algebraically `100 - 100/(1 + ag/al)` collapses to `100·ag/(ag+al)`,
|
||||
// which needs a single division instead of two and removes the separate
|
||||
@@ -376,6 +446,65 @@ mod tests {
|
||||
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_config(proptest::test_runner::Config::with_cases(48))]
|
||||
#[test]
|
||||
|
||||
@@ -86,6 +86,62 @@ impl Sma {
|
||||
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 {
|
||||
@@ -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_config(proptest::test_runner::Config::with_cases(64))]
|
||||
#[test]
|
||||
|
||||
@@ -166,4 +166,4 @@ pub use indicators::PnfColumn;
|
||||
pub use indicators::RenkoBrick;
|
||||
pub use microstructure::{Level, OrderBook, Side, Trade, TradeQuote};
|
||||
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 {}
|
||||
|
||||
/// 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,
|
||||
/// Point-and-Figure) that turns a candle stream into a stream of price-driven
|
||||
/// bars.
|
||||
@@ -297,6 +320,17 @@ mod tests {
|
||||
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]
|
||||
fn chain_pipes_first_into_second() {
|
||||
let mut c = Chain::new(Doubler::default(), Doubler::default());
|
||||
|
||||
Reference in New Issue
Block a user