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.
+
+
+
+
+
+
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