From aacb9280f1d7f7de0f0755c03c82bcdb40afac76 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Sat, 6 Jun 2026 20:57:31 +0200 Subject: [PATCH] Honest tiered cross-library benchmark + streaming/batch perf (#186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary An honest, tiered cross-library benchmark — and the optimization pass it triggered. ### Performance (wickra-core, outputs unchanged) Profiling against the other Rust TA crates exposed real inefficiencies. Each benchmarked indicator is now **5–79% faster** in both streaming and batch: - **SMA, Bollinger**: flat `Box<[f64]>` ring buffers replace `VecDeque` (−69…79%). - **RSI**: `100·ag/(ag+al)` collapses three divisions into one; Wilder smoothing hoists `1/period` out of the hot path (−46%). - **ATR**: reciprocal hoisted (−42%). - **EMA/RSI/ATR**: per-tick `Option` hot state → bare `f64` + ready flag. Net result vs `kand`: Wickra now wins **RSI, Bollinger and ATR** (streaming), and ties `ta-rs` on SMA — up from losing every indicator 1.5–6× before. ### Benchmark harness New `crates/wickra-bench` (publish=false): a Criterion benchmark comparing Wickra against `kand`, `ta-rs` and `yata` on an identical BTCUSDT candle series, in streaming and batch modes. Peer APIs were verified against their source, not guessed. Wired into the nightly `cross-library-bench` workflow as a separate job. ### Honest README The benchmark section is rewritten into three layered tables (Rust core vs Rust crates; Python vs the Python ecosystem) that **show the losses as well as the wins**. The "only library that combines…" claim is gone; the new framing is breadth + multi-language reach + the deliberate safety trade-off that costs raw speed. Added an origin/why-slower rationale and a star CTA. ### Python benchmark Added `tulipy` runners and expanded per-tick streaming coverage to SMA/EMA/RSI/ MACD/Bollinger. `bench.in`/`bench.txt` now lock `TA-Lib` + `tulipy` (hash-pinned); `pandas-ta` stays out (it requires Python ≥ 3.12, the bench runs on 3.11). ### Notes - TA-Lib/tulipy numbers in the README Python table are marked ⧗ — they are produced by the CI Linux job (C extensions don't build cleanly on every desktop), not measured locally. - The matching `wickra-docs` prose update is committed separately and will be pushed with the release, per the docs-don't-lead-the-registries rule. Verified locally: `cargo fmt`, `cargo test --workspace --all-features` (3413 core + bindings), `cargo clippy --workspace --all-targets --all-features -D warnings`, Node build + 498 tests, and pytest all green. --- .github/requirements/bench.in | 2 + .github/requirements/bench.txt | 80 ++ .github/workflows/bench.yml | 26 + CHANGELOG.md | 18 + Cargo.lock | 109 ++- Cargo.toml | 1 + README.md | 178 +++-- .../python/benchmarks/compare_libraries.py | 151 ++++ bindings/python/pyproject.toml | 1 + crates/wickra-bench/Cargo.toml | 22 + crates/wickra-bench/benches/cross_lib.rs | 695 ++++++++++++++++++ crates/wickra-bench/src/lib.rs | 6 + crates/wickra-core/src/indicators/atr.rs | 37 +- .../wickra-core/src/indicators/bollinger.rs | 48 +- crates/wickra-core/src/indicators/ema.rs | 42 +- crates/wickra-core/src/indicators/rsi.rs | 81 +- crates/wickra-core/src/indicators/sma.rs | 55 +- examples/rust/Cargo.toml | 2 +- 18 files changed, 1413 insertions(+), 141 deletions(-) create mode 100644 crates/wickra-bench/Cargo.toml create mode 100644 crates/wickra-bench/benches/cross_lib.rs create mode 100644 crates/wickra-bench/src/lib.rs diff --git a/.github/requirements/bench.in b/.github/requirements/bench.in index a6fcc893..e8a77aac 100644 --- a/.github/requirements/bench.in +++ b/.github/requirements/bench.in @@ -5,5 +5,7 @@ maturin numpy pandas +TA-Lib +tulipy talipp finta diff --git a/.github/requirements/bench.txt b/.github/requirements/bench.txt index fd6407a4..ada98750 100644 --- a/.github/requirements/bench.txt +++ b/.github/requirements/bench.txt @@ -1,5 +1,13 @@ # This file was autogenerated by uv via the following command: # ./scripts/update-lockfiles.sh +build==1.5.0 \ + --hash=sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f \ + --hash=sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647 + # via ta-lib +colorama==0.4.6 \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via build finta==1.3 \ --hash=sha256:b94b94df311c18bf5402eb2fe8fd2db5e1bdaff08baf58a7367d05c7abdd10d3 \ --hash=sha256:f2fa0673748f4be8f57e57cf6d5c00a4d44bc6071ea69dbb9a1d329d045cbba2 @@ -97,6 +105,12 @@ numpy==2.4.6 \ # -r .github/requirements/bench.in # finta # pandas + # ta-lib + # tulipy +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 + # via build pandas==3.0.3 \ --hash=sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c \ --hash=sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2 \ @@ -149,6 +163,10 @@ pandas==3.0.3 \ # via # -r .github/requirements/bench.in # finta +pyproject-hooks==1.2.0 \ + --hash=sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8 \ + --hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 + # via build python-dateutil==2.9.0.post0 \ --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 @@ -157,10 +175,72 @@ six==1.17.0 \ --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 # via python-dateutil +ta-lib==0.6.8 \ + --hash=sha256:02388054c059945e5f02625f5075bac20a1803573cb43e7d096091027511961f \ + --hash=sha256:094677b279a59c3f01c3aca8a889fda3523fd641a3805f69a2d642121b72e55e \ + --hash=sha256:0a08a29690a922ba92a6cf42902a8a93c6fbda4cfed62c3c5b0471560ef60135 \ + --hash=sha256:0ccd478ff5735831bf2a61d653466bfda8afadc26ad58ca6b1edb9e7521cc674 \ + --hash=sha256:0e371d14b49e70caa973a234c8823341dd446f5c5d7acc826868bb42b272bdc0 \ + --hash=sha256:11a373c9308eae3bac2d56d37017f9ab63968cc074a8b95be879aae3d13133aa \ + --hash=sha256:128ec92e6a0e9ff7a38edef80e3b74f15bb2ed1c531d5d3252c8dca22677651b \ + --hash=sha256:1fb4028437201e19014e4e374272b739867c8a3eb655da46675ef4c2ff14b616 \ + --hash=sha256:282e49c766b5952dd8796f77d7ed3ae412cdd88e31f845b1fbbb86ac6cb7bebf \ + --hash=sha256:2b369cabb48485fbf444beb3f5a878075367b99c2c86db2f796afeabebc749e0 \ + --hash=sha256:2bf714333788bf5175f2512b86d2ed129e89ae6f6c2923e8a297a1e3395e13b5 \ + --hash=sha256:30de46b55873b51be945a09edf486afcc190dc47eff9fb5d2b12c9f7e3d743da \ + --hash=sha256:34e3b12407ddf99f6627435aa8a165f094339bb7dc33de92e1d7472e9f237304 \ + --hash=sha256:36b2a516fce57309840f5ef3fa2fd0c4449293fc72536a0400d2e1e26b414da8 \ + --hash=sha256:3a9195299df9d7d2a6e9d16bebd6b706b0ea99e4b871864c4b034c2577e21a77 \ + --hash=sha256:3c32fc0f546ceecc47dd45f33d72ab4a1e341b80d9081c2d77b100add5d49104 \ + --hash=sha256:3d7333e907bff3e3997e54f89733ffa8d619842a3e1cd962bca34bdc11944c28 \ + --hash=sha256:4795e93d130c9b7fb661f0cead49752ae6a980437df74b99d5918026c212443e \ + --hash=sha256:490e19a45cd3cdd6dfe6b46019f7ffe1103500750b41b51996a870e7c1c5f066 \ + --hash=sha256:4aa0fe08383f3e5fc7d2f8cf9b42ac778f4d53fd75bcd2799a858225954eab89 \ + --hash=sha256:559326d8f3d904cd4aa61f6a392d5626f35eec6a9f6cc83bcddb0abf88c40516 \ + --hash=sha256:5929c83bd8cb7572d1c17ffdbf0eac235bf3c4d53cde1950cf89d944eaf97525 \ + --hash=sha256:5bfd21b6acb32e20d4e279c34405a34e63da345be4b2b6eabd683e1a88857406 \ + --hash=sha256:613cf06313331f49dd7b85a5a24fbddb1156c9723b6921a231906241726e5aee \ + --hash=sha256:66a8e1c1e899d15a2f7510e43527fba22d895e7f6058d027db3e3837d88a69de \ + --hash=sha256:691a62926ba09f2653ec0908554b3635497efb7751c5d46b916cd1ebbb1d3c25 \ + --hash=sha256:6c1fd18e45c39d5a4be4b0d6a20c141e43fe46daeb1b2e2f304ebae7015ab6e6 \ + --hash=sha256:6c6a1e8f98de92e817491b50aa4d01d69a1b41a4ed3173747e8f16f0d4cf81cc \ + --hash=sha256:6cf029b886cfb28a2701503b7c602b811f2daa45276bd6459b0c71e051deb497 \ + --hash=sha256:71506116eac0d3e3598d6325b4b818c3a0f6acb3222b24d30ad726e8c4bf7ea8 \ + --hash=sha256:7993164e8e9f78ec31d38c47850ca6ba5451788b5b49a8a2dbb3322b36b5693b \ + --hash=sha256:7a5cc6bf60791d8274edfdfe2dd7cec3f00f656dcc92e2b0a9af06c8b18ce6a6 \ + --hash=sha256:87c1cc1057d903b78a8257a7c5f497db6fd5284f5080392bd57b66031d7389a3 \ + --hash=sha256:98376c75bd6c103c74396953084a5e0798ffe476aecbfcc51ec6d100a685ac38 \ + --hash=sha256:a395524b0fafa10446d11e11acb4742e919523de58aac03b791f26d7a783bcf0 \ + --hash=sha256:a5100a4be91b7d4b7c8fe16a3600bd0951e10205eb1066b6873afd3996b51ee4 \ + --hash=sha256:a63a52221f8c73f82f4e00493351d987f594931198589287aee96f8da673cfd5 \ + --hash=sha256:a89734a7bcb2ea3b6fd600a74d6fbcdb8d3fa3f7917dbd978e039710b5509c9c \ + --hash=sha256:b165f5e6de1ccc964e863bd2035807a4d3bad3e0481f9db2dc52034d6ad4f9de \ + --hash=sha256:b3845e4c2fa32963fb7f384ebbaa2761b0e6b96145239bf80e956d4aff4b071c \ + --hash=sha256:b3b017d9103e7a7372a146773be32b184ff7330bd708d40b1f56f06a686756ed \ + --hash=sha256:b6c6e4858d8c3f88e19b7aa94b6a7619108f0bee51da9fa67b0785a8b59955f9 \ + --hash=sha256:bfad1202fb1f9140e3810cc607058395f59032d9128cc0d716900c78bea5f337 \ + --hash=sha256:c01809fb602e2fefc8cbfb3b603bb59d2a2eaee8708410896d48a835ba00e7c5 \ + --hash=sha256:cce8de9d48289927ed18aaa420740efd52b2cd9289da32e3799afbb3a02822e8 \ + --hash=sha256:ce2bc1ea01200b6d8130ab917296d05d77a1a571ec6c1ee25cfca6d55cd5db4a \ + --hash=sha256:d4601e2a8b46ffbf540601a4926fd6cc5aae8a13b36fdd467f1040f01f9edaed \ + --hash=sha256:d556d1c256b3700b60b6b061664a667b2e49d599c2772d46a9f2348f2dc4ab5c \ + --hash=sha256:ddf7453acd03b966624ebefdb38169b5bbbeea1a1a58c90b095667247f9de327 \ + --hash=sha256:e781eeb65b2007af553389c8a7fb7bc53cb856118b0fcffb2c26b0f49561c686 \ + --hash=sha256:e920c272cd9e70a6b10eae9203cc96845da142e1dd4482de9343dda3738a9862 \ + --hash=sha256:f5b6174bf4bf9152e368561dff410203c6921e4dd2afbcda3283a95957158112 \ + --hash=sha256:f69bd42fd2515060af69b120668213121264bb7976b113954b6f9db327727c65 \ + --hash=sha256:f823d0f6b04a6797fbe253bcf91666e71a6b63c290683819650c68b2468ebe64 \ + --hash=sha256:fa7e9f2e80a9535f9692e113d02b4268b5f88675a730d1b0ef0abeb74c9a4e80 + # via -r .github/requirements/bench.in talipp==2.7.0 \ --hash=sha256:567f59ad74366cb59a14a00d350f35fd9d22e6924d6228bad581e6dcf1de2205 \ --hash=sha256:f749f22b9ad615605e71faf26457bb7f5e3fe16f04d3287f4ca54fd16bc3d4eb # via -r .github/requirements/bench.in +tulipy==0.4.0 \ + --hash=sha256:540704956b5b940a5f6306aa393a37536a6d7c3cbc07efe47512f3496e5203ab \ + --hash=sha256:95542e40537afdd345d875baf37485eac993c6a819d00c51432e9de8df21eba8 \ + --hash=sha256:fbc31727ef7657c93ad910bfdce65fecc6aaa7a5e961fe00240718e7a3fc79d8 + # via -r .github/requirements/bench.in tzdata==2026.2 \ --hash=sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10 \ --hash=sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7 diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 3a031f79..8d3d1276 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -117,3 +117,29 @@ jobs: with: name: cross-library-bench path: bindings/python/benchmark.txt + + rust-cross-bench: + name: Rust cross-library benchmark report + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27 + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore + timeout-minutes: 6 + + # Wickra vs the other Rust TA crates (kand, ta-rs, yata) on an identical + # candle series — the like-for-like engine comparison with no binding + # overhead. Streaming + batch, in crates/wickra-bench/benches/cross_lib.rs. + - name: Run Rust cross-library benchmark + run: cargo bench -p wickra-bench --bench cross_lib | tee rust_cross_bench.txt + + - name: Upload Rust report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: rust-cross-bench + path: rust_cross_bench.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index ed90a3b0..d211a8b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Internal Rust cross-library benchmark harness (`crates/wickra-bench`, not + published) comparing Wickra against `kand`, `ta-rs` and `yata` on an identical + candle series in both streaming and batch modes; wired into the nightly + `cross-library-bench` workflow. +- `tulipy` runners and expanded per-tick streaming coverage (SMA, EMA, RSI, + MACD, Bollinger) in the Python `compare_libraries` benchmark. + +### Changed + +- Faster streaming and batch updates for SMA, Bollinger Bands, RSI, EMA and ATR + (flat ring buffers replacing `VecDeque`, hoisted reciprocals in the Wilder + smoothing, leaner hot state) — indicator outputs are unchanged. +- Rewrote the README benchmark section into honest, tiered tables (Rust core vs + the other Rust crates, and Python vs the Python ecosystem) that show where + Wickra wins and where it loses, not only the favourable comparisons. + ## [0.5.8] - 2026-06-04 - **TSF Oscillator** — the percentage gap of the close to the one-bar-ahead time-series forecast, a close-relative companion to CFO (`TsfOscillator`). - **MACD Histogram** — the standalone macd-minus-signal bar of MACD as a scalar series (`MacdHistogram`). diff --git a/Cargo.lock b/Cargo.lock index 19313cfe..e27eed0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -702,6 +702,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kand" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1f41590bd014ef6c3dd815b45f07deb4c3198e355a4319bb7521b6a3a6aeb5" +dependencies = [ + "num_enum", + "thiserror", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -911,6 +921,28 @@ dependencies = [ "libm", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "numpy" version = "0.28.0" @@ -1081,6 +1113,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1498,6 +1539,12 @@ dependencies = [ "syn", ] +[[package]] +name = "ta" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "609409d472a0a7d8d4dd9e19891bbdef546b9dce670c3057d0e02192dc541226" + [[package]] name = "target-lexicon" version = "0.13.5" @@ -1607,6 +1654,36 @@ dependencies = [ "tungstenite", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + [[package]] name = "tungstenite" version = "0.29.0" @@ -1876,6 +1953,18 @@ dependencies = [ "wickra-data", ] +[[package]] +name = "wickra-bench" +version = "0.5.8" +dependencies = [ + "criterion", + "kand", + "ta", + "wickra", + "wickra-data", + "yata", +] + [[package]] name = "wickra-core" version = "0.5.8" @@ -1905,7 +1994,7 @@ dependencies = [ [[package]] name = "wickra-examples" -version = "0.0.0" +version = "0.5.8" dependencies = [ "serde_json", "tokio", @@ -1991,6 +2080,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2091,6 +2189,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "yata" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b4ef8ddfa3ccd93454262c0e60a43a2bbf403d404174e1815f7581d5028229f" +dependencies = [ + "serde", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 27a207a6..6731a460 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "bindings/wasm", "bindings/node", "examples/rust", + "crates/wickra-bench", ] exclude = ["fuzz"] diff --git a/README.md b/README.md index 60a89d4f..8f47a6c8 100644 --- a/README.md +++ b/README.md @@ -60,77 +60,129 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**: ## Why Wickra exists -The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta, -talipp, tulipy — and every one of them shares the same blind spot: +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. -| Library | Install pain | Streaming | Multi-language | Active | -|------------------------|-----------------|-----------|----------------|--------| -| **★ Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** | -| TA-Lib (Python) | yes (C deps) | no | no | barely | -| pandas-ta | clean | no | no | slow | -| finta | clean | no | no | stale | -| ta-lib-python | yes (C deps) | no | no | barely | -| talipp | clean | yes | no | yes | -| Tulip Indicators | yes (C deps) | no | partial | stale | -| ooples (C#) | clean | no | C# only | yes | +Plenty of TA libraries are fast. Each one forces a trade-off Wickra does not: -Wickra is the only library that combines all of: clean install, streaming, -multi-language reach, and active maintenance. +| Library | Install | Streaming | Languages | Indicators | Active | +|------------------|-------------|-------------|-----------------------------|-----------:|--------| +| **★ Wickra**| **clean** | **yes, O(1)** | **Python · Node · WASM · Rust** | **423** | **yes** | +| kand | clean | yes | Python · WASM · Rust | ~60 | yes | +| ta-rs | clean | yes | Rust only | ~30 | stale | +| yata | clean | partial | Rust only | ~35 | yes | +| TA-Lib | yes (C deps)| no | many bindings | ~150 | barely | +| pandas-ta | clean | no | Python | ~130 | slow | +| finta | clean | no | Python | ~80 | stale | +| talipp | clean | yes | Python | ~40 | yes | -## Benchmark: how much faster is "streaming-first"? +Wickra's edge is **breadth with reach**: 423 indicators that all update in O(1) +per tick and ship natively to Python, Node.js, WebAssembly and Rust from a +single engine. -The numbers below were measured on a single developer workstation and are not -guaranteed to reproduce identically on different hardware — absolute µs values -depend on CPU, memory clock and OS scheduler. Read them as **relative -speedups** between libraries on identical input, not as a universal -performance contract. +**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. + +## 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. - **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5, - Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`), - Python 3.12, Node 20. -- **Reproduce yourself:** `pip install -e bindings/python[bench]` then - `python -m benchmarks.compare_libraries`. The script auto-detects every - installed peer library and runs them on the same generated inputs as - Wickra. The CI job `cross-library-bench` runs the same script on every - push and uploads the raw report as a build artefact. + 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). -Lower µs/op = faster. Wickra wins every batch category outright, and the -streaming gap widens linearly with how much history a batch-only library has -to recompute on every tick. +### 1. Rust core vs the other Rust TA crates -### Batch — single full pass over a 20 000-bar series +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. -Reading the table: each cell shows that library's runtime, plus how many times -slower it is than Wickra in parentheses. **★** marks the winner per row. +**Streaming** (one value fed per `update`): -| Indicator | **★ Wickra** | finta | talipp | -|---------------------|---------------------|-----------------------------|-------------------------------| -| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) | -| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) | -| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) | -| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) | -| Bollinger(20, 2.0) | **105.3 µs ★** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)| -| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) | +| 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 | — | -### Streaming — per-tick latency after seeding with 5 000 historical bars +**Batch** (whole series at once). Only Wickra and kand expose a batch API; +ta-rs and yata are streaming-only. -A batch-only library has to re-run its full indicator over the entire history on -every new tick; Wickra updates state in O(1). +| 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 | -| Indicator | **★ Wickra (per tick)** | talipp (per tick) | -|-----------|---------------------|---------------------------| -| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) | +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. -> TA-Lib and pandas-ta are not included here because both fail to install -> cleanly on Windows without C build tooling — which is precisely the install -> pain Wickra was built to remove. The benchmark script auto-detects every -> peer library it can find and runs them on the same inputs as Wickra; install -> them in your environment to see those rows light up too. +### 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 -pip install -e bindings/python[bench] +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 ``` @@ -247,7 +299,8 @@ wickra/ ├── crates/ │ ├── wickra-core/ core engine + all 423 indicators │ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/ -│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds +│ ├── wickra-data/ CSV reader, tick aggregator, live exchange feeds +│ └── wickra-bench/ internal cross-library benchmark harness (not published) ├── bindings/ │ ├── python/ PyO3 + maturin (publishes on PyPI) │ ├── node/ napi-rs (publishes on npm) @@ -261,9 +314,10 @@ wickra/ └── .github/workflows/ CI and release pipelines ``` -Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live -in the workspace member crate at `examples/rust/`. There is no top-level -`benches/` directory. +Wickra's own regression benchmarks live in `crates/wickra/benches/`; the +cross-library comparison against kand, ta-rs and yata lives in the internal +`crates/wickra-bench/` crate. Runnable Rust examples live in the workspace member +crate at `examples/rust/`. There is no top-level `benches/` directory. ## Building everything from source @@ -271,7 +325,8 @@ in the workspace member crate at `examples/rust/`. There is no top-level # Rust core + tests cargo test --workspace cargo clippy --workspace --all-targets -- -D warnings -cargo bench -p wickra +cargo bench -p wickra # Wickra's own regression benchmarks +cargo bench -p wickra-bench # cross-library comparison (kand, ta-rs, yata) # Python binding (requires Rust toolchain + maturin) cd bindings/python @@ -371,3 +426,10 @@ The library is provided **as is**, without warranty of any kind; see

If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.

+ +

+ + Star Wickra on GitHub + +

diff --git a/bindings/python/benchmarks/compare_libraries.py b/bindings/python/benchmarks/compare_libraries.py index 744ea7aa..2ccccf5c 100644 --- a/bindings/python/benchmarks/compare_libraries.py +++ b/bindings/python/benchmarks/compare_libraries.py @@ -49,6 +49,7 @@ TALIB = _try_import("talib") PANDAS_TA = _try_import("pandas_ta") TALIPP = _try_import("talipp.indicators") or _try_import("talipp") FINTA = _try_import("finta") +TULIPY = _try_import("tulipy") PD = _try_import("pandas") import wickra as WICKRA # noqa: E402 -- the library under test must be importable @@ -275,6 +276,34 @@ def talipp_bollinger_batch(prices: np.ndarray) -> Optional[Callable[[], None]]: return lambda: BB(period=20, std_dev_mult=2.0, input_values=list(prices)) +# tulipy wraps the C "Tulip Indicators" library; it takes contiguous float64 +# arrays and indicator options as positional arguments. + + +def tulipy_sma_batch(prices: np.ndarray) -> Optional[Callable[[], None]]: + return None if TULIPY is None else (lambda: TULIPY.sma(prices, 20)) + + +def tulipy_ema_batch(prices: np.ndarray) -> Optional[Callable[[], None]]: + return None if TULIPY is None else (lambda: TULIPY.ema(prices, 20)) + + +def tulipy_rsi_batch(prices: np.ndarray) -> Optional[Callable[[], None]]: + return None if TULIPY is None else (lambda: TULIPY.rsi(prices, 14)) + + +def tulipy_macd_batch(prices: np.ndarray) -> Optional[Callable[[], None]]: + return None if TULIPY is None else (lambda: TULIPY.macd(prices, 12, 26, 9)) + + +def tulipy_bollinger_batch(prices: np.ndarray) -> Optional[Callable[[], None]]: + return None if TULIPY is None else (lambda: TULIPY.bbands(prices, 20, 2.0)) + + +def tulipy_atr_batch(high: np.ndarray, low: np.ndarray, close: np.ndarray) -> Optional[Callable[[], None]]: + return None if TULIPY is None else (lambda: TULIPY.atr(high, low, close, 14)) + + # --------------------------------------------------------------------------- # # Streaming scenario: per-tick latency # --------------------------------------------------------------------------- # @@ -329,6 +358,105 @@ def talipp_rsi_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callabl return run +# Scalar streaming peers: Wickra and talipp both update incrementally in O(1), +# so this is the like-for-like per-tick comparison (batch-only libs are covered +# by the batch tables and the recompute contrast on RSI above). + + +def wickra_sma_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]: + def run() -> None: + sma = WICKRA.SMA(20) + sma.batch(seed) + for p in live: + sma.update(float(p)) + + return run + + +def talipp_sma_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]: + if TALIPP is None: + return None + from talipp.indicators import SMA # type: ignore + + def run() -> None: + sma = SMA(period=20, input_values=list(seed)) + for p in live: + sma.add(float(p)) + + return run + + +def wickra_ema_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]: + def run() -> None: + ema = WICKRA.EMA(20) + ema.batch(seed) + for p in live: + ema.update(float(p)) + + return run + + +def talipp_ema_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]: + if TALIPP is None: + return None + from talipp.indicators import EMA # type: ignore + + def run() -> None: + ema = EMA(period=20, input_values=list(seed)) + for p in live: + ema.add(float(p)) + + return run + + +def wickra_macd_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]: + def run() -> None: + macd = WICKRA.MACD() + macd.batch(seed) + for p in live: + macd.update(float(p)) + + return run + + +def talipp_macd_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]: + if TALIPP is None: + return None + from talipp.indicators import MACD # type: ignore + + def run() -> None: + macd = MACD( + fast_period=12, slow_period=26, signal_period=9, input_values=list(seed) + ) + for p in live: + macd.add(float(p)) + + return run + + +def wickra_bollinger_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]: + def run() -> None: + bb = WICKRA.BollingerBands(20, 2.0) + bb.batch(seed) + for p in live: + bb.update(float(p)) + + return run + + +def talipp_bollinger_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]: + if TALIPP is None: + return None + from talipp.indicators import BB # type: ignore + + def run() -> None: + bb = BB(period=20, std_dev_mult=2.0, input_values=list(seed)) + for p in live: + bb.add(float(p)) + + return run + + # --------------------------------------------------------------------------- # # Runner # --------------------------------------------------------------------------- # @@ -339,6 +467,7 @@ BATCH_INDICATORS = [ ("Wickra", wickra_sma_batch), ("TA-Lib", talib_sma_batch), ("pandas-ta", pandas_ta_sma_batch), + ("tulipy", tulipy_sma_batch), ("finta", finta_sma_batch), ("talipp", talipp_sma_batch), ]), @@ -346,6 +475,7 @@ BATCH_INDICATORS = [ ("Wickra", wickra_ema_batch), ("TA-Lib", talib_ema_batch), ("pandas-ta", pandas_ta_ema_batch), + ("tulipy", tulipy_ema_batch), ("finta", finta_ema_batch), ("talipp", talipp_ema_batch), ]), @@ -353,6 +483,7 @@ BATCH_INDICATORS = [ ("Wickra", wickra_rsi_batch), ("TA-Lib", talib_rsi_batch), ("pandas-ta", pandas_ta_rsi_batch), + ("tulipy", tulipy_rsi_batch), ("finta", finta_rsi_batch), ("talipp", talipp_rsi_batch), ]), @@ -360,6 +491,7 @@ BATCH_INDICATORS = [ ("Wickra", wickra_macd_batch), ("TA-Lib", talib_macd_batch), ("pandas-ta", pandas_ta_macd_batch), + ("tulipy", tulipy_macd_batch), ("finta", finta_macd_batch), ("talipp", talipp_macd_batch), ]), @@ -367,6 +499,7 @@ BATCH_INDICATORS = [ ("Wickra", wickra_bollinger_batch), ("TA-Lib", talib_bollinger_batch), ("pandas-ta", pandas_ta_bollinger_batch), + ("tulipy", tulipy_bollinger_batch), ("finta", finta_bollinger_batch), ("talipp", talipp_bollinger_batch), ]), @@ -376,18 +509,35 @@ OHLC_INDICATORS = [ ("ATR(14)", [ ("Wickra", wickra_atr_batch), ("TA-Lib", talib_atr_batch), + ("tulipy", tulipy_atr_batch), ("finta", finta_atr_batch), ("talipp", talipp_atr_batch), ]), ] STREAMING_INDICATORS = [ + ("SMA(20)", [ + ("Wickra", wickra_sma_streaming), + ("talipp", talipp_sma_streaming), + ]), + ("EMA(20)", [ + ("Wickra", wickra_ema_streaming), + ("talipp", talipp_ema_streaming), + ]), ("RSI(14)", [ ("Wickra", wickra_rsi_streaming), ("TA-Lib", talib_rsi_streaming), ("pandas-ta", pandas_ta_rsi_streaming), ("talipp", talipp_rsi_streaming), ]), + ("MACD(12, 26, 9)", [ + ("Wickra", wickra_macd_streaming), + ("talipp", talipp_macd_streaming), + ]), + ("Bollinger(20, 2.0)", [ + ("Wickra", wickra_bollinger_streaming), + ("talipp", talipp_bollinger_streaming), + ]), ] @@ -501,6 +651,7 @@ def main() -> None: available = [] if TALIB is not None: available.append("TA-Lib") if PANDAS_TA is not None: available.append("pandas-ta") + if TULIPY is not None: available.append("tulipy") if FINTA is not None: available.append("finta") if TALIPP is not None: available.append("talipp") print(f"Wickra benchmark suite — wickra=v{WICKRA.__version__}") diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml index 95be4549..37e3483d 100644 --- a/bindings/python/pyproject.toml +++ b/bindings/python/pyproject.toml @@ -39,6 +39,7 @@ bench = [ "pytest-benchmark>=4", "TA-Lib; platform_system != 'Windows'", "pandas-ta>=0.3.14b", + "tulipy>=0.4; platform_system != 'Windows'", "talipp>=2", "finta>=1.3", "pandas>=2", diff --git a/crates/wickra-bench/Cargo.toml b/crates/wickra-bench/Cargo.toml new file mode 100644 index 00000000..13d6d581 --- /dev/null +++ b/crates/wickra-bench/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "wickra-bench" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false +description = "Internal cross-library benchmark harness (not published)." + +[lints] +workspace = true + +[dev-dependencies] +wickra = { path = "../wickra" } +wickra-data = { path = "../wickra-data" } +criterion = { workspace = true } +kand = "0.2.2" +ta = "0.5.0" +yata = "0.7.0" + +[[bench]] +name = "cross_lib" +harness = false diff --git a/crates/wickra-bench/benches/cross_lib.rs b/crates/wickra-bench/benches/cross_lib.rs new file mode 100644 index 00000000..600b94a2 --- /dev/null +++ b/crates/wickra-bench/benches/cross_lib.rs @@ -0,0 +1,695 @@ +//! Cross-library Criterion benchmark: Wickra vs `kand` vs `ta` (ta-rs) vs `yata`. +//! +//! All four are pure-Rust technical-analysis crates, so this is a like-for-like +//! Rust-vs-Rust comparison with no language-binding overhead. It feeds the exact +//! same BTCUSDT 1-minute candle series used by `crates/wickra/benches/indicators.rs`. +//! +//! Two arenas, kept honest: +//! +//! * **Streaming** (`*/stream`): one value fed at a time. Wickra (`Indicator::update`), +//! ta-rs (`Next::next`) and yata (`Method::next`) carry their own state; `kand` +//! exposes stateless `*_inc` helpers, so the per-tick state is threaded manually +//! here, seeded from `kand`'s own batch output (the seed is computed outside the +//! timed closure). yata only appears for SMA/EMA — its RSI/MACD/Bollinger/ATR are +//! exposed through a heavier signal-oriented indicator API, not a raw-value method, +//! so they are intentionally left out rather than compared unfairly. +//! * **Batch** (`*/batch`): the whole series at once. Only Wickra (`BatchExt::batch`) +//! and `kand` (TA-Lib-style fill-the-output-slice functions) have a real batch API; +//! ta-rs and yata are streaming-only and are deliberately absent from this arena. +//! +//! Run: `cargo bench -p wickra-bench` + +// Each indicator's benchmark group spells out every library arm explicitly, which +// runs a few groups over the 100-line lint threshold; that verbosity is the point. +#![allow(clippy::too_many_lines)] + +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_data::csv::CandleReader; +use yata::prelude::Method; + +const SIZES: &[usize] = &[1_000, 10_000, 50_000]; + +const SMA_PERIOD: usize = 20; +const EMA_PERIOD: usize = 20; +const RSI_PERIOD: usize = 14; +const ATR_PERIOD: usize = 14; +const BB_PERIOD: usize = 20; +const BB_DEV: f64 = 2.0; +const MACD_FAST: usize = 12; +const MACD_SLOW: usize = 26; +const MACD_SIGNAL: usize = 9; + +fn load_candles() -> Vec { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../examples/data/btcusdt-1m.csv" + ); + CandleReader::open(path) + .expect("dataset present") + .read_all() + .expect("valid OHLCV rows") +} + +/// Mean of the first `period` samples — the warmup seed for `kand`'s SMA/EMA `*_inc`. +fn window_mean(series: &[f64], period: usize) -> f64 { + series[..period].iter().sum::() / period as f64 +} + +fn sma_group(crit: &mut Criterion, closes: &[f64]) { + let mut group = crit.benchmark_group("sma_20"); + for &len in SIZES { + let len = len.min(closes.len()); + let series: &[f64] = &closes[..len]; + group.throughput(Throughput::Elements(len as u64)); + + group.bench_with_input( + BenchmarkId::new("wickra/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = Sma::new(SMA_PERIOD).unwrap(); + for &price in series { + black_box(ind.update(price)); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("wickra/batch", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = Sma::new(SMA_PERIOD).unwrap(); + black_box(ind.batch(series)); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/stream", len), + &series, + |bencher, &series| { + let seed = window_mean(series, SMA_PERIOD); + bencher.iter(|| { + let mut prev = seed; + for idx in SMA_PERIOD..series.len() { + prev = kand::ohlcv::sma::sma_inc( + prev, + series[idx], + series[idx - SMA_PERIOD], + SMA_PERIOD, + ) + .unwrap(); + black_box(prev); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/batch", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut out = vec![0.0; series.len()]; + kand::ohlcv::sma::sma(series, SMA_PERIOD, &mut out).unwrap(); + black_box(&out); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("ta-rs/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = ta::indicators::SimpleMovingAverage::new(SMA_PERIOD).unwrap(); + for &price in series { + black_box(ta::Next::next(&mut ind, price)); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("yata/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = yata::methods::SMA::new(SMA_PERIOD as u8, &series[0]).unwrap(); + for price in series { + black_box(ind.next(price)); + } + }); + }, + ); + } + group.finish(); +} + +fn ema_group(crit: &mut Criterion, closes: &[f64]) { + let mut group = crit.benchmark_group("ema_20"); + for &len in SIZES { + let len = len.min(closes.len()); + let series: &[f64] = &closes[..len]; + group.throughput(Throughput::Elements(len as u64)); + + group.bench_with_input( + BenchmarkId::new("wickra/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = Ema::new(EMA_PERIOD).unwrap(); + for &price in series { + black_box(ind.update(price)); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("wickra/batch", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = Ema::new(EMA_PERIOD).unwrap(); + black_box(ind.batch(series)); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/stream", len), + &series, + |bencher, &series| { + let seed = window_mean(series, EMA_PERIOD); + bencher.iter(|| { + let mut prev = seed; + for &price in &series[EMA_PERIOD..] { + prev = kand::ohlcv::ema::ema_inc(price, prev, EMA_PERIOD, None).unwrap(); + black_box(prev); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/batch", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut out = vec![0.0; series.len()]; + kand::ohlcv::ema::ema(series, EMA_PERIOD, None, &mut out).unwrap(); + black_box(&out); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("ta-rs/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = + ta::indicators::ExponentialMovingAverage::new(EMA_PERIOD).unwrap(); + for &price in series { + black_box(ta::Next::next(&mut ind, price)); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("yata/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = yata::methods::EMA::new(EMA_PERIOD as u8, &series[0]).unwrap(); + for price in series { + black_box(ind.next(price)); + } + }); + }, + ); + } + group.finish(); +} + +fn rsi_group(crit: &mut Criterion, closes: &[f64]) { + let mut group = crit.benchmark_group("rsi_14"); + for &len in SIZES { + let len = len.min(closes.len()); + let series: &[f64] = &closes[..len]; + group.throughput(Throughput::Elements(len as u64)); + + group.bench_with_input( + BenchmarkId::new("wickra/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = Rsi::new(RSI_PERIOD).unwrap(); + for &price in series { + black_box(ind.update(price)); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("wickra/batch", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = Rsi::new(RSI_PERIOD).unwrap(); + black_box(ind.batch(series)); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/stream", len), + &series, + |bencher, &series| { + // Wilder seed: simple average of the first `period` gains and losses. + let mut gain = 0.0; + let mut loss = 0.0; + for idx in 1..=RSI_PERIOD { + let delta = series[idx] - series[idx - 1]; + if delta > 0.0 { + gain += delta; + } else { + loss -= delta; + } + } + let seed_gain = gain / RSI_PERIOD as f64; + let seed_loss = loss / RSI_PERIOD as f64; + bencher.iter(|| { + let mut avg_gain = seed_gain; + let mut avg_loss = seed_loss; + let mut prev_price = series[RSI_PERIOD]; + for &price in &series[RSI_PERIOD + 1..] { + let (rsi, next_gain, next_loss) = kand::ohlcv::rsi::rsi_inc( + price, prev_price, avg_gain, avg_loss, RSI_PERIOD, + ) + .unwrap(); + avg_gain = next_gain; + avg_loss = next_loss; + prev_price = price; + black_box(rsi); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/batch", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut rsi = vec![0.0; series.len()]; + let mut avg_gain = vec![0.0; series.len()]; + let mut avg_loss = vec![0.0; series.len()]; + kand::ohlcv::rsi::rsi( + series, + RSI_PERIOD, + &mut rsi, + &mut avg_gain, + &mut avg_loss, + ) + .unwrap(); + black_box(&rsi); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("ta-rs/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = ta::indicators::RelativeStrengthIndex::new(RSI_PERIOD).unwrap(); + for &price in series { + black_box(ta::Next::next(&mut ind, price)); + } + }); + }, + ); + } + group.finish(); +} + +fn macd_group(crit: &mut Criterion, closes: &[f64]) { + let mut group = crit.benchmark_group("macd_12_26_9"); + for &len in SIZES { + let len = len.min(closes.len()); + let series: &[f64] = &closes[..len]; + group.throughput(Throughput::Elements(len as u64)); + + group.bench_with_input( + BenchmarkId::new("wickra/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = MacdIndicator::classic(); + for &price in series { + black_box(ind.update(price)); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("wickra/batch", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = MacdIndicator::classic(); + black_box(ind.batch(series)); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/stream", len), + &series, + |bencher, &series| { + // Seed the fast/slow/signal EMAs from kand's own warmed-up batch state. + let lookback = + kand::ohlcv::macd::lookback(MACD_FAST, MACD_SLOW, MACD_SIGNAL).unwrap(); + let mut macd_line = vec![0.0; series.len()]; + let mut signal_line = vec![0.0; series.len()]; + let mut histogram = vec![0.0; series.len()]; + let mut fast_ema = vec![0.0; series.len()]; + let mut slow_ema = vec![0.0; series.len()]; + kand::ohlcv::macd::macd( + series, + MACD_FAST, + MACD_SLOW, + MACD_SIGNAL, + &mut macd_line, + &mut signal_line, + &mut histogram, + &mut fast_ema, + &mut slow_ema, + ) + .unwrap(); + let seed_fast = fast_ema[lookback]; + let seed_slow = slow_ema[lookback]; + let seed_signal = signal_line[lookback]; + bencher.iter(|| { + // macd_inc returns (macd, signal, hist) but not the new EMAs, so the + // fast/slow/signal state is threaded with kand's own ema_inc primitive. + let mut prev_fast = seed_fast; + let mut prev_slow = seed_slow; + let mut prev_signal = seed_signal; + for &price in &series[lookback + 1..] { + let fast = + kand::ohlcv::ema::ema_inc(price, prev_fast, MACD_FAST, None).unwrap(); + let slow = + kand::ohlcv::ema::ema_inc(price, prev_slow, MACD_SLOW, None).unwrap(); + let macd = fast - slow; + let signal = + kand::ohlcv::ema::ema_inc(macd, prev_signal, MACD_SIGNAL, None) + .unwrap(); + prev_fast = fast; + prev_slow = slow; + prev_signal = signal; + black_box((macd, signal, macd - signal)); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/batch", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut macd_line = vec![0.0; series.len()]; + let mut signal_line = vec![0.0; series.len()]; + let mut histogram = vec![0.0; series.len()]; + let mut fast_ema = vec![0.0; series.len()]; + let mut slow_ema = vec![0.0; series.len()]; + kand::ohlcv::macd::macd( + series, + MACD_FAST, + MACD_SLOW, + MACD_SIGNAL, + &mut macd_line, + &mut signal_line, + &mut histogram, + &mut fast_ema, + &mut slow_ema, + ) + .unwrap(); + black_box(&macd_line); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("ta-rs/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = ta::indicators::MovingAverageConvergenceDivergence::new( + MACD_FAST, + MACD_SLOW, + MACD_SIGNAL, + ) + .unwrap(); + for &price in series { + black_box(ta::Next::next(&mut ind, price)); + } + }); + }, + ); + } + group.finish(); +} + +fn bbands_group(crit: &mut Criterion, closes: &[f64]) { + let mut group = crit.benchmark_group("bollinger_20_2"); + for &len in SIZES { + let len = len.min(closes.len()); + let series: &[f64] = &closes[..len]; + group.throughput(Throughput::Elements(len as u64)); + + group.bench_with_input( + BenchmarkId::new("wickra/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = BollingerBands::new(BB_PERIOD, BB_DEV).unwrap(); + for &price in series { + black_box(ind.update(price)); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("wickra/batch", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = BollingerBands::new(BB_PERIOD, BB_DEV).unwrap(); + black_box(ind.batch(series)); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/stream", len), + &series, + |bencher, &series| { + // Seed running sma/sum/sum_sq from kand's batch state at the warmup edge. + let mut upper = vec![0.0; series.len()]; + let mut middle = vec![0.0; series.len()]; + let mut lower = vec![0.0; series.len()]; + let mut sma = vec![0.0; series.len()]; + let mut variance = vec![0.0; series.len()]; + let mut sum = vec![0.0; series.len()]; + let mut sum_sq = vec![0.0; series.len()]; + kand::ohlcv::bbands::bbands( + series, + BB_PERIOD, + BB_DEV, + BB_DEV, + &mut upper, + &mut middle, + &mut lower, + &mut sma, + &mut variance, + &mut sum, + &mut sum_sq, + ) + .unwrap(); + let seed_sma = sma[BB_PERIOD - 1]; + let seed_sum = sum[BB_PERIOD - 1]; + let seed_sum_sq = sum_sq[BB_PERIOD - 1]; + bencher.iter(|| { + let mut prev_sma = seed_sma; + let mut prev_sum = seed_sum; + let mut prev_sum_sq = seed_sum_sq; + for idx in BB_PERIOD..series.len() { + let result = kand::ohlcv::bbands::bbands_inc( + series[idx], + prev_sma, + prev_sum, + prev_sum_sq, + series[idx - BB_PERIOD], + BB_PERIOD, + BB_DEV, + BB_DEV, + ) + .unwrap(); + prev_sma = result.1; + prev_sum = result.4; + prev_sum_sq = result.5; + black_box((result.0, result.1, result.2)); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/batch", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut upper = vec![0.0; series.len()]; + let mut middle = vec![0.0; series.len()]; + let mut lower = vec![0.0; series.len()]; + let mut sma = vec![0.0; series.len()]; + let mut variance = vec![0.0; series.len()]; + let mut sum = vec![0.0; series.len()]; + let mut sum_sq = vec![0.0; series.len()]; + kand::ohlcv::bbands::bbands( + series, + BB_PERIOD, + BB_DEV, + BB_DEV, + &mut upper, + &mut middle, + &mut lower, + &mut sma, + &mut variance, + &mut sum, + &mut sum_sq, + ) + .unwrap(); + black_box(&upper); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("ta-rs/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = ta::indicators::BollingerBands::new(BB_PERIOD, BB_DEV).unwrap(); + for &price in series { + black_box(ta::Next::next(&mut ind, price)); + } + }); + }, + ); + } + group.finish(); +} + +fn atr_group(crit: &mut Criterion, candles: &[Candle]) { + let mut group = crit.benchmark_group("atr_14"); + for &len in SIZES { + let len = len.min(candles.len()); + let series: &[Candle] = &candles[..len]; + group.throughput(Throughput::Elements(len as u64)); + + group.bench_with_input( + BenchmarkId::new("wickra/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = Atr::new(ATR_PERIOD).unwrap(); + for &candle in series { + black_box(ind.update(candle)); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("wickra/batch", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = Atr::new(ATR_PERIOD).unwrap(); + black_box(ind.batch(series)); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/stream", len), + &series, + |bencher, &series| { + 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(); + // Seed prev_atr from kand's batch ATR at the first valid index (= period). + let mut atr_out = vec![0.0; series.len()]; + kand::ohlcv::atr::atr(&high, &low, &close, ATR_PERIOD, &mut atr_out).unwrap(); + let seed_atr = atr_out[ATR_PERIOD]; + bencher.iter(|| { + let mut prev_atr = seed_atr; + for idx in ATR_PERIOD + 1..series.len() { + prev_atr = kand::ohlcv::atr::atr_inc( + high[idx], + low[idx], + close[idx - 1], + prev_atr, + ATR_PERIOD, + ) + .unwrap(); + black_box(prev_atr); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/batch", len), + &series, + |bencher, &series| { + 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 atr_out = vec![0.0; series.len()]; + kand::ohlcv::atr::atr(&high, &low, &close, ATR_PERIOD, &mut atr_out).unwrap(); + black_box(&atr_out); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("ta-rs/stream", len), + &series, + |bencher, &series| { + let items: Vec = series + .iter() + .map(|candle| { + ta::DataItem::builder() + .open(candle.open) + .high(candle.high) + .low(candle.low) + .close(candle.close) + .volume(candle.volume) + .build() + .unwrap() + }) + .collect(); + bencher.iter(|| { + let mut ind = ta::indicators::AverageTrueRange::new(ATR_PERIOD).unwrap(); + for item in &items { + black_box(ta::Next::next(&mut ind, item)); + } + }); + }, + ); + } + group.finish(); +} + +fn benches(crit: &mut Criterion) { + let candles = load_candles(); + let closes: Vec = candles.iter().map(|candle| candle.close).collect(); + sma_group(crit, &closes); + ema_group(crit, &closes); + rsi_group(crit, &closes); + macd_group(crit, &closes); + bbands_group(crit, &closes); + atr_group(crit, &candles); +} + +criterion_group!(name = cross_lib; config = Criterion::default(); targets = benches); +criterion_main!(cross_lib); diff --git a/crates/wickra-bench/src/lib.rs b/crates/wickra-bench/src/lib.rs new file mode 100644 index 00000000..02b392f6 --- /dev/null +++ b/crates/wickra-bench/src/lib.rs @@ -0,0 +1,6 @@ +//! Internal cross-library benchmark harness for Wickra. +//! +//! This crate is `publish = false`. It exists only to host the Criterion +//! benchmark in `benches/cross_lib.rs`, which compares Wickra against the +//! Rust technical-analysis crates `kand`, `ta` (ta-rs) and `yata` on an +//! identical candle series. It deliberately carries no library code. diff --git a/crates/wickra-core/src/indicators/atr.rs b/crates/wickra-core/src/indicators/atr.rs index da61f37e..de1376a8 100644 --- a/crates/wickra-core/src/indicators/atr.rs +++ b/crates/wickra-core/src/indicators/atr.rs @@ -28,9 +28,17 @@ use crate::traits::Indicator; #[derive(Debug, Clone)] pub struct Atr { period: usize, + /// `period - 1` as `f64`, precomputed for the Wilder smoothing step. + n_minus_1: f64, + /// `1 / period`, precomputed so the per-tick smoothing multiplies instead of + /// divides. + inv_period: f64, prev_close: Option, seed_buf: Vec, - avg: Option, + /// Smoothed ATR, valid once `seeded` is set. Bare `f64` + flag rather than + /// `Option` so the hot recurrence avoids an enum-tag read per tick. + avg: f64, + seeded: bool, } impl Atr { @@ -45,9 +53,12 @@ impl Atr { } Ok(Self { period, + n_minus_1: (period - 1) as f64, + inv_period: 1.0 / period as f64, prev_close: None, seed_buf: Vec::with_capacity(period), - avg: None, + avg: 0.0, + seeded: false, }) } @@ -58,7 +69,11 @@ impl Atr { /// Current value if available. pub const fn value(&self) -> Option { - self.avg + if self.seeded { + Some(self.avg) + } else { + None + } } } @@ -70,17 +85,18 @@ impl Indicator for Atr { let tr = candle.true_range(self.prev_close); self.prev_close = Some(candle.close); - if let Some(avg) = self.avg { - let n = self.period as f64; - let new_avg = avg.mul_add(n - 1.0, tr) / n; - self.avg = Some(new_avg); + if self.seeded { + // Wilder smoothing with the reciprocal hoisted out of the hot path. + let new_avg = self.avg.mul_add(self.n_minus_1, tr) * self.inv_period; + self.avg = new_avg; return Some(new_avg); } self.seed_buf.push(tr); if self.seed_buf.len() == self.period { let seed = self.seed_buf.iter().copied().sum::() / self.period as f64; - self.avg = Some(seed); + self.avg = seed; + self.seeded = true; return Some(seed); } None @@ -89,7 +105,8 @@ impl Indicator for Atr { fn reset(&mut self) { self.prev_close = None; self.seed_buf.clear(); - self.avg = None; + self.avg = 0.0; + self.seeded = false; } fn warmup_period(&self) -> usize { @@ -97,7 +114,7 @@ impl Indicator for Atr { } fn is_ready(&self) -> bool { - self.avg.is_some() + self.seeded } fn name(&self) -> &'static str { diff --git a/crates/wickra-core/src/indicators/bollinger.rs b/crates/wickra-core/src/indicators/bollinger.rs index 8a8825a7..72761f08 100644 --- a/crates/wickra-core/src/indicators/bollinger.rs +++ b/crates/wickra-core/src/indicators/bollinger.rs @@ -1,7 +1,5 @@ //! Bollinger Bands. -use std::collections::VecDeque; - use crate::error::{Error, Result}; use crate::traits::Indicator; @@ -49,7 +47,13 @@ pub struct BollingerOutput { pub struct BollingerBands { period: usize, multiplier: f64, - window: VecDeque, + /// Fixed-capacity ring buffer of the last `period` finite inputs. A flat + /// `Box<[f64]>` with a manual write cursor beats `VecDeque` on this hot path. + buf: Box<[f64]>, + /// Index of the next slot to write — also the oldest element once full. + head: usize, + /// Number of slots filled, saturating at `period`. + count: usize, sum: f64, sum_sq: f64, /// Number of finite updates since the running sums were last reseeded @@ -80,7 +84,9 @@ impl BollingerBands { Ok(Self { period, multiplier, - window: VecDeque::with_capacity(period), + buf: vec![0.0; period].into_boxed_slice(), + head: 0, + count: 0, sum: 0.0, sum_sq: 0.0, updates_since_recompute: 0, @@ -103,7 +109,7 @@ impl BollingerBands { } fn current(&self) -> Option { - if self.window.len() != self.period { + if self.count != self.period { return None; } let n = self.period as f64; @@ -129,25 +135,38 @@ impl Indicator for BollingerBands { if !input.is_finite() { return self.current(); } - if self.window.len() == self.period { - let old = self.window.pop_front().expect("non-empty"); + if self.count == self.period { + let old = self.buf[self.head]; self.sum -= old; self.sum_sq -= old * old; + self.buf[self.head] = input; + self.sum += input; + self.sum_sq += input * input; + } else { + self.buf[self.head] = input; + self.sum += input; + self.sum_sq += input * input; + self.count += 1; + } + self.head += 1; + if self.head == self.period { + self.head = 0; } - self.window.push_back(input); - self.sum += input; - self.sum_sq += input * input; self.updates_since_recompute += 1; if self.updates_since_recompute >= RECOMPUTE_EVERY * self.period { - self.sum = self.window.iter().copied().sum(); - self.sum_sq = self.window.iter().copied().map(|x| x * x).sum(); + // Reseed in chronological order (oldest at `head`) to keep the running + // sums bit-equivalent to a fresh from-scratch pass on stable inputs. + let chronological = self.buf[self.head..].iter().chain(&self.buf[..self.head]); + self.sum = chronological.clone().copied().sum(); + self.sum_sq = chronological.map(|&x| x * x).sum(); self.updates_since_recompute = 0; } self.current() } fn reset(&mut self) { - self.window.clear(); + self.head = 0; + self.count = 0; self.sum = 0.0; self.sum_sq = 0.0; self.updates_since_recompute = 0; @@ -158,7 +177,7 @@ impl Indicator for BollingerBands { } fn is_ready(&self) -> bool { - self.window.len() == self.period + self.count == self.period } fn name(&self) -> &'static str { @@ -171,6 +190,7 @@ mod tests { use super::*; use crate::traits::BatchExt; use approx::assert_relative_eq; + use std::collections::VecDeque; fn naive(prices: &[f64], period: usize, mult: f64) -> BollingerOutput { assert!( diff --git a/crates/wickra-core/src/indicators/ema.rs b/crates/wickra-core/src/indicators/ema.rs index bcbcde4d..f46ce800 100644 --- a/crates/wickra-core/src/indicators/ema.rs +++ b/crates/wickra-core/src/indicators/ema.rs @@ -25,7 +25,15 @@ use crate::traits::Indicator; pub struct Ema { period: usize, alpha: f64, - state: Option, + /// `1 - alpha`, precomputed so the recurrence avoids a subtraction per tick. + /// Cached value, so the steady-state output is bit-for-bit unchanged. + one_minus_alpha: f64, + /// Latest EMA value, valid only once `seeded` is true. Stored as a bare `f64` + /// (plus the `seeded` flag) rather than `Option` so the steady-state + /// recurrence reads and writes 8 bytes with no enum-tag handling per tick. + current: f64, + /// Whether `current` holds a real value yet (warmup complete). + seeded: bool, warmup_buf: Vec, } @@ -43,7 +51,9 @@ impl Ema { Ok(Self { period, alpha, - state: None, + one_minus_alpha: 1.0 - alpha, + current: 0.0, + seeded: false, warmup_buf: Vec::with_capacity(period), }) } @@ -66,7 +76,9 @@ impl Ema { Ok(Self { period: 1, alpha, - state: None, + one_minus_alpha: 1.0 - alpha, + current: 0.0, + seeded: false, warmup_buf: Vec::with_capacity(1), }) } @@ -83,21 +95,28 @@ impl Ema { /// Current value if available. pub const fn value(&self) -> Option { - self.state + if self.seeded { + Some(self.current) + } else { + None + } } /// 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 { - if let Some(prev) = self.state { - let new = self.alpha.mul_add(input, (1.0 - self.alpha) * prev); - self.state = Some(new); + if self.seeded { + let new = self + .alpha + .mul_add(input, self.one_minus_alpha * self.current); + self.current = new; return Some(new); } self.warmup_buf.push(input); if self.warmup_buf.len() == self.period { let seed = self.warmup_buf.iter().copied().sum::() / self.period as f64; - self.state = Some(seed); + self.current = seed; + self.seeded = true; return Some(seed); } None @@ -110,13 +129,14 @@ impl Indicator for Ema { fn update(&mut self, input: f64) -> Option { if !input.is_finite() { - return self.state; + return self.value(); } self.step_unchecked(input) } fn reset(&mut self) { - self.state = None; + self.current = 0.0; + self.seeded = false; self.warmup_buf.clear(); } @@ -125,7 +145,7 @@ impl Indicator for Ema { } fn is_ready(&self) -> bool { - self.state.is_some() + self.seeded } fn name(&self) -> &'static str { diff --git a/crates/wickra-core/src/indicators/rsi.rs b/crates/wickra-core/src/indicators/rsi.rs index a8f77f50..5ae0e188 100644 --- a/crates/wickra-core/src/indicators/rsi.rs +++ b/crates/wickra-core/src/indicators/rsi.rs @@ -25,13 +25,24 @@ use crate::traits::Indicator; #[derive(Debug, Clone)] pub struct Rsi { period: usize, - prev_close: Option, + /// `period - 1` as `f64`, precomputed for the Wilder smoothing step. + n_minus_1: f64, + /// `1 / period`, precomputed so the per-tick smoothing multiplies instead of + /// divides (a reciprocal is hoisted out of the hot path). + inv_period: f64, + /// Previous close, valid once `has_prev` is set. Bare `f64` + flag instead of + /// `Option` to avoid an enum-tag read on every tick. + prev_close: f64, + has_prev: bool, // Wilder seeds with the simple average of the first `period` gains/losses, // then transitions to recursive smoothing. seed_buf_gains: Vec, seed_buf_losses: Vec, - avg_gain: Option, - avg_loss: Option, + /// Smoothed average gain / loss, valid once `avgs_seeded` is set. Bare `f64`s + /// + flag so the hot recurrence avoids reading two `Option` tags per tick. + avg_gain: f64, + avg_loss: f64, + avgs_seeded: bool, last_value: Option, } @@ -47,11 +58,15 @@ impl Rsi { } Ok(Self { period, - prev_close: None, + n_minus_1: (period - 1) as f64, + inv_period: 1.0 / period as f64, + prev_close: 0.0, + has_prev: false, seed_buf_gains: Vec::with_capacity(period), seed_buf_losses: Vec::with_capacity(period), - avg_gain: None, - avg_loss: None, + avg_gain: 0.0, + avg_loss: 0.0, + avgs_seeded: false, last_value: None, }) } @@ -67,16 +82,16 @@ impl Rsi { } fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 { - if avg_loss == 0.0 { - if avg_gain == 0.0 { - // No movement at all -> RSI undefined; standard convention returns 50. - 50.0 - } else { - 100.0 - } + // Algebraically `100 - 100/(1 + ag/al)` collapses to `100·ag/(ag+al)`, + // which needs a single division instead of two and removes the separate + // `rs` step. Edge cases stay exact: `al == 0, ag > 0` gives `100·ag/ag = + // 100`; `ag == 0, al > 0` gives `0`; both zero (no movement) is the + // undefined case and returns the neutral 50. + let denom = avg_gain + avg_loss; + if denom == 0.0 { + 50.0 } else { - let rs = avg_gain / avg_loss; - 100.0 - 100.0 / (1.0 + rs) + 100.0 * avg_gain / denom } } } @@ -90,22 +105,25 @@ impl Indicator for Rsi { return self.last_value; } - let Some(prev) = self.prev_close else { - self.prev_close = Some(input); + if !self.has_prev { + self.prev_close = input; + self.has_prev = true; return None; - }; - self.prev_close = Some(input); + } + let prev = self.prev_close; + self.prev_close = input; let diff = input - prev; let gain = if diff > 0.0 { diff } else { 0.0 }; let loss = if diff < 0.0 { -diff } else { 0.0 }; - if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) { - let n = self.period as f64; - let new_ag = (ag * (n - 1.0) + gain) / n; - let new_al = (al * (n - 1.0) + loss) / n; - self.avg_gain = Some(new_ag); - self.avg_loss = Some(new_al); + if self.avgs_seeded { + // Wilder smoothing `(prev·(n-1) + x) / n` with the reciprocal hoisted: + // a fused multiply-add then a multiply by `1/n`, no per-tick division. + let new_ag = self.avg_gain.mul_add(self.n_minus_1, gain) * self.inv_period; + let new_al = self.avg_loss.mul_add(self.n_minus_1, loss) * self.inv_period; + self.avg_gain = new_ag; + self.avg_loss = new_al; let v = Self::rsi_from_avgs(new_ag, new_al); self.last_value = Some(v); return Some(v); @@ -116,8 +134,9 @@ impl Indicator for Rsi { if self.seed_buf_gains.len() == self.period { let ag = self.seed_buf_gains.iter().sum::() / self.period as f64; let al = self.seed_buf_losses.iter().sum::() / self.period as f64; - self.avg_gain = Some(ag); - self.avg_loss = Some(al); + self.avg_gain = ag; + self.avg_loss = al; + self.avgs_seeded = true; let v = Self::rsi_from_avgs(ag, al); self.last_value = Some(v); return Some(v); @@ -126,11 +145,13 @@ impl Indicator for Rsi { } fn reset(&mut self) { - self.prev_close = None; + self.prev_close = 0.0; + self.has_prev = false; self.seed_buf_gains.clear(); self.seed_buf_losses.clear(); - self.avg_gain = None; - self.avg_loss = None; + self.avg_gain = 0.0; + self.avg_loss = 0.0; + self.avgs_seeded = false; self.last_value = None; } diff --git a/crates/wickra-core/src/indicators/sma.rs b/crates/wickra-core/src/indicators/sma.rs index f8ed8d55..6a2d85d6 100644 --- a/crates/wickra-core/src/indicators/sma.rs +++ b/crates/wickra-core/src/indicators/sma.rs @@ -1,7 +1,5 @@ //! Simple Moving Average. -use std::collections::VecDeque; - use crate::error::{Error, Result}; use crate::traits::Indicator; @@ -33,7 +31,14 @@ use crate::traits::Indicator; #[derive(Debug, Clone)] pub struct Sma { period: usize, - window: VecDeque, + /// Fixed-capacity ring buffer of the last `period` finite inputs. A flat + /// `Box<[f64]>` with a manual write cursor beats `VecDeque` on this hot path: + /// sequential storage, branchless wraparound, no per-call bookkeeping. + buf: Box<[f64]>, + /// Index of the next slot to write — also the oldest element once full. + head: usize, + /// Number of slots filled, saturating at `period`. + count: usize, sum: f64, /// Number of finite updates since the running `sum` was last reseeded from /// the live window. Caps accumulated floating-point drift on long streams. @@ -60,7 +65,9 @@ impl Sma { } Ok(Self { period, - window: VecDeque::with_capacity(period), + buf: vec![0.0; period].into_boxed_slice(), + head: 0, + count: 0, sum: 0.0, updates_since_recompute: 0, }) @@ -73,7 +80,7 @@ impl Sma { /// Current value if available. pub fn value(&self) -> Option { - if self.window.len() == self.period { + if self.count == self.period { Some(self.sum / self.period as f64) } else { None @@ -89,25 +96,40 @@ impl Indicator for Sma { if !input.is_finite() { return self.value(); } - if self.window.len() == self.period { - // Slide: drop the oldest, then add the new. Each step is a single - // f64 add/subtract — O(1) but introduces ~1 ULP of rounding noise. - // The periodic reseed below caps the accumulated drift. - let old = self.window.pop_front().expect("window non-empty"); - self.sum -= old; + if self.count == self.period { + // Window full: overwrite the oldest slot (at `head`). Each step is a + // single f64 add/subtract — O(1) but introduces ~1 ULP of rounding + // noise. The periodic reseed below caps the accumulated drift. + self.sum -= self.buf[self.head]; + self.buf[self.head] = input; + self.sum += input; + } else { + self.buf[self.head] = input; + self.sum += input; + self.count += 1; + } + // Branchless-ish wraparound, cheaper than `% period`. + self.head += 1; + if self.head == self.period { + self.head = 0; } - self.window.push_back(input); - self.sum += input; self.updates_since_recompute += 1; if self.updates_since_recompute >= RECOMPUTE_EVERY * self.period { - self.sum = self.window.iter().copied().sum(); + // Reseed in chronological order (oldest at `head`) so the running sum + // tracks a fresh from-scratch mean to the bit on stable inputs. + self.sum = self.buf[self.head..] + .iter() + .chain(&self.buf[..self.head]) + .copied() + .sum(); self.updates_since_recompute = 0; } self.value() } fn reset(&mut self) { - self.window.clear(); + self.head = 0; + self.count = 0; self.sum = 0.0; self.updates_since_recompute = 0; } @@ -117,7 +139,7 @@ impl Indicator for Sma { } fn is_ready(&self) -> bool { - self.window.len() == self.period + self.count == self.period } fn name(&self) -> &'static str { @@ -130,6 +152,7 @@ mod tests { use super::*; use crate::traits::BatchExt; use approx::assert_relative_eq; + use std::collections::VecDeque; #[test] fn new_rejects_zero_period() { diff --git a/examples/rust/Cargo.toml b/examples/rust/Cargo.toml index f1f2b626..e37e0eb8 100644 --- a/examples/rust/Cargo.toml +++ b/examples/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wickra-examples" -version = "0.0.0" +version.workspace = true publish = false description = "Runnable Rust examples for the Wickra technical-analysis library." authors.workspace = true