Compare commits

...
2 Commits
Author SHA1 Message Date
kingchencandGitHub 654da5722f release: bump 0.5.8 -> 0.5.9 (#187)
Patch release: streaming/batch perf (SMA, Bollinger, RSI, EMA, ATR; outputs unchanged), cross-library benchmark harness, honest tiered README. No new indicators, no API changes.
2026-06-06 21:09:05 +02:00
kingchencandGitHub aacb9280f1 Honest tiered cross-library benchmark + streaming/batch perf (#186)
## 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<f64>` 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.
2026-06-06 20:57:31 +02:00
27 changed files with 1466 additions and 191 deletions
+2
View File
@@ -5,5 +5,7 @@
maturin maturin
numpy numpy
pandas pandas
TA-Lib
tulipy
talipp talipp
finta finta
+80
View File
@@ -1,5 +1,13 @@
# This file was autogenerated by uv via the following command: # This file was autogenerated by uv via the following command:
# ./scripts/update-lockfiles.sh # ./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 \ finta==1.3 \
--hash=sha256:b94b94df311c18bf5402eb2fe8fd2db5e1bdaff08baf58a7367d05c7abdd10d3 \ --hash=sha256:b94b94df311c18bf5402eb2fe8fd2db5e1bdaff08baf58a7367d05c7abdd10d3 \
--hash=sha256:f2fa0673748f4be8f57e57cf6d5c00a4d44bc6071ea69dbb9a1d329d045cbba2 --hash=sha256:f2fa0673748f4be8f57e57cf6d5c00a4d44bc6071ea69dbb9a1d329d045cbba2
@@ -97,6 +105,12 @@ numpy==2.4.6 \
# -r .github/requirements/bench.in # -r .github/requirements/bench.in
# finta # finta
# pandas # pandas
# ta-lib
# tulipy
packaging==26.2 \
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
# via build
pandas==3.0.3 \ pandas==3.0.3 \
--hash=sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c \ --hash=sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c \
--hash=sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2 \ --hash=sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2 \
@@ -149,6 +163,10 @@ pandas==3.0.3 \
# via # via
# -r .github/requirements/bench.in # -r .github/requirements/bench.in
# finta # finta
pyproject-hooks==1.2.0 \
--hash=sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8 \
--hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913
# via build
python-dateutil==2.9.0.post0 \ python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
@@ -157,10 +175,72 @@ six==1.17.0 \
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via python-dateutil # 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 \ talipp==2.7.0 \
--hash=sha256:567f59ad74366cb59a14a00d350f35fd9d22e6924d6228bad581e6dcf1de2205 \ --hash=sha256:567f59ad74366cb59a14a00d350f35fd9d22e6924d6228bad581e6dcf1de2205 \
--hash=sha256:f749f22b9ad615605e71faf26457bb7f5e3fe16f04d3287f4ca54fd16bc3d4eb --hash=sha256:f749f22b9ad615605e71faf26457bb7f5e3fe16f04d3287f4ca54fd16bc3d4eb
# via -r .github/requirements/bench.in # 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 \ tzdata==2026.2 \
--hash=sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10 \ --hash=sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10 \
--hash=sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7 --hash=sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7
+26
View File
@@ -117,3 +117,29 @@ jobs:
with: with:
name: cross-library-bench name: cross-library-bench
path: bindings/python/benchmark.txt 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
+22 -1
View File
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.5.9] - 2026-06-06
### 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 ## [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`). - **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`). - **MACD Histogram** — the standalone macd-minus-signal bar of MACD as a scalar series (`MacdHistogram`).
@@ -1273,7 +1293,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
optional Binance live feed. optional Binance live feed.
- Bindings for Python, Node.js, and WebAssembly. - Bindings for Python, Node.js, and WebAssembly.
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.5.8...HEAD [Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.5.9...HEAD
[0.5.9]: https://github.com/wickra-lib/wickra/compare/v0.5.8...v0.5.9
[0.5.8]: https://github.com/wickra-lib/wickra/compare/v0.5.7...v0.5.8 [0.5.8]: https://github.com/wickra-lib/wickra/compare/v0.5.7...v0.5.8
[0.5.7]: https://github.com/wickra-lib/wickra/compare/v0.5.6...v0.5.7 [0.5.7]: https://github.com/wickra-lib/wickra/compare/v0.5.6...v0.5.7
[0.5.6]: https://github.com/wickra-lib/wickra/compare/v0.5.5...v0.5.6 [0.5.6]: https://github.com/wickra-lib/wickra/compare/v0.5.5...v0.5.6
Generated
+114 -7
View File
@@ -702,6 +702,16 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "leb128fmt" name = "leb128fmt"
version = "0.1.0" version = "0.1.0"
@@ -911,6 +921,28 @@ dependencies = [
"libm", "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]] [[package]]
name = "numpy" name = "numpy"
version = "0.28.0" version = "0.28.0"
@@ -1081,6 +1113,15 @@ dependencies = [
"syn", "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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@@ -1498,6 +1539,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "ta"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "609409d472a0a7d8d4dd9e19891bbdef546b9dce670c3057d0e02192dc541226"
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.13.5" version = "0.13.5"
@@ -1607,6 +1654,36 @@ dependencies = [
"tungstenite", "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]] [[package]]
name = "tungstenite" name = "tungstenite"
version = "0.29.0" version = "0.29.0"
@@ -1867,7 +1944,7 @@ dependencies = [
[[package]] [[package]]
name = "wickra" name = "wickra"
version = "0.5.8" version = "0.5.9"
dependencies = [ dependencies = [
"approx", "approx",
"criterion", "criterion",
@@ -1876,9 +1953,21 @@ dependencies = [
"wickra-data", "wickra-data",
] ]
[[package]]
name = "wickra-bench"
version = "0.5.9"
dependencies = [
"criterion",
"kand",
"ta",
"wickra",
"wickra-data",
"yata",
]
[[package]] [[package]]
name = "wickra-core" name = "wickra-core"
version = "0.5.8" version = "0.5.9"
dependencies = [ dependencies = [
"approx", "approx",
"proptest", "proptest",
@@ -1888,7 +1977,7 @@ dependencies = [
[[package]] [[package]]
name = "wickra-data" name = "wickra-data"
version = "0.5.8" version = "0.5.9"
dependencies = [ dependencies = [
"approx", "approx",
"csv", "csv",
@@ -1905,7 +1994,7 @@ dependencies = [
[[package]] [[package]]
name = "wickra-examples" name = "wickra-examples"
version = "0.0.0" version = "0.5.9"
dependencies = [ dependencies = [
"serde_json", "serde_json",
"tokio", "tokio",
@@ -1915,7 +2004,7 @@ dependencies = [
[[package]] [[package]]
name = "wickra-node" name = "wickra-node"
version = "0.5.8" version = "0.5.9"
dependencies = [ dependencies = [
"napi", "napi",
"napi-build", "napi-build",
@@ -1925,7 +2014,7 @@ dependencies = [
[[package]] [[package]]
name = "wickra-python" name = "wickra-python"
version = "0.5.8" version = "0.5.9"
dependencies = [ dependencies = [
"numpy", "numpy",
"pyo3", "pyo3",
@@ -1934,7 +2023,7 @@ dependencies = [
[[package]] [[package]]
name = "wickra-wasm" name = "wickra-wasm"
version = "0.5.8" version = "0.5.9"
dependencies = [ dependencies = [
"console_error_panic_hook", "console_error_panic_hook",
"js-sys", "js-sys",
@@ -1991,6 +2080,15 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "winnow"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.51.0" version = "0.51.0"
@@ -2091,6 +2189,15 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "yata"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b4ef8ddfa3ccd93454262c0e60a43a2bbf403d404174e1815f7581d5028229f"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.2" version = "0.8.2"
+3 -2
View File
@@ -8,11 +8,12 @@ members = [
"bindings/wasm", "bindings/wasm",
"bindings/node", "bindings/node",
"examples/rust", "examples/rust",
"crates/wickra-bench",
] ]
exclude = ["fuzz"] exclude = ["fuzz"]
[workspace.package] [workspace.package]
version = "0.5.8" version = "0.5.9"
authors = ["kingchenc <support@wickra.org>"] authors = ["kingchenc <support@wickra.org>"]
edition = "2021" edition = "2021"
rust-version = "1.86" rust-version = "1.86"
@@ -24,7 +25,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
categories = ["finance", "mathematics", "science"] categories = ["finance", "mathematics", "science"]
[workspace.dependencies] [workspace.dependencies]
wickra-core = { path = "crates/wickra-core", version = "0.5.8" } wickra-core = { path = "crates/wickra-core", version = "0.5.9" }
thiserror = "2" thiserror = "2"
rayon = "1.10" rayon = "1.10"
+120 -58
View File
@@ -60,77 +60,129 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
## Why Wickra exists ## Why Wickra exists
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta, Wickra started as a personal itch. The existing TA libraries never quite fit the
talipp, tulipy — and every one of them shares the same blind spot: 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 | Plenty of TA libraries are fast. Each one forces a trade-off Wickra does not:
|------------------------|-----------------|-----------|----------------|--------|
| **★&nbsp;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 |
Wickra is the only library that combines all of: clean install, streaming, | Library | Install | Streaming | Languages | Indicators | Active |
multi-language reach, and active maintenance. |------------------|-------------|-------------|-----------------------------|-----------:|--------|
| **★&nbsp;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 **On speed — and why Wickra isn't the fastest.** It deliberately isn't. The
guaranteed to reproduce identically on different hardware — absolute µs values leaner Rust crates (kand, ta-rs) win several of the micro-benchmarks below, and
depend on CPU, memory clock and OS scheduler. Read them as **relative those losses are shown rather than hidden. The gap is a *choice*, not a ceiling:
speedups** between libraries on identical input, not as a universal every `update` validates its input, runs a real warmup before it emits a value,
performance contract. 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, - **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`), Rust 1.92 (release: `lto = "fat"`, `codegen-units = 1`), Python 3.12.
Python 3.12, Node 20. - **Reproduce yourself:**
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then - Rust core vs Rust crates: `cargo bench -p wickra-bench`
`python -m benchmarks.compare_libraries`. The script auto-detects every - Python vs Python libs: `pip install -e bindings/python[bench]` then
installed peer library and runs them on the same generated inputs as `python -m benchmarks.compare_libraries` (auto-detects installed peers).
Wickra. The CI job `cross-library-bench` runs the same script on every
push and uploads the raw report as a build artefact.
Lower µs/op = faster. Wickra wins every batch category outright, and the ### 1. Rust core vs the other Rust TA crates
streaming gap widens linearly with how much history a batch-only library has
to recompute on every tick.
### 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 **Streaming** (one value fed per `update`):
slower it is than Wickra in parentheses. **★** marks the winner per row.
| Indicator | **★&nbsp;Wickra** | finta | talipp | | Indicator | **★&nbsp;Wickra** | kand | ta-rs | yata |
|---------------------|---------------------|-----------------------------|-------------------------------| |------------------|------------------:|-----:|------:|-----:|
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) | | SMA(20) | 50 | 38 | 47 | 38 |
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) | | EMA(20) | 154 | 69 | 56 | 69 |
| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) | | RSI(14) | 164 | 216 | 74 | — |
| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) | | MACD(12, 26, 9) | 275 | 143 | 66 | — |
| Bollinger(20, 2.0) | **105.3 µs** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)| | Bollinger(20, 2) | **128** | 248 | 168 | — |
| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) | | 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 | Indicator | **★&nbsp;Wickra** | kand |
every new tick; Wickra updates state in O(1). |------------------|------------------:|-----:|
| 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 | **★&nbsp;Wickra (per tick)** | talipp (per tick) | 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
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) | `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 ### 2. Python vs the Python TA ecosystem — batch
> cleanly on Windows without C build tooling — which is precisely the install
> pain Wickra was built to remove. The benchmark script auto-detects every Full pass over a 20 000-bar series, µs/op (lower = faster). **★** per row.
> 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. | Indicator | **★&nbsp;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 | **★&nbsp;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: Run the suite yourself:
```bash ```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 python -m benchmarks.compare_libraries
``` ```
@@ -247,7 +299,8 @@ wickra/
├── crates/ ├── crates/
│ ├── wickra-core/ core engine + all 423 indicators │ ├── wickra-core/ core engine + all 423 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/ │ ├── 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/ ├── bindings/
│ ├── python/ PyO3 + maturin (publishes on PyPI) │ ├── python/ PyO3 + maturin (publishes on PyPI)
│ ├── node/ napi-rs (publishes on npm) │ ├── node/ napi-rs (publishes on npm)
@@ -261,9 +314,10 @@ wickra/
└── .github/workflows/ CI and release pipelines └── .github/workflows/ CI and release pipelines
``` ```
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live Wickra's own regression benchmarks live in `crates/wickra/benches/`; the
in the workspace member crate at `examples/rust/`. There is no top-level cross-library comparison against kand, ta-rs and yata lives in the internal
`benches/` directory. `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 ## Building everything from source
@@ -271,7 +325,8 @@ in the workspace member crate at `examples/rust/`. There is no top-level
# Rust core + tests # Rust core + tests
cargo test --workspace cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings 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) # Python binding (requires Rust toolchain + maturin)
cd bindings/python cd bindings/python
@@ -371,3 +426,10 @@ The library is provided **as is**, without warranty of any kind; see
<p align="center"> <p align="center">
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo. If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
</p> </p>
<p align="center">
<a href="https://github.com/wickra-lib/wickra">
<img alt="Star Wickra on GitHub"
src="https://img.shields.io/badge/%E2%AD%90%20Star%20Wickra%20on%20GitHub-1f2328?style=for-the-badge&logo=github&logoColor=ffd866&labelColor=1f2328">
</a>
</p>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "wickra-darwin-arm64", "name": "wickra-darwin-arm64",
"version": "0.5.8", "version": "0.5.9",
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.", "description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-arm64.node", "main": "wickra.darwin-arm64.node",
"files": [ "files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "wickra-darwin-x64", "name": "wickra-darwin-x64",
"version": "0.5.8", "version": "0.5.9",
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.", "description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-x64.node", "main": "wickra.darwin-x64.node",
"files": [ "files": [
@@ -1,6 +1,6 @@
{ {
"name": "wickra-linux-arm64-gnu", "name": "wickra-linux-arm64-gnu",
"version": "0.5.8", "version": "0.5.9",
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.", "description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-arm64-gnu.node", "main": "wickra.linux-arm64-gnu.node",
"files": [ "files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "wickra-linux-x64-gnu", "name": "wickra-linux-x64-gnu",
"version": "0.5.8", "version": "0.5.9",
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.", "description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-x64-gnu.node", "main": "wickra.linux-x64-gnu.node",
"files": [ "files": [
@@ -1,6 +1,6 @@
{ {
"name": "wickra-win32-arm64-msvc", "name": "wickra-win32-arm64-msvc",
"version": "0.5.8", "version": "0.5.9",
"description": "Native binding for wickra (Windows arm64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.", "description": "Native binding for wickra (Windows arm64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-arm64-msvc.node", "main": "wickra.win32-arm64-msvc.node",
"files": [ "files": [
@@ -1,6 +1,6 @@
{ {
"name": "wickra-win32-x64-msvc", "name": "wickra-win32-x64-msvc",
"version": "0.5.8", "version": "0.5.9",
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.", "description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-x64-msvc.node", "main": "wickra.win32-x64-msvc.node",
"files": [ "files": [
+20 -20
View File
@@ -1,12 +1,12 @@
{ {
"name": "wickra", "name": "wickra",
"version": "0.5.8", "version": "0.5.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "wickra", "name": "wickra",
"version": "0.5.8", "version": "0.5.9",
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"devDependencies": { "devDependencies": {
"@napi-rs/cli": "^2.18.0" "@napi-rs/cli": "^2.18.0"
@@ -15,12 +15,12 @@
"node": ">= 18" "node": ">= 18"
}, },
"optionalDependencies": { "optionalDependencies": {
"wickra-darwin-arm64": "0.5.8", "wickra-darwin-arm64": "0.5.9",
"wickra-darwin-x64": "0.5.8", "wickra-darwin-x64": "0.5.9",
"wickra-linux-arm64-gnu": "0.5.8", "wickra-linux-arm64-gnu": "0.5.9",
"wickra-linux-x64-gnu": "0.5.8", "wickra-linux-x64-gnu": "0.5.9",
"wickra-win32-arm64-msvc": "0.5.8", "wickra-win32-arm64-msvc": "0.5.9",
"wickra-win32-x64-msvc": "0.5.8" "wickra-win32-x64-msvc": "0.5.9"
} }
}, },
"node_modules/@napi-rs/cli": { "node_modules/@napi-rs/cli": {
@@ -41,8 +41,8 @@
} }
}, },
"node_modules/wickra-darwin-arm64": { "node_modules/wickra-darwin-arm64": {
"version": "0.5.8", "version": "0.5.9",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.8.tgz", "resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.9.tgz",
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==", "integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -57,8 +57,8 @@
} }
}, },
"node_modules/wickra-darwin-x64": { "node_modules/wickra-darwin-x64": {
"version": "0.5.8", "version": "0.5.9",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.8.tgz", "resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.9.tgz",
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==", "integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -73,8 +73,8 @@
} }
}, },
"node_modules/wickra-linux-arm64-gnu": { "node_modules/wickra-linux-arm64-gnu": {
"version": "0.5.8", "version": "0.5.9",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.8.tgz", "resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.9.tgz",
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==", "integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -89,8 +89,8 @@
} }
}, },
"node_modules/wickra-linux-x64-gnu": { "node_modules/wickra-linux-x64-gnu": {
"version": "0.5.8", "version": "0.5.9",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.8.tgz", "resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.9.tgz",
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==", "integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -105,8 +105,8 @@
} }
}, },
"node_modules/wickra-win32-arm64-msvc": { "node_modules/wickra-win32-arm64-msvc": {
"version": "0.5.8", "version": "0.5.9",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.8.tgz", "resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.9.tgz",
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==", "integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -121,8 +121,8 @@
} }
}, },
"node_modules/wickra-win32-x64-msvc": { "node_modules/wickra-win32-x64-msvc": {
"version": "0.5.8", "version": "0.5.9",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.8.tgz", "resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.9.tgz",
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==", "integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
"cpu": [ "cpu": [
"x64" "x64"
+7 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "wickra", "name": "wickra",
"version": "0.5.8", "version": "0.5.9",
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.", "description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
"author": "kingchenc <support@wickra.org>", "author": "kingchenc <support@wickra.org>",
"main": "index.js", "main": "index.js",
@@ -47,12 +47,12 @@
"node": ">= 18" "node": ">= 18"
}, },
"optionalDependencies": { "optionalDependencies": {
"wickra-linux-x64-gnu": "0.5.8", "wickra-linux-x64-gnu": "0.5.9",
"wickra-linux-arm64-gnu": "0.5.8", "wickra-linux-arm64-gnu": "0.5.9",
"wickra-darwin-x64": "0.5.8", "wickra-darwin-x64": "0.5.9",
"wickra-darwin-arm64": "0.5.8", "wickra-darwin-arm64": "0.5.9",
"wickra-win32-x64-msvc": "0.5.8", "wickra-win32-x64-msvc": "0.5.9",
"wickra-win32-arm64-msvc": "0.5.8" "wickra-win32-arm64-msvc": "0.5.9"
}, },
"scripts": { "scripts": {
"build": "napi build --platform --release", "build": "napi build --platform --release",
@@ -49,6 +49,7 @@ TALIB = _try_import("talib")
PANDAS_TA = _try_import("pandas_ta") PANDAS_TA = _try_import("pandas_ta")
TALIPP = _try_import("talipp.indicators") or _try_import("talipp") TALIPP = _try_import("talipp.indicators") or _try_import("talipp")
FINTA = _try_import("finta") FINTA = _try_import("finta")
TULIPY = _try_import("tulipy")
PD = _try_import("pandas") PD = _try_import("pandas")
import wickra as WICKRA # noqa: E402 -- the library under test must be importable 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)) 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 # Streaming scenario: per-tick latency
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -329,6 +358,105 @@ def talipp_rsi_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callabl
return run 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 # Runner
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -339,6 +467,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_sma_batch), ("Wickra", wickra_sma_batch),
("TA-Lib", talib_sma_batch), ("TA-Lib", talib_sma_batch),
("pandas-ta", pandas_ta_sma_batch), ("pandas-ta", pandas_ta_sma_batch),
("tulipy", tulipy_sma_batch),
("finta", finta_sma_batch), ("finta", finta_sma_batch),
("talipp", talipp_sma_batch), ("talipp", talipp_sma_batch),
]), ]),
@@ -346,6 +475,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_ema_batch), ("Wickra", wickra_ema_batch),
("TA-Lib", talib_ema_batch), ("TA-Lib", talib_ema_batch),
("pandas-ta", pandas_ta_ema_batch), ("pandas-ta", pandas_ta_ema_batch),
("tulipy", tulipy_ema_batch),
("finta", finta_ema_batch), ("finta", finta_ema_batch),
("talipp", talipp_ema_batch), ("talipp", talipp_ema_batch),
]), ]),
@@ -353,6 +483,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_rsi_batch), ("Wickra", wickra_rsi_batch),
("TA-Lib", talib_rsi_batch), ("TA-Lib", talib_rsi_batch),
("pandas-ta", pandas_ta_rsi_batch), ("pandas-ta", pandas_ta_rsi_batch),
("tulipy", tulipy_rsi_batch),
("finta", finta_rsi_batch), ("finta", finta_rsi_batch),
("talipp", talipp_rsi_batch), ("talipp", talipp_rsi_batch),
]), ]),
@@ -360,6 +491,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_macd_batch), ("Wickra", wickra_macd_batch),
("TA-Lib", talib_macd_batch), ("TA-Lib", talib_macd_batch),
("pandas-ta", pandas_ta_macd_batch), ("pandas-ta", pandas_ta_macd_batch),
("tulipy", tulipy_macd_batch),
("finta", finta_macd_batch), ("finta", finta_macd_batch),
("talipp", talipp_macd_batch), ("talipp", talipp_macd_batch),
]), ]),
@@ -367,6 +499,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_bollinger_batch), ("Wickra", wickra_bollinger_batch),
("TA-Lib", talib_bollinger_batch), ("TA-Lib", talib_bollinger_batch),
("pandas-ta", pandas_ta_bollinger_batch), ("pandas-ta", pandas_ta_bollinger_batch),
("tulipy", tulipy_bollinger_batch),
("finta", finta_bollinger_batch), ("finta", finta_bollinger_batch),
("talipp", talipp_bollinger_batch), ("talipp", talipp_bollinger_batch),
]), ]),
@@ -376,18 +509,35 @@ OHLC_INDICATORS = [
("ATR(14)", [ ("ATR(14)", [
("Wickra", wickra_atr_batch), ("Wickra", wickra_atr_batch),
("TA-Lib", talib_atr_batch), ("TA-Lib", talib_atr_batch),
("tulipy", tulipy_atr_batch),
("finta", finta_atr_batch), ("finta", finta_atr_batch),
("talipp", talipp_atr_batch), ("talipp", talipp_atr_batch),
]), ]),
] ]
STREAMING_INDICATORS = [ STREAMING_INDICATORS = [
("SMA(20)", [
("Wickra", wickra_sma_streaming),
("talipp", talipp_sma_streaming),
]),
("EMA(20)", [
("Wickra", wickra_ema_streaming),
("talipp", talipp_ema_streaming),
]),
("RSI(14)", [ ("RSI(14)", [
("Wickra", wickra_rsi_streaming), ("Wickra", wickra_rsi_streaming),
("TA-Lib", talib_rsi_streaming), ("TA-Lib", talib_rsi_streaming),
("pandas-ta", pandas_ta_rsi_streaming), ("pandas-ta", pandas_ta_rsi_streaming),
("talipp", talipp_rsi_streaming), ("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 = [] available = []
if TALIB is not None: available.append("TA-Lib") if TALIB is not None: available.append("TA-Lib")
if PANDAS_TA is not None: available.append("pandas-ta") 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 FINTA is not None: available.append("finta")
if TALIPP is not None: available.append("talipp") if TALIPP is not None: available.append("talipp")
print(f"Wickra benchmark suite — wickra=v{WICKRA.__version__}") print(f"Wickra benchmark suite — wickra=v{WICKRA.__version__}")
+2 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project] [project]
name = "wickra" name = "wickra"
version = "0.5.8" version = "0.5.9"
description = "Streaming-first technical indicators: incremental, fast, install-free." description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md" readme = "README.md"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
@@ -39,6 +39,7 @@ bench = [
"pytest-benchmark>=4", "pytest-benchmark>=4",
"TA-Lib; platform_system != 'Windows'", "TA-Lib; platform_system != 'Windows'",
"pandas-ta>=0.3.14b", "pandas-ta>=0.3.14b",
"tulipy>=0.4; platform_system != 'Windows'",
"talipp>=2", "talipp>=2",
"finta>=1.3", "finta>=1.3",
"pandas>=2", "pandas>=2",
+22
View File
@@ -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
+695
View File
@@ -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<Candle> {
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::<f64>() / 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<f64> = series.iter().map(|candle| candle.high).collect();
let low: Vec<f64> = series.iter().map(|candle| candle.low).collect();
let close: Vec<f64> = series.iter().map(|candle| candle.close).collect();
// 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<f64> = series.iter().map(|candle| candle.high).collect();
let low: Vec<f64> = series.iter().map(|candle| candle.low).collect();
let close: Vec<f64> = series.iter().map(|candle| candle.close).collect();
bencher.iter(|| {
let mut 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<ta::DataItem> = 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<f64> = 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);
+6
View File
@@ -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.
+27 -10
View File
@@ -28,9 +28,17 @@ use crate::traits::Indicator;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Atr { pub struct Atr {
period: usize, 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<f64>, prev_close: Option<f64>,
seed_buf: Vec<f64>, seed_buf: Vec<f64>,
avg: Option<f64>, /// Smoothed ATR, valid once `seeded` is set. Bare `f64` + flag rather than
/// `Option<f64>` so the hot recurrence avoids an enum-tag read per tick.
avg: f64,
seeded: bool,
} }
impl Atr { impl Atr {
@@ -45,9 +53,12 @@ impl Atr {
} }
Ok(Self { Ok(Self {
period, period,
n_minus_1: (period - 1) as f64,
inv_period: 1.0 / period as f64,
prev_close: None, prev_close: None,
seed_buf: Vec::with_capacity(period), seed_buf: Vec::with_capacity(period),
avg: None, avg: 0.0,
seeded: false,
}) })
} }
@@ -58,7 +69,11 @@ impl Atr {
/// Current value if available. /// Current value if available.
pub const fn value(&self) -> Option<f64> { pub const fn value(&self) -> Option<f64> {
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); let tr = candle.true_range(self.prev_close);
self.prev_close = Some(candle.close); self.prev_close = Some(candle.close);
if let Some(avg) = self.avg { if self.seeded {
let n = self.period as f64; // Wilder smoothing with the reciprocal hoisted out of the hot path.
let new_avg = avg.mul_add(n - 1.0, tr) / n; let new_avg = self.avg.mul_add(self.n_minus_1, tr) * self.inv_period;
self.avg = Some(new_avg); self.avg = new_avg;
return Some(new_avg); return Some(new_avg);
} }
self.seed_buf.push(tr); self.seed_buf.push(tr);
if self.seed_buf.len() == self.period { if self.seed_buf.len() == self.period {
let seed = self.seed_buf.iter().copied().sum::<f64>() / self.period as f64; let seed = self.seed_buf.iter().copied().sum::<f64>() / self.period as f64;
self.avg = Some(seed); self.avg = seed;
self.seeded = true;
return Some(seed); return Some(seed);
} }
None None
@@ -89,7 +105,8 @@ impl Indicator for Atr {
fn reset(&mut self) { fn reset(&mut self) {
self.prev_close = None; self.prev_close = None;
self.seed_buf.clear(); self.seed_buf.clear();
self.avg = None; self.avg = 0.0;
self.seeded = false;
} }
fn warmup_period(&self) -> usize { fn warmup_period(&self) -> usize {
@@ -97,7 +114,7 @@ impl Indicator for Atr {
} }
fn is_ready(&self) -> bool { fn is_ready(&self) -> bool {
self.avg.is_some() self.seeded
} }
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
+34 -14
View File
@@ -1,7 +1,5 @@
//! Bollinger Bands. //! Bollinger Bands.
use std::collections::VecDeque;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::traits::Indicator; use crate::traits::Indicator;
@@ -49,7 +47,13 @@ pub struct BollingerOutput {
pub struct BollingerBands { pub struct BollingerBands {
period: usize, period: usize,
multiplier: f64, multiplier: f64,
window: VecDeque<f64>, /// 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: f64,
sum_sq: f64, sum_sq: f64,
/// Number of finite updates since the running sums were last reseeded /// Number of finite updates since the running sums were last reseeded
@@ -80,7 +84,9 @@ impl BollingerBands {
Ok(Self { Ok(Self {
period, period,
multiplier, multiplier,
window: VecDeque::with_capacity(period), buf: vec![0.0; period].into_boxed_slice(),
head: 0,
count: 0,
sum: 0.0, sum: 0.0,
sum_sq: 0.0, sum_sq: 0.0,
updates_since_recompute: 0, updates_since_recompute: 0,
@@ -103,7 +109,7 @@ impl BollingerBands {
} }
fn current(&self) -> Option<BollingerOutput> { fn current(&self) -> Option<BollingerOutput> {
if self.window.len() != self.period { if self.count != self.period {
return None; return None;
} }
let n = self.period as f64; let n = self.period as f64;
@@ -129,25 +135,38 @@ impl Indicator for BollingerBands {
if !input.is_finite() { if !input.is_finite() {
return self.current(); return self.current();
} }
if self.window.len() == self.period { if self.count == self.period {
let old = self.window.pop_front().expect("non-empty"); let old = self.buf[self.head];
self.sum -= old; self.sum -= old;
self.sum_sq -= old * 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; self.updates_since_recompute += 1;
if self.updates_since_recompute >= RECOMPUTE_EVERY * self.period { if self.updates_since_recompute >= RECOMPUTE_EVERY * self.period {
self.sum = self.window.iter().copied().sum(); // Reseed in chronological order (oldest at `head`) to keep the running
self.sum_sq = self.window.iter().copied().map(|x| x * x).sum(); // 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.updates_since_recompute = 0;
} }
self.current() self.current()
} }
fn reset(&mut self) { fn reset(&mut self) {
self.window.clear(); self.head = 0;
self.count = 0;
self.sum = 0.0; self.sum = 0.0;
self.sum_sq = 0.0; self.sum_sq = 0.0;
self.updates_since_recompute = 0; self.updates_since_recompute = 0;
@@ -158,7 +177,7 @@ impl Indicator for BollingerBands {
} }
fn is_ready(&self) -> bool { fn is_ready(&self) -> bool {
self.window.len() == self.period self.count == self.period
} }
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
@@ -171,6 +190,7 @@ mod tests {
use super::*; use super::*;
use crate::traits::BatchExt; use crate::traits::BatchExt;
use approx::assert_relative_eq; use approx::assert_relative_eq;
use std::collections::VecDeque;
fn naive(prices: &[f64], period: usize, mult: f64) -> BollingerOutput { fn naive(prices: &[f64], period: usize, mult: f64) -> BollingerOutput {
assert!( assert!(
+31 -11
View File
@@ -25,7 +25,15 @@ use crate::traits::Indicator;
pub struct Ema { pub struct Ema {
period: usize, period: usize,
alpha: f64, alpha: f64,
state: Option<f64>, /// `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<f64>` 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<f64>, warmup_buf: Vec<f64>,
} }
@@ -43,7 +51,9 @@ impl Ema {
Ok(Self { Ok(Self {
period, period,
alpha, alpha,
state: None, one_minus_alpha: 1.0 - alpha,
current: 0.0,
seeded: false,
warmup_buf: Vec::with_capacity(period), warmup_buf: Vec::with_capacity(period),
}) })
} }
@@ -66,7 +76,9 @@ impl Ema {
Ok(Self { Ok(Self {
period: 1, period: 1,
alpha, alpha,
state: None, one_minus_alpha: 1.0 - alpha,
current: 0.0,
seeded: false,
warmup_buf: Vec::with_capacity(1), warmup_buf: Vec::with_capacity(1),
}) })
} }
@@ -83,21 +95,28 @@ impl Ema {
/// Current value if available. /// Current value if available.
pub const fn value(&self) -> Option<f64> { pub const fn value(&self) -> Option<f64> {
self.state if self.seeded {
Some(self.current)
} else {
None
}
} }
/// Internal helper that feeds a value without finiteness validation. The caller /// Internal helper that feeds a value without finiteness validation. The caller
/// guarantees `input.is_finite()`. Used by MACD which has already validated. /// guarantees `input.is_finite()`. Used by MACD which has already validated.
pub(crate) fn step_unchecked(&mut self, input: f64) -> Option<f64> { pub(crate) fn step_unchecked(&mut self, input: f64) -> Option<f64> {
if let Some(prev) = self.state { if self.seeded {
let new = self.alpha.mul_add(input, (1.0 - self.alpha) * prev); let new = self
self.state = Some(new); .alpha
.mul_add(input, self.one_minus_alpha * self.current);
self.current = new;
return Some(new); return Some(new);
} }
self.warmup_buf.push(input); self.warmup_buf.push(input);
if self.warmup_buf.len() == self.period { if self.warmup_buf.len() == self.period {
let seed = self.warmup_buf.iter().copied().sum::<f64>() / self.period as f64; let seed = self.warmup_buf.iter().copied().sum::<f64>() / self.period as f64;
self.state = Some(seed); self.current = seed;
self.seeded = true;
return Some(seed); return Some(seed);
} }
None None
@@ -110,13 +129,14 @@ impl Indicator for Ema {
fn update(&mut self, input: f64) -> Option<f64> { fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() { if !input.is_finite() {
return self.state; return self.value();
} }
self.step_unchecked(input) self.step_unchecked(input)
} }
fn reset(&mut self) { fn reset(&mut self) {
self.state = None; self.current = 0.0;
self.seeded = false;
self.warmup_buf.clear(); self.warmup_buf.clear();
} }
@@ -125,7 +145,7 @@ impl Indicator for Ema {
} }
fn is_ready(&self) -> bool { fn is_ready(&self) -> bool {
self.state.is_some() self.seeded
} }
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
+51 -30
View File
@@ -25,13 +25,24 @@ use crate::traits::Indicator;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Rsi { pub struct Rsi {
period: usize, period: usize,
prev_close: Option<f64>, /// `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<f64>` 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, // Wilder seeds with the simple average of the first `period` gains/losses,
// then transitions to recursive smoothing. // then transitions to recursive smoothing.
seed_buf_gains: Vec<f64>, seed_buf_gains: Vec<f64>,
seed_buf_losses: Vec<f64>, seed_buf_losses: Vec<f64>,
avg_gain: Option<f64>, /// Smoothed average gain / loss, valid once `avgs_seeded` is set. Bare `f64`s
avg_loss: Option<f64>, /// + flag so the hot recurrence avoids reading two `Option<f64>` tags per tick.
avg_gain: f64,
avg_loss: f64,
avgs_seeded: bool,
last_value: Option<f64>, last_value: Option<f64>,
} }
@@ -47,11 +58,15 @@ impl Rsi {
} }
Ok(Self { Ok(Self {
period, 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_gains: Vec::with_capacity(period),
seed_buf_losses: Vec::with_capacity(period), seed_buf_losses: Vec::with_capacity(period),
avg_gain: None, avg_gain: 0.0,
avg_loss: None, avg_loss: 0.0,
avgs_seeded: false,
last_value: None, last_value: None,
}) })
} }
@@ -67,16 +82,16 @@ impl Rsi {
} }
fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 { fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
if avg_loss == 0.0 { // Algebraically `100 - 100/(1 + ag/al)` collapses to `100·ag/(ag+al)`,
if avg_gain == 0.0 { // which needs a single division instead of two and removes the separate
// No movement at all -> RSI undefined; standard convention returns 50. // `rs` step. Edge cases stay exact: `al == 0, ag > 0` gives `100·ag/ag =
50.0 // 100`; `ag == 0, al > 0` gives `0`; both zero (no movement) is the
} else { // undefined case and returns the neutral 50.
100.0 let denom = avg_gain + avg_loss;
} if denom == 0.0 {
50.0
} else { } else {
let rs = avg_gain / avg_loss; 100.0 * avg_gain / denom
100.0 - 100.0 / (1.0 + rs)
} }
} }
} }
@@ -90,22 +105,25 @@ impl Indicator for Rsi {
return self.last_value; return self.last_value;
} }
let Some(prev) = self.prev_close else { if !self.has_prev {
self.prev_close = Some(input); self.prev_close = input;
self.has_prev = true;
return None; return None;
}; }
self.prev_close = Some(input); let prev = self.prev_close;
self.prev_close = input;
let diff = input - prev; let diff = input - prev;
let gain = if diff > 0.0 { diff } else { 0.0 }; let gain = if diff > 0.0 { diff } else { 0.0 };
let loss = 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) { if self.avgs_seeded {
let n = self.period as f64; // Wilder smoothing `(prev·(n-1) + x) / n` with the reciprocal hoisted:
let new_ag = (ag * (n - 1.0) + gain) / n; // a fused multiply-add then a multiply by `1/n`, no per-tick division.
let new_al = (al * (n - 1.0) + loss) / n; let new_ag = self.avg_gain.mul_add(self.n_minus_1, gain) * self.inv_period;
self.avg_gain = Some(new_ag); let new_al = self.avg_loss.mul_add(self.n_minus_1, loss) * self.inv_period;
self.avg_loss = Some(new_al); self.avg_gain = new_ag;
self.avg_loss = new_al;
let v = Self::rsi_from_avgs(new_ag, new_al); let v = Self::rsi_from_avgs(new_ag, new_al);
self.last_value = Some(v); self.last_value = Some(v);
return Some(v); return Some(v);
@@ -116,8 +134,9 @@ impl Indicator for Rsi {
if self.seed_buf_gains.len() == self.period { if self.seed_buf_gains.len() == self.period {
let ag = self.seed_buf_gains.iter().sum::<f64>() / self.period as f64; let ag = self.seed_buf_gains.iter().sum::<f64>() / self.period as f64;
let al = self.seed_buf_losses.iter().sum::<f64>() / self.period as f64; let al = self.seed_buf_losses.iter().sum::<f64>() / self.period as f64;
self.avg_gain = Some(ag); self.avg_gain = ag;
self.avg_loss = Some(al); self.avg_loss = al;
self.avgs_seeded = true;
let v = Self::rsi_from_avgs(ag, al); let v = Self::rsi_from_avgs(ag, al);
self.last_value = Some(v); self.last_value = Some(v);
return Some(v); return Some(v);
@@ -126,11 +145,13 @@ impl Indicator for Rsi {
} }
fn reset(&mut self) { 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_gains.clear();
self.seed_buf_losses.clear(); self.seed_buf_losses.clear();
self.avg_gain = None; self.avg_gain = 0.0;
self.avg_loss = None; self.avg_loss = 0.0;
self.avgs_seeded = false;
self.last_value = None; self.last_value = None;
} }
+39 -16
View File
@@ -1,7 +1,5 @@
//! Simple Moving Average. //! Simple Moving Average.
use std::collections::VecDeque;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::traits::Indicator; use crate::traits::Indicator;
@@ -33,7 +31,14 @@ use crate::traits::Indicator;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Sma { pub struct Sma {
period: usize, period: usize,
window: VecDeque<f64>, /// 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, sum: f64,
/// Number of finite updates since the running `sum` was last reseeded from /// Number of finite updates since the running `sum` was last reseeded from
/// the live window. Caps accumulated floating-point drift on long streams. /// the live window. Caps accumulated floating-point drift on long streams.
@@ -60,7 +65,9 @@ impl Sma {
} }
Ok(Self { Ok(Self {
period, period,
window: VecDeque::with_capacity(period), buf: vec![0.0; period].into_boxed_slice(),
head: 0,
count: 0,
sum: 0.0, sum: 0.0,
updates_since_recompute: 0, updates_since_recompute: 0,
}) })
@@ -73,7 +80,7 @@ impl Sma {
/// Current value if available. /// Current value if available.
pub fn value(&self) -> Option<f64> { pub fn value(&self) -> Option<f64> {
if self.window.len() == self.period { if self.count == self.period {
Some(self.sum / self.period as f64) Some(self.sum / self.period as f64)
} else { } else {
None None
@@ -89,25 +96,40 @@ impl Indicator for Sma {
if !input.is_finite() { if !input.is_finite() {
return self.value(); return self.value();
} }
if self.window.len() == self.period { if self.count == self.period {
// Slide: drop the oldest, then add the new. Each step is a single // Window full: overwrite the oldest slot (at `head`). Each step is a
// f64 add/subtract — O(1) but introduces ~1 ULP of rounding noise. // single f64 add/subtract — O(1) but introduces ~1 ULP of rounding
// The periodic reseed below caps the accumulated drift. // noise. The periodic reseed below caps the accumulated drift.
let old = self.window.pop_front().expect("window non-empty"); self.sum -= self.buf[self.head];
self.sum -= old; 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; self.updates_since_recompute += 1;
if self.updates_since_recompute >= RECOMPUTE_EVERY * self.period { 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.updates_since_recompute = 0;
} }
self.value() self.value()
} }
fn reset(&mut self) { fn reset(&mut self) {
self.window.clear(); self.head = 0;
self.count = 0;
self.sum = 0.0; self.sum = 0.0;
self.updates_since_recompute = 0; self.updates_since_recompute = 0;
} }
@@ -117,7 +139,7 @@ impl Indicator for Sma {
} }
fn is_ready(&self) -> bool { fn is_ready(&self) -> bool {
self.window.len() == self.period self.count == self.period
} }
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
@@ -130,6 +152,7 @@ mod tests {
use super::*; use super::*;
use crate::traits::BatchExt; use crate::traits::BatchExt;
use approx::assert_relative_eq; use approx::assert_relative_eq;
use std::collections::VecDeque;
#[test] #[test]
fn new_rejects_zero_period() { fn new_rejects_zero_period() {
+7 -7
View File
@@ -17,7 +17,7 @@
}, },
"../../bindings/node": { "../../bindings/node": {
"name": "wickra", "name": "wickra",
"version": "0.5.8", "version": "0.5.9",
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"devDependencies": { "devDependencies": {
"@napi-rs/cli": "^2.18.0" "@napi-rs/cli": "^2.18.0"
@@ -26,12 +26,12 @@
"node": ">= 18" "node": ">= 18"
}, },
"optionalDependencies": { "optionalDependencies": {
"wickra-darwin-arm64": "0.5.8", "wickra-darwin-arm64": "0.5.9",
"wickra-darwin-x64": "0.5.8", "wickra-darwin-x64": "0.5.9",
"wickra-linux-arm64-gnu": "0.5.8", "wickra-linux-arm64-gnu": "0.5.9",
"wickra-linux-x64-gnu": "0.5.8", "wickra-linux-x64-gnu": "0.5.9",
"wickra-win32-arm64-msvc": "0.5.8", "wickra-win32-arm64-msvc": "0.5.9",
"wickra-win32-x64-msvc": "0.5.8" "wickra-win32-x64-msvc": "0.5.9"
} }
}, },
"node_modules/wickra": { "node_modules/wickra": {
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "wickra-examples" name = "wickra-examples"
version = "0.0.0" version.workspace = true
publish = false publish = false
description = "Runnable Rust examples for the Wickra technical-analysis library." description = "Runnable Rust examples for the Wickra technical-analysis library."
authors.workspace = true authors.workspace = true