From 05fe7ffa903dc412f45c38873ceaa526a5585f1b Mon Sep 17 00:00:00 2001 From: kingchenc Date: Mon, 8 Jun 2026 00:17:58 +0200 Subject: [PATCH] perf: bit-exact batch fast paths + streaming-first benchmark docs (#202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- BENCHMARKS.md | 96 ++++++ CHANGELOG.md | 2 + README.md | 158 +++------ .../python/benchmarks/compare_libraries.py | 233 ++++++++++++- bindings/python/src/lib.rs | 310 +++++++++--------- crates/wickra-bench/benches/cross_lib.rs | 18 +- crates/wickra-core/src/indicators/atr.rs | 136 ++++++++ .../wickra-core/src/indicators/bollinger.rs | 149 +++++++++ crates/wickra-core/src/indicators/ema.rs | 127 +++++++ crates/wickra-core/src/indicators/macd.rs | 183 +++++++++++ crates/wickra-core/src/indicators/rsi.rs | 129 ++++++++ crates/wickra-core/src/indicators/sma.rs | 119 +++++++ crates/wickra-core/src/lib.rs | 2 +- crates/wickra-core/src/traits.rs | 34 ++ 14 files changed, 1400 insertions(+), 296 deletions(-) create mode 100644 BENCHMARKS.md diff --git a/BENCHMARKS.md b/BENCHMARKS.md new file mode 100644 index 00000000..96f9f8cd --- /dev/null +++ b/BENCHMARKS.md @@ -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 +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index a6acd70f..03a32d1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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>` 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`). diff --git a/README.md b/README.md index 127745a5..22676060 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bindings/python/benchmarks/compare_libraries.py b/bindings/python/benchmarks/compare_libraries.py index 2ccccf5c..669cbb1b 100644 --- a/bindings/python/benchmarks/compare_libraries.py +++ b/bindings/python/benchmarks/compare_libraries.py @@ -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__": diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 48516bf2..6f96bd79 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -17,7 +17,7 @@ use pyo3::exceptions::{PyTypeError, PyValueError}; use pyo3::prelude::*; use pyo3::types::PyDict; use wickra_core as wc; -use wickra_core::{BarBuilder, BatchExt, Indicator}; +use wickra_core::{BarBuilder, BatchExt, BatchNanExt, Indicator}; fn map_err(e: wc::Error) -> PyErr { match e { @@ -35,15 +35,6 @@ fn map_err(e: wc::Error) -> PyErr { } } -fn opt_to_nan(v: Option) -> f64 { - v.unwrap_or(f64::NAN) -} - -/// Convert a slice of `Option` to a flat `Vec` with NaNs for warmup. -fn flatten(values: Vec>) -> Vec { - values.into_iter().map(opt_to_nan).collect() -} - /// Raised instead of panicking when a `NumPy` input is not C-contiguous. const NON_CONTIGUOUS: &str = "array must be C-contiguous; pass np.ascontiguousarray(arr)"; @@ -91,7 +82,7 @@ impl PySma { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -142,7 +133,7 @@ impl PyEma { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -197,7 +188,7 @@ impl PyWma { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -249,7 +240,7 @@ impl PyRsi { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -307,14 +298,7 @@ impl PyMacd { .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; let n = slice.len(); - let mut out = vec![f64::NAN; n * 3]; - for (i, p) in slice.iter().enumerate() { - if let Some(o) = self.inner.update(*p) { - out[i * 3] = o.macd; - out[i * 3 + 1] = o.signal; - out[i * 3 + 2] = o.histogram; - } - } + let out = self.inner.batch_macd(slice); Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out) .expect("shape consistent") .into_pyarray(py)) @@ -375,15 +359,7 @@ impl PyBb { .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; let n = slice.len(); - let mut out = vec![f64::NAN; n * 4]; - for (i, p) in slice.iter().enumerate() { - if let Some(o) = self.inner.update(*p) { - out[i * 4] = o.upper; - out[i * 4 + 1] = o.middle; - out[i * 4 + 2] = o.lower; - out[i * 4 + 3] = o.stddev; - } - } + let out = self.inner.batch_bands(slice); Ok(numpy::ndarray::Array2::from_shape_vec((n, 4), out) .expect("shape consistent") .into_pyarray(py)) @@ -489,12 +465,20 @@ impl PyAtr { "high, low, close must be equal length", )); } - let mut out = Vec::with_capacity(h.len()); + // Validate the OHLC invariants once (the streaming path gets this from + // `Candle::new`); ATR uses the close as open, so `high >= close >= low`. for i in 0..h.len() { - let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; - out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + if !(h[i].is_finite() && l[i].is_finite() && c[i].is_finite()) + || h[i] < l[i] + || h[i] < c[i] + || l[i] > c[i] + { + return Err(PyValueError::new_err( + "high, low, close must be finite with low <= close <= high", + )); + } } - Ok(out.into_pyarray(py)) + Ok(self.inner.batch_atr(h, l, c).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -950,7 +934,7 @@ impl PyMidPoint { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -1065,7 +1049,7 @@ impl PyRocp { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -1113,7 +1097,7 @@ impl PyRocr { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -1161,7 +1145,7 @@ impl PyRocr100 { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -1213,7 +1197,7 @@ impl PyLinRegIntercept { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -1261,7 +1245,7 @@ impl PyTsf { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -1606,7 +1590,7 @@ impl PyLogReturn { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -1658,7 +1642,7 @@ impl PyRealizedVolatility { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -1706,7 +1690,7 @@ impl PyRollingIqr { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -1758,7 +1742,7 @@ impl PyRollingPercentileRank { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -1810,7 +1794,7 @@ impl PyRollingQuantile { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -2134,7 +2118,7 @@ impl PyTrendLabel { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -2182,7 +2166,7 @@ impl PyJumpIndicator { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -2235,7 +2219,7 @@ impl PyRegimeLabel { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn vol_period(&self) -> usize { @@ -2288,7 +2272,7 @@ impl PyWinRate { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -2336,7 +2320,7 @@ impl PyExpectancy { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -2384,7 +2368,7 @@ impl PySineWeightedMa { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -2432,7 +2416,7 @@ impl PyGeometricMa { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -2480,7 +2464,7 @@ impl PyEhma { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -2528,7 +2512,7 @@ impl PyMedianMa { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -2580,7 +2564,7 @@ impl PyAdaptiveLaguerreFilter { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -2632,7 +2616,7 @@ impl PyDisparityIndex { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -2680,7 +2664,7 @@ impl PyFisherRsi { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -2728,7 +2712,7 @@ impl PyRsx { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn length(&self) -> usize { @@ -2780,7 +2764,7 @@ impl PyDynamicMomentumIndex { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -2968,7 +2952,7 @@ impl PyTrendStrengthIndex { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -3082,7 +3066,7 @@ impl PyPolarizedFractalEfficiency { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -3131,7 +3115,7 @@ impl PyWavePm { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn length(&self) -> usize { @@ -3325,7 +3309,7 @@ impl PyTsfOscillator { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -3373,7 +3357,7 @@ impl PyMacdHistogram { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } fn reset(&mut self) { self.inner.reset(); @@ -3418,7 +3402,7 @@ impl PyPpoHistogram { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } fn reset(&mut self) { self.inner.reset(); @@ -3467,7 +3451,7 @@ impl PyBipowerVariation { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -3728,7 +3712,7 @@ impl PyJarqueBera { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -3776,7 +3760,7 @@ impl PyRollingMinMaxScaler { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -3824,7 +3808,7 @@ impl PyHighpassFilter { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -3872,7 +3856,7 @@ impl PyReflex { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -3920,7 +3904,7 @@ impl PyTrendflex { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -3968,7 +3952,7 @@ impl PyCorrelationTrendIndicator { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -4016,7 +4000,7 @@ impl PyAdaptiveRsi { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -4064,7 +4048,7 @@ impl PyUniversalOscillator { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -4512,7 +4496,7 @@ impl PyDema { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -4559,7 +4543,7 @@ impl PyTema { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -4606,7 +4590,7 @@ impl PyHma { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -4654,7 +4638,7 @@ impl PyKama { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } fn reset(&mut self) { self.inner.reset(); @@ -4766,7 +4750,7 @@ impl PyConnorsRsi { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } fn reset(&mut self) { self.inner.reset(); @@ -4811,7 +4795,7 @@ impl PyLaguerreRsi { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn gamma(&self) -> f64 { @@ -5133,7 +5117,7 @@ impl PyFrama { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -5309,7 +5293,7 @@ impl PyJma { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } fn reset(&mut self) { self.inner.reset(); @@ -5354,7 +5338,7 @@ impl PyVidya { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } fn reset(&mut self) { self.inner.reset(); @@ -5403,7 +5387,7 @@ impl PyMcGinleyDynamic { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -5451,7 +5435,7 @@ impl PyAlma { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -5574,7 +5558,7 @@ impl PyStc { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } fn reset(&mut self) { self.inner.reset(); @@ -5625,7 +5609,7 @@ impl PyElderImpulse { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } fn reset(&mut self) { self.inner.reset(); @@ -5728,7 +5712,7 @@ impl PyCfo { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -5776,7 +5760,7 @@ impl PyApo { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } fn reset(&mut self) { self.inner.reset(); @@ -5888,7 +5872,7 @@ impl PyRoc { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -6203,7 +6187,7 @@ impl PyTrix { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } fn reset(&mut self) { self.inner.reset(); @@ -6827,7 +6811,7 @@ impl PyBollingerBandwidth { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -6887,7 +6871,7 @@ impl PyPercentB { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -7019,7 +7003,7 @@ impl PyStdDev { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -7071,7 +7055,7 @@ impl PyUlcerIndex { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -7127,7 +7111,7 @@ impl PyHistoricalVolatility { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn periods(&self) -> (usize, usize) { @@ -7548,7 +7532,7 @@ impl PyPpo { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn periods(&self) -> (usize, usize) { @@ -7601,7 +7585,7 @@ impl PyDpo { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -7657,7 +7641,7 @@ impl PyCoppock { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn periods(&self) -> (usize, usize, usize) { @@ -7710,7 +7694,7 @@ impl PyStochRsi { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn periods(&self) -> (usize, usize) { @@ -7840,7 +7824,7 @@ impl PyMom { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -7892,7 +7876,7 @@ impl PyCmo { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -7944,7 +7928,7 @@ impl PyTsi { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn periods(&self) -> (usize, usize) { @@ -7997,7 +7981,7 @@ impl PyPmo { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn periods(&self) -> (usize, usize) { @@ -8050,7 +8034,7 @@ impl PyTii { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn periods(&self) -> (usize, usize) { @@ -8102,7 +8086,7 @@ impl PyZlema { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -8158,7 +8142,7 @@ impl PyT3 { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -8218,7 +8202,7 @@ impl PyGeneralizedDema { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -8274,7 +8258,7 @@ impl PyHoltWinters { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn alpha(&self) -> f64 { @@ -8342,7 +8326,7 @@ impl PyRmi { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -8408,7 +8392,7 @@ impl PyDerivativeOscillator { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } fn reset(&mut self) { self.inner.reset(); @@ -8515,7 +8499,7 @@ impl PySmma { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -8566,7 +8550,7 @@ impl PyTrima { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -9154,7 +9138,7 @@ impl PyAnchoredRsi { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn value(&self) -> Option { @@ -10210,7 +10194,7 @@ impl PyPercentageTrailingStop { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn percent(&self) -> f64 { @@ -10262,7 +10246,7 @@ impl PyStepTrailingStop { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn step_size(&self) -> f64 { @@ -10314,7 +10298,7 @@ impl PyRenkoTrailingStop { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn block_size(&self) -> f64 { @@ -10933,7 +10917,7 @@ impl PyLinearRegression { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -10981,7 +10965,7 @@ impl PyLinRegSlope { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -11244,7 +11228,7 @@ impl PyVerticalHorizontalFilter { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -11422,7 +11406,7 @@ impl PyZScore { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -11470,7 +11454,7 @@ impl PyLinRegAngle { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -11822,7 +11806,7 @@ impl PyRviVolatility { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -14541,7 +14525,7 @@ macro_rules! py_scalar_one_period { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -14611,7 +14595,7 @@ impl PyInverseFisherTransform { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn scale(&self) -> f64 { @@ -14666,7 +14650,7 @@ impl PyDecyclerOscillator { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn periods(&self) -> (usize, usize) { @@ -14715,7 +14699,7 @@ impl PyRoofingFilter { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn periods(&self) -> (usize, usize) { @@ -14768,7 +14752,7 @@ impl PyEmd { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -14825,7 +14809,7 @@ macro_rules! py_no_params_scalar { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn value(&self) -> Option { @@ -14883,7 +14867,7 @@ impl PySineWave { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn value(&self) -> Option { @@ -14996,7 +14980,7 @@ impl PyFama { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn limits(&self) -> (f64, f64) { @@ -15228,7 +15212,7 @@ impl PyVariance { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -15280,7 +15264,7 @@ impl PyCoefficientOfVariation { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -15328,7 +15312,7 @@ impl PySkewness { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -15376,7 +15360,7 @@ impl PyKurtosis { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -15424,7 +15408,7 @@ impl PyStandardError { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -15476,7 +15460,7 @@ impl PyDetrendedStdDev { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -15524,7 +15508,7 @@ impl PyRSquared { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -15576,7 +15560,7 @@ impl PyAutocorrelation { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -15636,7 +15620,7 @@ impl PyMedianAbsoluteDeviation { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -15684,7 +15668,7 @@ impl PyHurstExponent { let s = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + Ok(self.inner.batch_nan(s).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -20436,7 +20420,7 @@ impl PySharpeRatio { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -20490,7 +20474,7 @@ impl PySortinoRatio { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -20543,7 +20527,7 @@ impl PyCalmarRatio { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -20589,7 +20573,7 @@ impl PyOmegaRatio { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -20642,7 +20626,7 @@ impl PyMaxDrawdown { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -20691,7 +20675,7 @@ impl PyAverageDrawdown { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -20787,7 +20771,7 @@ impl PyPainIndex { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -20833,7 +20817,7 @@ impl PyValueAtRisk { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -20891,7 +20875,7 @@ impl PyConditionalValueAtRisk { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -20944,7 +20928,7 @@ impl PyProfitFactor { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -20989,7 +20973,7 @@ impl PyGainLossRatio { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -21038,7 +21022,7 @@ impl PyRecoveryFactor { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } fn reset(&mut self) { self.inner.reset(); @@ -21083,7 +21067,7 @@ impl PyKellyCriterion { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn period(&self) -> usize { @@ -22835,7 +22819,7 @@ impl PyEwmaVolatility { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn lambda_(&self) -> f64 { @@ -22884,7 +22868,7 @@ impl PyGarch11 { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn params(&self) -> (f64, f64, f64) { @@ -22941,7 +22925,7 @@ impl PyVolatilityOfVolatility { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn windows(&self) -> (usize, usize) { @@ -23564,7 +23548,7 @@ impl PyShannonEntropy { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn params(&self) -> (usize, usize) { @@ -23617,7 +23601,7 @@ impl PySampleEntropy { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn params(&self) -> (usize, usize, f64) { @@ -23734,7 +23718,7 @@ impl PyBandpassFilter { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn params(&self) -> (usize, f64) { @@ -23791,7 +23775,7 @@ impl PyEvenBetterSinewave { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn params(&self) -> (usize, usize) { @@ -23844,7 +23828,7 @@ impl PyAutocorrelationPeriodogram { let slice = prices .as_slice() .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; - Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + Ok(self.inner.batch_nan(slice).into_pyarray(py)) } #[getter] fn periods(&self) -> (usize, usize) { diff --git a/crates/wickra-bench/benches/cross_lib.rs b/crates/wickra-bench/benches/cross_lib.rs index 600b94a2..1eeb482a 100644 --- a/crates/wickra-bench/benches/cross_lib.rs +++ b/crates/wickra-bench/benches/cross_lib.rs @@ -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 = series.iter().map(|candle| candle.high).collect(); + let low: Vec = series.iter().map(|candle| candle.low).collect(); + let close: Vec = 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)); }); }, ); diff --git a/crates/wickra-core/src/indicators/atr.rs b/crates/wickra-core/src/indicators/atr.rs index de1376a8..32a39ab5 100644 --- a/crates/wickra-core/src/indicators/atr.rs +++ b/crates/wickra-core/src/indicators/atr.rs @@ -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 { + 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 { + 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, Vec, Vec) { + let base: Vec = (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 = (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] diff --git a/crates/wickra-core/src/indicators/bollinger.rs b/crates/wickra-core/src/indicators/bollinger.rs index 72761f08..b993cb61 100644 --- a/crates/wickra-core/src/indicators/bollinger.rs +++ b/crates/wickra-core/src/indicators/bollinger.rs @@ -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` 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>`; only the bindings, which want + /// a flat `f64` buffer, call this. + pub fn batch_bands(&mut self, inputs: &[f64]) -> Vec { + 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 { 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 { + 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 = (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(); diff --git a/crates/wickra-core/src/indicators/ema.rs b/crates/wickra-core/src/indicators/ema.rs index f46ce800..28f4c4dc 100644 --- a/crates/wickra-core/src/indicators/ema.rs +++ b/crates/wickra-core/src/indicators/ema.rs @@ -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 { + 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::() / 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 { @@ -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 { + 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 = (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 = 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] diff --git a/crates/wickra-core/src/indicators/macd.rs b/crates/wickra-core/src/indicators/macd.rs index 04346bca..a5862478 100644 --- a/crates/wickra-core/src/indicators/macd.rs +++ b/crates/wickra-core/src/indicators/macd.rs @@ -86,6 +86,116 @@ impl MacdIndicator { pub const fn value(&self) -> Option { 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 { + 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 { + 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 = (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 = (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 = (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 = (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(); diff --git a/crates/wickra-core/src/indicators/rsi.rs b/crates/wickra-core/src/indicators/rsi.rs index 5ae0e188..8f3233c3 100644 --- a/crates/wickra-core/src/indicators/rsi.rs +++ b/crates/wickra-core/src/indicators/rsi.rs @@ -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 { + 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 { + 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 = (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 = 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] diff --git a/crates/wickra-core/src/indicators/sma.rs b/crates/wickra-core/src/indicators/sma.rs index 6a2d85d6..88b7c4a0 100644 --- a/crates/wickra-core/src/indicators/sma.rs +++ b/crates/wickra-core/src/indicators/sma.rs @@ -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` 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 { + 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 { + 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 = (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 = 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] diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index d83aa05f..f1d1f6a3 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -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}; diff --git a/crates/wickra-core/src/traits.rs b/crates/wickra-core/src/traits.rs index babd5d4b..39788fbd 100644 --- a/crates/wickra-core/src/traits.rs +++ b/crates/wickra-core/src/traits.rs @@ -90,6 +90,29 @@ pub trait BatchExt: Indicator { impl BatchExt for T {} +/// Fast batch for scalar `f64 -> f64` indicators. +/// +/// The generic [`BatchExt::batch`] returns `Vec>` — 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 { + /// One `f64` per input, warmup positions filled with `NaN`. + fn batch_nan(&mut self, inputs: &[f64]) -> Vec { + let mut out = Vec::with_capacity(inputs.len()); + for &x in inputs { + out.push(self.update(x).unwrap_or(f64::NAN)); + } + out + } +} + +impl> 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());