Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 654da5722f | |||
| aacb9280f1 | |||
| d2bc000892 | |||
| 1f4bf9e3a6 | |||
| d36d514f56 | |||
| 6e0464930e | |||
| 13bc801f89 | |||
| ac8f6acf08 | |||
| 4f81222aed |
@@ -5,5 +5,7 @@
|
||||
maturin
|
||||
numpy
|
||||
pandas
|
||||
TA-Lib
|
||||
tulipy
|
||||
talipp
|
||||
finta
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+51
-1
@@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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
|
||||
- **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`).
|
||||
- **PPO Histogram** — the Percentage Price Oscillator with its signal EMA and the resulting zero-centered histogram (`PpoHistogram`).
|
||||
|
||||
## [0.5.7] - 2026-06-04
|
||||
- **Qstick** — Qstick (Chande), the SMA of the candle body (close − open) as a net buying/selling pressure gauge (`QSTICK`).
|
||||
- **TTM Trend** — TTM Trend (John Carter), +1/−1 by whether the close sits above the SMA of recent median prices (`TTM_TREND`).
|
||||
- **Trend Strength Index** — trend strength index, the signed r² of a linear regression of price against time (`TREND_STRENGTH_INDEX`).
|
||||
- **Polarized Fractal Efficiency** — polarized fractal efficiency (Hannula), directional trend efficiency over a fractal lookback (`POLARIZED_FRACTAL_EFFICIENCY`).
|
||||
- **Wave PM** — Wave PM (Kase), a variance-normalised peak-momentum statistic (`WAVE_PM`).
|
||||
- **Gator Oscillator** — Gator Oscillator (Bill Williams), the Alligator convergence/divergence histogram (`GATOR_OSCILLATOR`).
|
||||
- **Kase Permission Stochastic** — Kase Permission Stochastic, a double-smoothed stochastic used as a trade-permission filter (`KASE_PERMISSION_STOCHASTIC`).
|
||||
|
||||
## [0.5.6] - 2026-06-04
|
||||
- **QQE** — quantitative qualitative estimation, a smoothed RSI with an ATR-of-RSI trailing line (`QQE`).
|
||||
- **Intraday Momentum Index** — intraday momentum index (Chande), RSI on the open-to-close body (`IMI`).
|
||||
- **Elder Ray** — Elder Ray bull power and bear power around an EMA of close (`ElderRay`).
|
||||
- **Derivative Oscillator** — derivative oscillator (Constance Brown), a double-smoothed RSI histogram (`DerivativeOscillator`).
|
||||
- **RMI** — relative momentum index (RMI), RSI over a multi-bar momentum lookback (`RMI`).
|
||||
- **Stochastic CCI** — stochastic CCI, a stochastic oscillator over the CCI (`StochasticCCI`).
|
||||
- **Dynamic Momentum Index** — dynamic momentum index (Chande), a volatility-adaptive RSI (`DynamicMomentumIndex`).
|
||||
- **RSX** — RSX, a Jurik-style three-stage smoothed RSI (`RSX`).
|
||||
- **Fisher RSI** — Fisher RSI, the Fisher transform of a normalised RSI (`FisherRSI`).
|
||||
- **Disparity Index** — disparity index, the percent gap between price and its moving average (`DisparityIndex`).
|
||||
|
||||
## [0.5.5] - 2026-06-04
|
||||
- **GD** — generalized DEMA (GD), Tillson's volume-factor double EMA and the building block of T3 (`GD`).
|
||||
- **GMA** — geometric moving average (GMA), the rolling geometric mean of prices (`GMA`).
|
||||
@@ -1247,7 +1293,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
optional Binance live feed.
|
||||
- Bindings for Python, Node.js, and WebAssembly.
|
||||
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.5.5...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.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.5]: https://github.com/wickra-lib/wickra/compare/v0.5.4...v0.5.5
|
||||
[0.5.4]: https://github.com/wickra-lib/wickra/compare/v0.5.3...v0.5.4
|
||||
[0.5.3]: https://github.com/wickra-lib/wickra/compare/v0.5.2...v0.5.3
|
||||
|
||||
Generated
+114
-7
@@ -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"
|
||||
@@ -1867,7 +1944,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra"
|
||||
version = "0.5.5"
|
||||
version = "0.5.9"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1876,9 +1953,21 @@ dependencies = [
|
||||
"wickra-data",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wickra-bench"
|
||||
version = "0.5.9"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"kand",
|
||||
"ta",
|
||||
"wickra",
|
||||
"wickra-data",
|
||||
"yata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.5.5"
|
||||
version = "0.5.9"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
@@ -1888,7 +1977,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.5.5"
|
||||
version = "0.5.9"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"csv",
|
||||
@@ -1905,7 +1994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-examples"
|
||||
version = "0.0.0"
|
||||
version = "0.5.9"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -1915,7 +2004,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-node"
|
||||
version = "0.5.5"
|
||||
version = "0.5.9"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -1925,7 +2014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.5.5"
|
||||
version = "0.5.9"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -1934,7 +2023,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.5.5"
|
||||
version = "0.5.9"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
@@ -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"
|
||||
|
||||
+3
-2
@@ -8,11 +8,12 @@ members = [
|
||||
"bindings/wasm",
|
||||
"bindings/node",
|
||||
"examples/rust",
|
||||
"crates/wickra-bench",
|
||||
]
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.5"
|
||||
version = "0.5.9"
|
||||
authors = ["kingchenc <support@wickra.org>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.86"
|
||||
@@ -24,7 +25,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
|
||||
categories = ["finance", "mathematics", "science"]
|
||||
|
||||
[workspace.dependencies]
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.5.5" }
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.5.9" }
|
||||
|
||||
thiserror = "2"
|
||||
rayon = "1.10"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=403" alt="Wickra — streaming-first technical indicators" width="100%"></a>
|
||||
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=423" alt="Wickra — streaming-first technical indicators" width="100%"></a>
|
||||
</p>
|
||||
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
@@ -48,7 +48,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
|
||||
[Node](https://docs.wickra.org/Quickstart-Node),
|
||||
[WASM](https://docs.wickra.org/Quickstart-WASM).
|
||||
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
|
||||
every one of the 403 indicators; start at the
|
||||
every one of the 423 indicators; start at the
|
||||
[indicators overview](https://docs.wickra.org/Indicators-Overview).
|
||||
- **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods),
|
||||
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
|
||||
@@ -60,83 +60,135 @@ 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
|
||||
```
|
||||
|
||||
## Indicators
|
||||
|
||||
403 streaming-first indicators across twenty-four families. Every one passes the
|
||||
423 streaming-first indicators across twenty-four families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
semantics tests. Each has a per-indicator deep dive (formula, parameters,
|
||||
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
|
||||
@@ -144,9 +196,9 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
|
||||
| Family | Indicators |
|
||||
|--------|-----------|
|
||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, SWMA, GMA, EHMA, Median MA, Adaptive Laguerre, GD, Holt-Winters |
|
||||
| Momentum Oscillators | RSI (Wilder), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia, ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100) |
|
||||
| Trend & Directional | MACD, MACD Fixed (MACDFIX), MACD Extended (MACDEXT), ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter, Plus DM, Minus DM, Plus DI, Minus DI, DX |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
|
||||
| Momentum Oscillators | RSI (Wilder), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia, ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100), Disparity Index, Fisher RSI, RSX, Dynamic Momentum Index, Stochastic CCI, RMI, Derivative Oscillator, Elder Ray, Intraday Momentum Index, QQE |
|
||||
| Trend & Directional | MACD, MACD Fixed (MACDFIX), MACD Extended (MACDEXT), ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter, Plus DM, Minus DM, Plus DI, Minus DI, DX, TTM Trend, Trend Strength Index, Qstick, Polarized Fractal Efficiency, Wave PM, Gator Oscillator, Kase Permission Stochastic |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC, TSF Oscillator, MACD Histogram, PPO Histogram |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility |
|
||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
|
||||
| Trailing Stops | Parabolic SAR, Parabolic SAR Extended (SAREXT), SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
|
||||
@@ -245,9 +297,10 @@ A Python live-trading example using the public `websockets` package lives at
|
||||
```
|
||||
wickra/
|
||||
├── crates/
|
||||
│ ├── wickra-core/ core engine + all 403 indicators
|
||||
│ ├── 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
|
||||
<p align="center">
|
||||
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
|
||||
</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>
|
||||
|
||||
@@ -28,6 +28,18 @@ function num(v) {
|
||||
// --- Scalar indicators: update(value) vs batch(prices) ---
|
||||
|
||||
const scalarFactories = {
|
||||
PpoHistogram: () => new wickra.PpoHistogram(3, 6, 3),
|
||||
MacdHistogram: () => new wickra.MacdHistogram(3, 6, 3),
|
||||
TsfOscillator: () => new wickra.TsfOscillator(3),
|
||||
WAVE_PM: () => new wickra.WAVE_PM(32, 3),
|
||||
POLARIZED_FRACTAL_EFFICIENCY: () => new wickra.POLARIZED_FRACTAL_EFFICIENCY(10, 5),
|
||||
TREND_STRENGTH_INDEX: () => new wickra.TREND_STRENGTH_INDEX(20),
|
||||
DerivativeOscillator: () => new wickra.DerivativeOscillator(14, 5, 3, 9),
|
||||
RMI: () => new wickra.RMI(14, 5),
|
||||
DynamicMomentumIndex: () => new wickra.DynamicMomentumIndex(14),
|
||||
RSX: () => new wickra.RSX(14),
|
||||
FisherRSI: () => new wickra.FisherRSI(14),
|
||||
DisparityIndex: () => new wickra.DisparityIndex(14),
|
||||
HoltWinters: () => new wickra.HoltWinters(0.2, 0.1),
|
||||
GD: () => new wickra.GD(5, 0.7),
|
||||
AdaptiveLaguerre: () => new wickra.AdaptiveLaguerre(13),
|
||||
@@ -334,6 +346,10 @@ const candleScalar = {
|
||||
BodySizePct: { make: () => new wickra.BodySizePct(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
WickRatio: { make: () => new wickra.WickRatio(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
HighLowRange: { make: () => new wickra.HighLowRange(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
StochasticCCI: { make: () => new wickra.StochasticCCI(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
IMI: { make: () => new wickra.IMI(14), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TTM_TREND: { make: () => new wickra.TTM_TREND(6), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
Qstick: { make: () => new wickra.Qstick(10), step: (ind, i) => ind.update(open[i], close[i]), batch: (ind) => ind.batch(open, close) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(candleScalar)) {
|
||||
@@ -416,6 +432,10 @@ const multi = {
|
||||
FibArcs: { make: () => new wickra.FibArcs(), fields: ['arc382', 'arc500', 'arc618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
FibChannel: { make: () => new wickra.FibChannel(), fields: ['base', 'level618', 'level1000', 'level1618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
FibTimeZones: { make: () => new wickra.FibTimeZones(), fields: ['onZone', 'barsToNext'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
ElderRay: { make: () => new wickra.ElderRay(13), fields: ['bullPower', 'bearPower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
QQE: { make: () => new wickra.QQE(14, 5, 4.236), fields: ['rsiMa', 'trailingLine'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
GatorOscillator: { make: () => new wickra.GatorOscillator(13, 8, 5), fields: ['upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
KasePermissionStochastic: { make: () => new wickra.KasePermissionStochastic(9, 3), fields: ['fast', 'slow'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(multi)) {
|
||||
|
||||
Vendored
+196
@@ -69,6 +69,22 @@ export interface HtPhasorValue {
|
||||
inphase: number
|
||||
quadrature: number
|
||||
}
|
||||
export interface QqeValue {
|
||||
rsiMa: number
|
||||
trailingLine: number
|
||||
}
|
||||
export interface ElderRayValue {
|
||||
bullPower: number
|
||||
bearPower: number
|
||||
}
|
||||
export interface GatorOscillatorValue {
|
||||
upper: number
|
||||
lower: number
|
||||
}
|
||||
export interface KasePermissionStochasticValue {
|
||||
fast: number
|
||||
slow: number
|
||||
}
|
||||
export interface StochValue {
|
||||
k: number
|
||||
d: number
|
||||
@@ -917,6 +933,60 @@ export declare class AdaptiveLaguerre {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type DisparityIndexNode = DisparityIndex
|
||||
export declare class DisparityIndex {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type FisherRsiNode = FisherRSI
|
||||
export declare class FisherRSI {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type RsxNode = RSX
|
||||
export declare class RSX {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type DynamicMomentumIndexNode = DynamicMomentumIndex
|
||||
export declare class DynamicMomentumIndex {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TrendStrengthIndexNode = TREND_STRENGTH_INDEX
|
||||
export declare class TREND_STRENGTH_INDEX {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TsfOscillatorNode = TsfOscillator
|
||||
export declare class TsfOscillator {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type JumpIndicatorNode = JumpIndicator
|
||||
export declare class JumpIndicator {
|
||||
constructor(period: number, threshold: number)
|
||||
@@ -1414,6 +1484,96 @@ export declare class HighLowRange {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type StochasticCciNode = StochasticCCI
|
||||
export declare class StochasticCCI {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, close: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type ImiNode = IMI
|
||||
export declare class IMI {
|
||||
constructor(period: number)
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type QqeNode = QQE
|
||||
export declare class QQE {
|
||||
constructor(rsiPeriod: number, smoothing: number, factor: number)
|
||||
update(value: number): QqeValue | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type ElderRayNode = ElderRay
|
||||
export declare class ElderRay {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, close: number): ElderRayValue | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TtmTrendNode = TTM_TREND
|
||||
export declare class TTM_TREND {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, close: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type QstickNode = Qstick
|
||||
export declare class Qstick {
|
||||
constructor(period: number)
|
||||
update(open: number, close: number): number | null
|
||||
batch(open: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type PolarizedFractalEfficiencyNode = POLARIZED_FRACTAL_EFFICIENCY
|
||||
export declare class POLARIZED_FRACTAL_EFFICIENCY {
|
||||
constructor(period: number, smoothing: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type WavePmNode = WAVE_PM
|
||||
export declare class WAVE_PM {
|
||||
constructor(length: number, smoothing: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type GatorOscillatorNode = GatorOscillator
|
||||
export declare class GatorOscillator {
|
||||
constructor(jawPeriod: number, teethPeriod: number, lipsPeriod: number)
|
||||
update(high: number, low: number, close: number): GatorOscillatorValue | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type KasePermissionStochasticNode = KasePermissionStochastic
|
||||
export declare class KasePermissionStochastic {
|
||||
constructor(length: number, smooth: number)
|
||||
update(high: number, low: number, close: number): KasePermissionStochasticValue | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type StochNode = Stochastic
|
||||
export declare class Stochastic {
|
||||
constructor(kPeriod: number, dPeriod: number)
|
||||
@@ -1740,6 +1900,42 @@ export declare class HoltWinters {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type RmiNode = RMI
|
||||
export declare class RMI {
|
||||
constructor(period: number, momentum: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type DerivativeOscillatorNode = DerivativeOscillator
|
||||
export declare class DerivativeOscillator {
|
||||
constructor(rsiPeriod: number, smooth1: number, smooth2: number, signalPeriod: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type MacdHistogramNode = MacdHistogram
|
||||
export declare class MacdHistogram {
|
||||
constructor(fast: number, slow: number, signal: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type PpoHistogramNode = PpoHistogram
|
||||
export declare class PpoHistogram {
|
||||
constructor(fast: number, slow: number, signal: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TsiNode = TSI
|
||||
export declare class TSI {
|
||||
constructor(long: number, short: number)
|
||||
|
||||
+21
-1
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-arm64",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.9",
|
||||
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.darwin-arm64.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-x64",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.9",
|
||||
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.darwin-x64.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-linux-arm64-gnu",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.9",
|
||||
"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",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-linux-x64-gnu",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.9",
|
||||
"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",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-win32-arm64-msvc",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.9",
|
||||
"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",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-win32-x64-msvc",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.9",
|
||||
"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",
|
||||
"files": [
|
||||
|
||||
Generated
+20
-20
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.9",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
@@ -15,12 +15,12 @@
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-darwin-arm64": "0.5.5",
|
||||
"wickra-darwin-x64": "0.5.5",
|
||||
"wickra-linux-arm64-gnu": "0.5.5",
|
||||
"wickra-linux-x64-gnu": "0.5.5",
|
||||
"wickra-win32-arm64-msvc": "0.5.5",
|
||||
"wickra-win32-x64-msvc": "0.5.5"
|
||||
"wickra-darwin-arm64": "0.5.9",
|
||||
"wickra-darwin-x64": "0.5.9",
|
||||
"wickra-linux-arm64-gnu": "0.5.9",
|
||||
"wickra-linux-x64-gnu": "0.5.9",
|
||||
"wickra-win32-arm64-msvc": "0.5.9",
|
||||
"wickra-win32-x64-msvc": "0.5.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/cli": {
|
||||
@@ -41,8 +41,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-arm64": {
|
||||
"version": "0.5.5",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.5.tgz",
|
||||
"version": "0.5.9",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.9.tgz",
|
||||
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -57,8 +57,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-x64": {
|
||||
"version": "0.5.5",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.5.tgz",
|
||||
"version": "0.5.9",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.9.tgz",
|
||||
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -73,8 +73,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-arm64-gnu": {
|
||||
"version": "0.5.5",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.5.tgz",
|
||||
"version": "0.5.9",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.9.tgz",
|
||||
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -89,8 +89,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-x64-gnu": {
|
||||
"version": "0.5.5",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.5.tgz",
|
||||
"version": "0.5.9",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.9.tgz",
|
||||
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -105,8 +105,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-arm64-msvc": {
|
||||
"version": "0.5.5",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.5.tgz",
|
||||
"version": "0.5.9",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.9.tgz",
|
||||
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -121,8 +121,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-x64-msvc": {
|
||||
"version": "0.5.5",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.5.tgz",
|
||||
"version": "0.5.9",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.9.tgz",
|
||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.9",
|
||||
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
|
||||
"author": "kingchenc <support@wickra.org>",
|
||||
"main": "index.js",
|
||||
@@ -47,12 +47,12 @@
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-linux-x64-gnu": "0.5.5",
|
||||
"wickra-linux-arm64-gnu": "0.5.5",
|
||||
"wickra-darwin-x64": "0.5.5",
|
||||
"wickra-darwin-arm64": "0.5.5",
|
||||
"wickra-win32-x64-msvc": "0.5.5",
|
||||
"wickra-win32-arm64-msvc": "0.5.5"
|
||||
"wickra-linux-x64-gnu": "0.5.9",
|
||||
"wickra-linux-arm64-gnu": "0.5.9",
|
||||
"wickra-darwin-x64": "0.5.9",
|
||||
"wickra-darwin-arm64": "0.5.9",
|
||||
"wickra-win32-x64-msvc": "0.5.9",
|
||||
"wickra-win32-arm64-msvc": "0.5.9"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "napi build --platform --release",
|
||||
|
||||
@@ -206,6 +206,20 @@ node_scalar_indicator!(
|
||||
"AdaptiveLaguerre",
|
||||
wc::AdaptiveLaguerreFilter
|
||||
);
|
||||
node_scalar_indicator!(DisparityIndexNode, "DisparityIndex", wc::DisparityIndex);
|
||||
node_scalar_indicator!(FisherRsiNode, "FisherRSI", wc::FisherRsi);
|
||||
node_scalar_indicator!(RsxNode, "RSX", wc::Rsx);
|
||||
node_scalar_indicator!(
|
||||
DynamicMomentumIndexNode,
|
||||
"DynamicMomentumIndex",
|
||||
wc::DynamicMomentumIndex
|
||||
);
|
||||
node_scalar_indicator!(
|
||||
TrendStrengthIndexNode,
|
||||
"TREND_STRENGTH_INDEX",
|
||||
wc::TrendStrengthIndex
|
||||
);
|
||||
node_scalar_indicator!(TsfOscillatorNode, "TsfOscillator", wc::TsfOscillator);
|
||||
#[napi(js_name = "JumpIndicator")]
|
||||
pub struct JumpIndicatorNode {
|
||||
inner: wc::JumpIndicator,
|
||||
@@ -2051,6 +2065,564 @@ impl HighLowRangeNode {
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "StochasticCCI")]
|
||||
pub struct StochasticCciNode {
|
||||
inner: wc::StochasticCci,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl StochasticCciNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::StochasticCci::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result<Option<f64>> {
|
||||
Ok(self.inner.update(cnd(high, low, close, 0.0)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"high, low, close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(high.len());
|
||||
for i in 0..high.len() {
|
||||
out.push(
|
||||
self.inner
|
||||
.update(cnd(high[i], low[i], close[i], 0.0)?)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "IMI")]
|
||||
pub struct ImiNode {
|
||||
inner: wc::IntradayMomentumIndex,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl ImiNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::IntradayMomentumIndex::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
open: f64,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
) -> napi::Result<Option<f64>> {
|
||||
Ok(self.inner.update(cnd4(open, high, low, close)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
open: Vec<f64>,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"open, high, low, close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let n = open.len();
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
out.push(
|
||||
self.inner
|
||||
.update(cnd4(open[i], high[i], low[i], close[i])?)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct QqeValue {
|
||||
pub rsi_ma: f64,
|
||||
pub trailing_line: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "QQE")]
|
||||
pub struct QqeNode {
|
||||
inner: wc::Qqe,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl QqeNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(rsi_period: u32, smoothing: u32, factor: f64) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Qqe::new(rsi_period as usize, smoothing as usize, factor)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<QqeValue> {
|
||||
self.inner.update(value).map(|o| QqeValue {
|
||||
rsi_ma: o.rsi_ma,
|
||||
trailing_line: o.trailing_line,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
let mut out = vec![f64::NAN; prices.len() * 2];
|
||||
for (i, p) in prices.iter().enumerate() {
|
||||
if let Some(o) = self.inner.update(*p) {
|
||||
out[i * 2] = o.rsi_ma;
|
||||
out[i * 2 + 1] = o.trailing_line;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct ElderRayValue {
|
||||
pub bull_power: f64,
|
||||
pub bear_power: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "ElderRay")]
|
||||
pub struct ElderRayNode {
|
||||
inner: wc::ElderRay,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl ElderRayNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::ElderRay::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
) -> napi::Result<Option<ElderRayValue>> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(cnd(high, low, close, 0.0)?)
|
||||
.map(|o| ElderRayValue {
|
||||
bull_power: o.bull_power,
|
||||
bear_power: o.bear_power,
|
||||
}))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"high, low, close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let n = high.len();
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for i in 0..n {
|
||||
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
|
||||
out[i * 2] = o.bull_power;
|
||||
out[i * 2 + 1] = o.bear_power;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "TTM_TREND")]
|
||||
pub struct TtmTrendNode {
|
||||
inner: wc::TtmTrend,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl TtmTrendNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::TtmTrend::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result<Option<f64>> {
|
||||
Ok(self.inner.update(cnd(high, low, close, 0.0)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"high, low, close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(high.len());
|
||||
for i in 0..high.len() {
|
||||
out.push(
|
||||
self.inner
|
||||
.update(cnd(high[i], low[i], close[i], 0.0)?)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "Qstick")]
|
||||
pub struct QstickNode {
|
||||
inner: wc::Qstick,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl QstickNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Qstick::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, open: f64, close: f64) -> napi::Result<Option<f64>> {
|
||||
let hi = open.max(close);
|
||||
let lo = open.min(close);
|
||||
Ok(self.inner.update(cnd4(open, hi, lo, close)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, open: Vec<f64>, close: Vec<f64>) -> napi::Result<Vec<f64>> {
|
||||
if open.len() != close.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"open, close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(open.len());
|
||||
for i in 0..open.len() {
|
||||
let hi = open[i].max(close[i]);
|
||||
let lo = open[i].min(close[i]);
|
||||
out.push(
|
||||
self.inner
|
||||
.update(cnd4(open[i], hi, lo, close[i])?)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "POLARIZED_FRACTAL_EFFICIENCY")]
|
||||
pub struct PolarizedFractalEfficiencyNode {
|
||||
inner: wc::PolarizedFractalEfficiency,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl PolarizedFractalEfficiencyNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, smoothing: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::PolarizedFractalEfficiency::new(period as usize, smoothing as usize)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
flatten(self.inner.batch(&prices))
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "WAVE_PM")]
|
||||
pub struct WavePmNode {
|
||||
inner: wc::WavePm,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl WavePmNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(length: u32, smoothing: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::WavePm::new(length as usize, smoothing as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
flatten(self.inner.batch(&prices))
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct GatorOscillatorValue {
|
||||
pub upper: f64,
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "GatorOscillator")]
|
||||
pub struct GatorOscillatorNode {
|
||||
inner: wc::GatorOscillator,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl GatorOscillatorNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(jaw_period: u32, teeth_period: u32, lips_period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::GatorOscillator::new(
|
||||
jaw_period as usize,
|
||||
teeth_period as usize,
|
||||
lips_period as usize,
|
||||
)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
) -> napi::Result<Option<GatorOscillatorValue>> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(cnd(high, low, close, 0.0)?)
|
||||
.map(|o| GatorOscillatorValue {
|
||||
upper: o.upper,
|
||||
lower: o.lower,
|
||||
}))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"high, low, close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let n = high.len();
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for i in 0..n {
|
||||
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
|
||||
out[i * 2] = o.upper;
|
||||
out[i * 2 + 1] = o.lower;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct KasePermissionStochasticValue {
|
||||
pub fast: f64,
|
||||
pub slow: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "KasePermissionStochastic")]
|
||||
pub struct KasePermissionStochasticNode {
|
||||
inner: wc::KasePermissionStochastic,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl KasePermissionStochasticNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(length: u32, smooth: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::KasePermissionStochastic::new(length as usize, smooth as usize)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
) -> napi::Result<Option<KasePermissionStochasticValue>> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(cnd(high, low, close, 0.0)?)
|
||||
.map(|o| KasePermissionStochasticValue {
|
||||
fast: o.fast,
|
||||
slow: o.slow,
|
||||
}))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"high, low, close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let n = high.len();
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for i in 0..n {
|
||||
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
|
||||
out[i * 2] = o.fast;
|
||||
out[i * 2 + 1] = o.slow;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct StochValue {
|
||||
pub k: f64,
|
||||
@@ -3877,6 +4449,167 @@ impl HoltWintersNode {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== RMI ==============================
|
||||
|
||||
#[napi(js_name = "RMI")]
|
||||
pub struct RmiNode {
|
||||
inner: wc::Rmi,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl RmiNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, momentum: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Rmi::new(period as usize, momentum as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
flatten(self.inner.batch(&prices))
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== DerivativeOscillator ==============================
|
||||
|
||||
#[napi(js_name = "DerivativeOscillator")]
|
||||
pub struct DerivativeOscillatorNode {
|
||||
inner: wc::DerivativeOscillator,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl DerivativeOscillatorNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(
|
||||
rsi_period: u32,
|
||||
smooth1: u32,
|
||||
smooth2: u32,
|
||||
signal_period: u32,
|
||||
) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::DerivativeOscillator::new(
|
||||
rsi_period as usize,
|
||||
smooth1 as usize,
|
||||
smooth2 as usize,
|
||||
signal_period as usize,
|
||||
)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
flatten(self.inner.batch(&prices))
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== MacdHistogram ==============================
|
||||
|
||||
#[napi(js_name = "MacdHistogram")]
|
||||
pub struct MacdHistogramNode {
|
||||
inner: wc::MacdHistogram,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl MacdHistogramNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(fast: u32, slow: u32, signal: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::MacdHistogram::new(fast as usize, slow as usize, signal as usize)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
flatten(self.inner.batch(&prices))
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== PpoHistogram ==============================
|
||||
|
||||
#[napi(js_name = "PpoHistogram")]
|
||||
pub struct PpoHistogramNode {
|
||||
inner: wc::PpoHistogram,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl PpoHistogramNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(fast: u32, slow: u32, signal: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::PpoHistogram::new(fast as usize, slow as usize, signal as usize)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
flatten(self.inner.batch(&prices))
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== TSI ==============================
|
||||
|
||||
#[napi(js_name = "TSI")]
|
||||
|
||||
@@ -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__}")
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wickra"
|
||||
version = "0.5.5"
|
||||
version = "0.5.9"
|
||||
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
||||
readme = "README.md"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -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",
|
||||
|
||||
@@ -25,6 +25,26 @@ from __future__ import annotations
|
||||
|
||||
from ._wickra import (
|
||||
__version__,
|
||||
PpoHistogram,
|
||||
MacdHistogram,
|
||||
TsfOscillator,
|
||||
Qstick,
|
||||
GatorOscillator,
|
||||
KasePermissionStochastic,
|
||||
WAVE_PM,
|
||||
POLARIZED_FRACTAL_EFFICIENCY,
|
||||
TREND_STRENGTH_INDEX,
|
||||
TTM_TREND,
|
||||
QQE,
|
||||
IMI,
|
||||
ElderRay,
|
||||
DerivativeOscillator,
|
||||
RMI,
|
||||
StochasticCCI,
|
||||
DynamicMomentumIndex,
|
||||
RSX,
|
||||
FisherRSI,
|
||||
DisparityIndex,
|
||||
HoltWinters,
|
||||
GD,
|
||||
AdaptiveLaguerre,
|
||||
@@ -456,6 +476,26 @@ from ._wickra import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PpoHistogram",
|
||||
"MacdHistogram",
|
||||
"TsfOscillator",
|
||||
"Qstick",
|
||||
"GatorOscillator",
|
||||
"KasePermissionStochastic",
|
||||
"WAVE_PM",
|
||||
"POLARIZED_FRACTAL_EFFICIENCY",
|
||||
"TREND_STRENGTH_INDEX",
|
||||
"TTM_TREND",
|
||||
"QQE",
|
||||
"IMI",
|
||||
"ElderRay",
|
||||
"DerivativeOscillator",
|
||||
"RMI",
|
||||
"StochasticCCI",
|
||||
"DynamicMomentumIndex",
|
||||
"RSX",
|
||||
"FisherRSI",
|
||||
"DisparityIndex",
|
||||
"HoltWinters",
|
||||
"GD",
|
||||
"AdaptiveLaguerre",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,18 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
# --- Scalar (f64 -> f64) indicators ---------------------------------------
|
||||
|
||||
SCALAR = [
|
||||
(ta.PpoHistogram, (3, 6, 3)),
|
||||
(ta.MacdHistogram, (3, 6, 3)),
|
||||
(ta.TsfOscillator, (3,)),
|
||||
(ta.WAVE_PM, (32, 3)),
|
||||
(ta.POLARIZED_FRACTAL_EFFICIENCY, (10, 5)),
|
||||
(ta.TREND_STRENGTH_INDEX, (20,)),
|
||||
(ta.DerivativeOscillator, (14, 5, 3, 9)),
|
||||
(ta.RMI, (14, 5)),
|
||||
(ta.DynamicMomentumIndex, (14,)),
|
||||
(ta.RSX, (14,)),
|
||||
(ta.FisherRSI, (14,)),
|
||||
(ta.DisparityIndex, (14,)),
|
||||
(ta.HoltWinters, (0.2, 0.1)),
|
||||
(ta.GD, (5, 0.7)),
|
||||
(ta.AdaptiveLaguerre, (13,)),
|
||||
@@ -157,6 +169,7 @@ SCALAR = [
|
||||
# Family 05 band/channel indicators with scalar input and multi-output.
|
||||
# `cols` is the expected number of band columns from `batch`.
|
||||
SCALAR_MULTI = {
|
||||
"Qqe": (lambda: ta.QQE(14, 5, 4.236), 2),
|
||||
"MaEnvelope": (lambda: ta.MaEnvelope(20, 0.025), 3),
|
||||
"LinRegChannel": (lambda: ta.LinRegChannel(20, 2.0), 3),
|
||||
"StandardErrorBands": (lambda: ta.StandardErrorBands(21, 2.0), 3),
|
||||
@@ -348,6 +361,8 @@ def test_relative_strength_streaming_matches_batch():
|
||||
# 6-tuple candle; the batch helper takes only the columns it needs.
|
||||
|
||||
CANDLE_SCALAR = {
|
||||
"TTM_TREND": (lambda: ta.TTM_TREND(6), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
"StochasticCCI": (lambda: ta.StochasticCCI(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
# Per-bar OHLC transforms (open matters). The streaming harness feeds
|
||||
# open == close, so batch passes the close column in for open to match.
|
||||
"HighLowRange": (lambda: ta.HighLowRange(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)),
|
||||
@@ -884,6 +899,21 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
|
||||
# --- Candle-input, multi-output indicators --------------------------------
|
||||
|
||||
MULTI = {
|
||||
"KasePermissionStochastic": (
|
||||
lambda: ta.KasePermissionStochastic(9, 3),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
2,
|
||||
),
|
||||
"GatorOscillator": (
|
||||
lambda: ta.GatorOscillator(13, 8, 5),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
2,
|
||||
),
|
||||
"ElderRay": (
|
||||
lambda: ta.ElderRay(13),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
2,
|
||||
),
|
||||
"FibFan": (
|
||||
lambda: ta.FibFan(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
@@ -2741,6 +2771,125 @@ def test_spread_ar1_coefficient_reference():
|
||||
out = ta.SpreadAr1Coefficient(20).batch(a, b)
|
||||
assert math.isclose(out[-1], 1.0, abs_tol=1e-9)
|
||||
|
||||
|
||||
def test_elder_ray_reference():
|
||||
er = ta.ElderRay(3)
|
||||
high = np.array([11.0, 13.0, 16.0])
|
||||
low = np.array([9.0, 11.0, 13.0])
|
||||
close = np.array([10.0, 12.0, 14.0])
|
||||
out = er.batch(high, low, close)
|
||||
# EMA(3) seeds at the third bar with mean close 12; bar high 16 -> bull 4,
|
||||
# low 13 -> bear 1.
|
||||
assert out[2][0] == pytest.approx(4.0)
|
||||
assert out[2][1] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_imi_reference():
|
||||
imi = ta.IMI(3)
|
||||
open_ = np.array([10.0, 11.0, 10.0])
|
||||
high = np.array([12.0, 12.0, 13.0])
|
||||
low = np.array([9.0, 9.0, 9.0])
|
||||
close = np.array([11.0, 10.0, 12.0])
|
||||
out = imi.batch(open_, high, low, close)
|
||||
# bodies +1, -1, +2 -> gain 3, loss 1 -> 100 * 3 / 4 = 75.
|
||||
assert math.isnan(out[0])
|
||||
assert math.isnan(out[1])
|
||||
assert out[2] == pytest.approx(75.0)
|
||||
|
||||
|
||||
def test_qstick_reference():
|
||||
q = ta.Qstick(3)
|
||||
open_ = np.array([10.0, 10.0, 10.0])
|
||||
close = np.array([11.0, 11.0, 11.0])
|
||||
out = q.batch(open_, close)
|
||||
# Each body is close - open = 1; SMA(3) of [1, 1, 1] = 1.
|
||||
assert math.isnan(out[0])
|
||||
assert math.isnan(out[1])
|
||||
assert out[2] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_ttm_trend_reference():
|
||||
t = ta.TTM_TREND(3)
|
||||
high = np.array([13.0, 13.0, 13.0])
|
||||
low = np.array([9.0, 9.0, 9.0])
|
||||
close = np.array([12.0, 12.0, 12.0])
|
||||
out = t.batch(high, low, close)
|
||||
# Median (13 + 9) / 2 = 11; close 12 is above the SMA(3) reference -> +1.
|
||||
assert math.isnan(out[0])
|
||||
assert out[2] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_trend_strength_index_reference():
|
||||
tsi = ta.TREND_STRENGTH_INDEX(10)
|
||||
closes = np.arange(10, dtype=float)
|
||||
out = tsi.batch(closes)
|
||||
# A clean ramp is a perfect uptrend -> signed r^2 = +1.
|
||||
assert math.isclose(out[-1], 1.0, abs_tol=1e-9)
|
||||
|
||||
|
||||
def test_polarized_fractal_efficiency_reference():
|
||||
pfe = ta.POLARIZED_FRACTAL_EFFICIENCY(5, 3)
|
||||
closes = np.arange(20, dtype=float)
|
||||
out = pfe.batch(closes)
|
||||
# On a straight ramp the path equals the diagonal -> efficiency 1 -> +100.
|
||||
assert math.isclose(out[-1], 100.0, abs_tol=1e-9)
|
||||
|
||||
|
||||
def test_wave_pm_reference():
|
||||
wpm = ta.WAVE_PM(10, 3)
|
||||
closes = np.arange(60, dtype=float) * 5.0
|
||||
out = wpm.batch(closes)
|
||||
# Constant-slope ramp: momentum equals its energy -> 100 * (1 - e^-0.5).
|
||||
baseline = 100.0 * (1.0 - math.exp(-0.5))
|
||||
assert math.isclose(out[-1], baseline, abs_tol=1e-9)
|
||||
|
||||
|
||||
def test_gator_oscillator_reference():
|
||||
g = ta.GatorOscillator(13, 8, 5)
|
||||
n = 40
|
||||
high = np.full(n, 11.0)
|
||||
low = np.full(n, 9.0)
|
||||
close = np.full(n, 10.0)
|
||||
out = g.batch(high, low, close)
|
||||
# Constant median collapses all three Alligator lines -> both bars zero.
|
||||
assert out[-1][0] == pytest.approx(0.0)
|
||||
assert out[-1][1] == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_kase_permission_stochastic_reference():
|
||||
k = ta.KasePermissionStochastic(4, 2)
|
||||
n = 20
|
||||
flat = np.full(n, 10.0)
|
||||
out = k.batch(flat, flat, flat)
|
||||
# HH == LL -> raw %K defaults to the neutral 50 -> both lines at 50.
|
||||
assert out[-1][0] == pytest.approx(50.0)
|
||||
assert out[-1][1] == pytest.approx(50.0)
|
||||
|
||||
|
||||
def test_tsf_oscillator_reference():
|
||||
t = ta.TsfOscillator(3)
|
||||
assert t.update(1.0) is None
|
||||
assert t.update(2.0) is None
|
||||
assert t.update(9.0) == pytest.approx(-33.33333333333333)
|
||||
|
||||
|
||||
def test_macd_histogram_reference():
|
||||
# On a constant-slope ramp the MACD line is flat once seeded, so the
|
||||
# signal EMA catches up and the histogram collapses to 0.
|
||||
t = ta.MacdHistogram(3, 6, 3)
|
||||
for i in range(7):
|
||||
assert t.update(100.0 + i * 2.0) is None
|
||||
assert t.update(100.0 + 7 * 2.0) == pytest.approx(0.0, abs=1e-9)
|
||||
|
||||
|
||||
def test_ppo_histogram_reference():
|
||||
# PPO divides the EMA gap by the slow EMA, so on the same ramp the ratio
|
||||
# keeps drifting and the histogram stays non-zero.
|
||||
t = ta.PpoHistogram(3, 6, 3)
|
||||
for i in range(7):
|
||||
assert t.update(100.0 + i * 2.0) is None
|
||||
assert t.update(100.0 + 7 * 2.0) == pytest.approx(-0.052098, abs=1e-6)
|
||||
|
||||
# --- Lifecycle ------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -80,6 +80,14 @@ wasm_scalar_indicator!(WasmTrima, "TRIMA", wc::Trima, period: usize);
|
||||
wasm_scalar_indicator!(WasmZlema, "ZLEMA", wc::Zlema, period: usize);
|
||||
wasm_scalar_indicator!(WasmT3, "T3", wc::T3, period: usize, v: f64);
|
||||
wasm_scalar_indicator!(WasmAlma, "ALMA", wc::Alma, period: usize, offset: f64, sigma: f64);
|
||||
wasm_scalar_indicator!(
|
||||
WasmPolarizedFractalEfficiency,
|
||||
"POLARIZED_FRACTAL_EFFICIENCY",
|
||||
wc::PolarizedFractalEfficiency,
|
||||
period: usize,
|
||||
smoothing: usize
|
||||
);
|
||||
wasm_scalar_indicator!(WasmWavePm, "WAVE_PM", wc::WavePm, length: usize, smoothing: usize);
|
||||
wasm_scalar_indicator!(WasmMcGinleyDynamic, "McGinleyDynamic", wc::McGinleyDynamic, period: usize);
|
||||
wasm_scalar_indicator!(WasmFrama, "FRAMA", wc::Frama, period: usize);
|
||||
wasm_scalar_indicator!(WasmVidya, "VIDYA", wc::Vidya, period: usize, cmo_period: usize);
|
||||
@@ -2050,6 +2058,424 @@ impl WasmHighLowRange {
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = StochasticCCI)]
|
||||
pub struct WasmStochasticCci {
|
||||
inner: wc::StochasticCci,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = StochasticCCI)]
|
||||
impl WasmStochasticCci {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmStochasticCci, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::StochasticCci::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
|
||||
let c = make_candle(high, low, close, 0.0)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
if high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(JsError::new("high, low, close must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(high.len());
|
||||
for i in 0..high.len() {
|
||||
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||
out.push(self.inner.update(c).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = IMI)]
|
||||
pub struct WasmImi {
|
||||
inner: wc::IntradayMomentumIndex,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = IMI)]
|
||||
impl WasmImi {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmImi, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::IntradayMomentumIndex::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Batch over open/high/low/close arrays; `NaN` during warmup.
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
open: &[f64],
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
let n = open.len();
|
||||
if high.len() != n || low.len() != n || close.len() != n {
|
||||
return Err(JsError::new("open, high, low, close must be equal length"));
|
||||
}
|
||||
let mut out = vec![f64::NAN; n];
|
||||
for i in 0..n {
|
||||
let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?;
|
||||
if let Some(v) = self.inner.update(c) {
|
||||
out[i] = v;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
/// Streaming update over one candle's open/high/low/close.
|
||||
pub fn update(
|
||||
&mut self,
|
||||
open: f64,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
) -> Result<Option<f64>, JsError> {
|
||||
let c = make_candle_ohlc(open, high, low, close)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = QQE)]
|
||||
pub struct WasmQqe {
|
||||
inner: wc::Qqe,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = QQE)]
|
||||
impl WasmQqe {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(rsi_period: usize, smoothing: usize, factor: f64) -> Result<WasmQqe, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::Qqe::new(rsi_period, smoothing, factor).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `[rsiMa0, trailing0, rsiMa1, trailing1, ...]`, length `2 * n`.
|
||||
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
|
||||
let mut out = vec![f64::NAN; prices.len() * 2];
|
||||
for (i, p) in prices.iter().enumerate() {
|
||||
if let Some(o) = self.inner.update(*p) {
|
||||
out[i * 2] = o.rsi_ma;
|
||||
out[i * 2 + 1] = o.trailing_line;
|
||||
}
|
||||
}
|
||||
Float64Array::from(out.as_slice())
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
/// Streaming update. Returns `{ rsiMa, trailingLine }` once warm, else `null`.
|
||||
pub fn update(&mut self, value: f64) -> JsValue {
|
||||
match self.inner.update(value) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"rsiMa".into(), &o.rsi_ma.into()).ok();
|
||||
Reflect::set(&obj, &"trailingLine".into(), &o.trailing_line.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
}
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = ElderRay)]
|
||||
pub struct WasmElderRay {
|
||||
inner: wc::ElderRay,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = ElderRay)]
|
||||
impl WasmElderRay {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmElderRay, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::ElderRay::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `[bull0, bear0, bull1, bear1, ...]`, length `2 * n`.
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
let n = high.len();
|
||||
if low.len() != n || close.len() != n {
|
||||
return Err(JsError::new("high, low, close must be equal length"));
|
||||
}
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for i in 0..n {
|
||||
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||
if let Some(o) = self.inner.update(c) {
|
||||
out[i * 2] = o.bull_power;
|
||||
out[i * 2 + 1] = o.bear_power;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
/// Streaming update. Returns `{ bullPower, bearPower }` once warm, else `null`.
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
|
||||
let c = make_candle(high, low, close, 0.0)?;
|
||||
Ok(match self.inner.update(c) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"bullPower".into(), &o.bull_power.into()).ok();
|
||||
Reflect::set(&obj, &"bearPower".into(), &o.bear_power.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
})
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = TTM_TREND)]
|
||||
pub struct WasmTtmTrend {
|
||||
inner: wc::TtmTrend,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = TTM_TREND)]
|
||||
impl WasmTtmTrend {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmTtmTrend, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::TtmTrend::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
|
||||
let c = make_candle(high, low, close, 0.0)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
if high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(JsError::new("high, low, close must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(high.len());
|
||||
for i in 0..high.len() {
|
||||
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||
out.push(self.inner.update(c).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = Qstick)]
|
||||
pub struct WasmQstick {
|
||||
inner: wc::Qstick,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = Qstick)]
|
||||
impl WasmQstick {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmQstick, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::Qstick::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Batch over open/close arrays; `NaN` during warmup.
|
||||
pub fn batch(&mut self, open: &[f64], close: &[f64]) -> Result<Float64Array, JsError> {
|
||||
let n = open.len();
|
||||
if close.len() != n {
|
||||
return Err(JsError::new("open, close must be equal length"));
|
||||
}
|
||||
let mut out = vec![f64::NAN; n];
|
||||
for i in 0..n {
|
||||
let hi = open[i].max(close[i]);
|
||||
let lo = open[i].min(close[i]);
|
||||
let c = make_candle_ohlc(open[i], hi, lo, close[i])?;
|
||||
if let Some(v) = self.inner.update(c) {
|
||||
out[i] = v;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
/// Streaming update over one candle's open and close.
|
||||
pub fn update(&mut self, open: f64, close: f64) -> Result<Option<f64>, JsError> {
|
||||
let hi = open.max(close);
|
||||
let lo = open.min(close);
|
||||
let c = make_candle_ohlc(open, hi, lo, close)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = GatorOscillator)]
|
||||
pub struct WasmGatorOscillator {
|
||||
inner: wc::GatorOscillator,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = GatorOscillator)]
|
||||
impl WasmGatorOscillator {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(
|
||||
jaw_period: usize,
|
||||
teeth_period: usize,
|
||||
lips_period: usize,
|
||||
) -> Result<WasmGatorOscillator, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::GatorOscillator::new(jaw_period, teeth_period, lips_period)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `[upper0, lower0, upper1, lower1, ...]`, length `2 * n`.
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
let n = high.len();
|
||||
if low.len() != n || close.len() != n {
|
||||
return Err(JsError::new("high, low, close must be equal length"));
|
||||
}
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for i in 0..n {
|
||||
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||
if let Some(o) = self.inner.update(c) {
|
||||
out[i * 2] = o.upper;
|
||||
out[i * 2 + 1] = o.lower;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
/// Streaming update. Returns `{ upper, lower }` once warm, else `null`.
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
|
||||
let c = make_candle(high, low, close, 0.0)?;
|
||||
Ok(match self.inner.update(c) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok();
|
||||
Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
})
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = KasePermissionStochastic)]
|
||||
pub struct WasmKasePermissionStochastic {
|
||||
inner: wc::KasePermissionStochastic,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = KasePermissionStochastic)]
|
||||
impl WasmKasePermissionStochastic {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(length: usize, smooth: usize) -> Result<WasmKasePermissionStochastic, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::KasePermissionStochastic::new(length, smooth).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `[fast0, slow0, fast1, slow1, ...]`, length `2 * n`.
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
let n = high.len();
|
||||
if low.len() != n || close.len() != n {
|
||||
return Err(JsError::new("high, low, close must be equal length"));
|
||||
}
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for i in 0..n {
|
||||
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||
if let Some(o) = self.inner.update(c) {
|
||||
out[i * 2] = o.fast;
|
||||
out[i * 2 + 1] = o.slow;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
/// Streaming update. Returns `{ fast, slow }` once warm, else `null`.
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
|
||||
let c = make_candle(high, low, close, 0.0)?;
|
||||
Ok(match self.inner.update(c) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"fast".into(), &o.fast.into()).ok();
|
||||
Reflect::set(&obj, &"slow".into(), &o.slow.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
})
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = Stochastic)]
|
||||
pub struct WasmStoch {
|
||||
inner: wc::Stochastic,
|
||||
@@ -10220,6 +10646,16 @@ wasm_scalar_indicator!(WasmMedianMa, "MedianMA", wc::MedianMa, period: usize);
|
||||
wasm_scalar_indicator!(WasmAdaptiveLaguerreFilter, "AdaptiveLaguerre", wc::AdaptiveLaguerreFilter, period: usize);
|
||||
wasm_scalar_indicator!(WasmGeneralizedDema, "GD", wc::GeneralizedDema, period: usize, v: f64);
|
||||
wasm_scalar_indicator!(WasmHoltWinters, "HoltWinters", wc::HoltWinters, alpha: f64, beta: f64);
|
||||
wasm_scalar_indicator!(WasmDisparityIndex, "DisparityIndex", wc::DisparityIndex, period: usize);
|
||||
wasm_scalar_indicator!(WasmFisherRsi, "FisherRSI", wc::FisherRsi, period: usize);
|
||||
wasm_scalar_indicator!(WasmRsx, "RSX", wc::Rsx, period: usize);
|
||||
wasm_scalar_indicator!(WasmDynamicMomentumIndex, "DynamicMomentumIndex", wc::DynamicMomentumIndex, period: usize);
|
||||
wasm_scalar_indicator!(WasmRmi, "RMI", wc::Rmi, period: usize, momentum: usize);
|
||||
wasm_scalar_indicator!(WasmDerivativeOscillator, "DerivativeOscillator", wc::DerivativeOscillator, rsi_period: usize, smooth1: usize, smooth2: usize, signal_period: usize);
|
||||
wasm_scalar_indicator!(WasmTrendStrengthIndex, "TREND_STRENGTH_INDEX", wc::TrendStrengthIndex, period: usize);
|
||||
wasm_scalar_indicator!(WasmTsfOscillator, "TsfOscillator", wc::TsfOscillator, period: usize);
|
||||
wasm_scalar_indicator!(WasmMacdHistogram, "MacdHistogram", wc::MacdHistogram, fast: usize, slow: usize, signal: usize);
|
||||
wasm_scalar_indicator!(WasmPpoHistogram, "PpoHistogram", wc::PpoHistogram, fast: usize, slow: usize, signal: usize);
|
||||
|
||||
// --- DrawdownDuration: u32 output, no constructor args ---
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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.
|
||||
@@ -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<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 {
|
||||
@@ -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<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);
|
||||
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::<f64>() / 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 {
|
||||
|
||||
@@ -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<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_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<BollingerOutput> {
|
||||
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!(
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Derivative Oscillator (Constance Brown).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::indicators::rsi::Rsi;
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Derivative Oscillator — Constance Brown's double-smoothed RSI histogram.
|
||||
///
|
||||
/// The RSI is smoothed twice with EMAs, then a simple moving average of that
|
||||
/// double-smoothed line is subtracted as a signal, leaving a zero-centered
|
||||
/// histogram:
|
||||
///
|
||||
/// ```text
|
||||
/// rsi = RSI(price, rsi_period)
|
||||
/// s1 = EMA(rsi, smooth1)
|
||||
/// s2 = EMA(s1, smooth2) // double-smoothed RSI
|
||||
/// signal = SMA(s2, signal_period)
|
||||
/// DerivativeOscillator = s2 - signal
|
||||
/// ```
|
||||
///
|
||||
/// The double EMA smoothing strips the RSI's high-frequency noise, and
|
||||
/// subtracting the SMA signal removes the residual level, so the result
|
||||
/// oscillates around zero: positive (and rising) bars mark accelerating bullish
|
||||
/// momentum, negative bars bearish. Brown's defaults are `rsi_period = 14`,
|
||||
/// `smooth1 = 5`, `smooth2 = 3`, `signal_period = 9`.
|
||||
///
|
||||
/// The first value lands after `rsi_period + smooth1 + smooth2 + signal_period − 2`
|
||||
/// inputs, the point at which the whole RSI → EMA → EMA → SMA chain is seeded.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{DerivativeOscillator, Indicator};
|
||||
///
|
||||
/// let mut indicator = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DerivativeOscillator {
|
||||
rsi: Rsi,
|
||||
ema1: Ema,
|
||||
ema2: Ema,
|
||||
signal: Sma,
|
||||
warmup: usize,
|
||||
}
|
||||
|
||||
impl DerivativeOscillator {
|
||||
/// Construct a Derivative Oscillator with the RSI, two EMA smoothing, and
|
||||
/// SMA signal periods.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if any period is `0`.
|
||||
pub fn new(
|
||||
rsi_period: usize,
|
||||
smooth1: usize,
|
||||
smooth2: usize,
|
||||
signal_period: usize,
|
||||
) -> Result<Self> {
|
||||
if rsi_period == 0 || smooth1 == 0 || smooth2 == 0 || signal_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
rsi: Rsi::new(rsi_period)?,
|
||||
ema1: Ema::new(smooth1)?,
|
||||
ema2: Ema::new(smooth2)?,
|
||||
signal: Sma::new(signal_period)?,
|
||||
// RSI seeds at rsi_period + 1, then each stage adds (len - 1).
|
||||
warmup: rsi_period + smooth1 + smooth2 + signal_period - 2,
|
||||
})
|
||||
}
|
||||
|
||||
/// Total warmup length (also returned by `warmup_period`).
|
||||
pub const fn warmup(&self) -> usize {
|
||||
self.warmup
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DerivativeOscillator {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let rsi = self.rsi.update(input)?;
|
||||
let s1 = self.ema1.update(rsi)?;
|
||||
let s2 = self.ema2.update(s1)?;
|
||||
let signal = self.signal.update(s2)?;
|
||||
Some(s2 - signal)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.rsi.reset();
|
||||
self.ema1.reset();
|
||||
self.ema2.reset();
|
||||
self.signal.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.warmup
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.signal.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DerivativeOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_periods() {
|
||||
assert!(matches!(
|
||||
DerivativeOscillator::new(0, 5, 3, 9),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
DerivativeOscillator::new(14, 0, 3, 9),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
DerivativeOscillator::new(14, 5, 0, 9),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
DerivativeOscillator::new(14, 5, 3, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
/// Cover the const accessor `warmup` and the Indicator-impl `warmup_period`
|
||||
/// + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
|
||||
// 14 + 5 + 3 + 9 - 2 = 29.
|
||||
assert_eq!(d.warmup(), 29);
|
||||
assert_eq!(d.warmup_period(), 29);
|
||||
assert_eq!(d.name(), "DerivativeOscillator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let prices: Vec<f64> = (0..60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0)
|
||||
.collect();
|
||||
let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
|
||||
let out = d.batch(&prices);
|
||||
let warmup = d.warmup_period();
|
||||
for (i, v) in out.iter().enumerate().take(warmup - 1) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(
|
||||
out[warmup - 1].is_some(),
|
||||
"first value must land at warmup_period - 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_manual_chain() {
|
||||
// Equals RSI -> EMA -> EMA, minus the SMA signal of that line.
|
||||
let prices: Vec<f64> = (0..80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
|
||||
.collect();
|
||||
let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
|
||||
let mut rsi = Rsi::new(14).unwrap();
|
||||
let mut e1 = Ema::new(5).unwrap();
|
||||
let mut e2 = Ema::new(3).unwrap();
|
||||
let mut sig = Sma::new(9).unwrap();
|
||||
for (i, &p) in prices.iter().enumerate() {
|
||||
let got = d.update(p);
|
||||
let want = rsi
|
||||
.update(p)
|
||||
.and_then(|r| e1.update(r))
|
||||
.and_then(|x| e2.update(x))
|
||||
.and_then(|s2| sig.update(s2).map(|s| s2 - s));
|
||||
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
|
||||
if let (Some(a), Some(b)) = (got, want) {
|
||||
assert_relative_eq!(a, b, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
|
||||
d.batch(&(0..60).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>());
|
||||
assert!(d.is_ready());
|
||||
d.reset();
|
||||
assert!(!d.is_ready());
|
||||
assert_eq!(d.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..80)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
|
||||
.collect();
|
||||
let mut a = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
|
||||
let mut b = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Disparity Index.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Disparity Index — the percentage gap between price and its moving average.
|
||||
///
|
||||
/// ```text
|
||||
/// Disparity = 100 * (price - SMA(price, period)) / SMA(price, period)
|
||||
/// ```
|
||||
///
|
||||
/// Originating in Japanese technical analysis (*kairi*), the disparity index
|
||||
/// expresses how far price has stretched from its `period`-bar simple moving
|
||||
/// average, as a percentage of that average. Positive readings mean price is
|
||||
/// above the mean (potentially overbought / strong), negative readings mean it
|
||||
/// is below (potentially oversold / weak); the magnitude measures how
|
||||
/// over-extended the move is.
|
||||
///
|
||||
/// The first output lands once the inner SMA is ready (input `period`). If the
|
||||
/// moving average is exactly zero the gap percentage is undefined and the index
|
||||
/// returns `0.0`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{DisparityIndex, Indicator};
|
||||
///
|
||||
/// let mut indicator = DisparityIndex::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DisparityIndex {
|
||||
period: usize,
|
||||
sma: Sma,
|
||||
}
|
||||
|
||||
impl DisparityIndex {
|
||||
/// Construct a disparity index over `period` inputs.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
period,
|
||||
sma: Sma::new(period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DisparityIndex {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let mean = self.sma.update(input)?;
|
||||
if mean == 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
Some(100.0 * (input - mean) / mean)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.sma.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.sma.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DisparityIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(DisparityIndex::new(0).is_err());
|
||||
}
|
||||
|
||||
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
|
||||
/// + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let di = DisparityIndex::new(14).unwrap();
|
||||
assert_eq!(di.period(), 14);
|
||||
assert_eq!(di.warmup_period(), 14);
|
||||
assert_eq!(di.name(), "DisparityIndex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_then_known_value() {
|
||||
// SMA(3) of [2, 4, 6] = 4; price 6 -> 100 * (6 - 4) / 4 = 50.
|
||||
let mut di = DisparityIndex::new(3).unwrap();
|
||||
assert_eq!(di.update(2.0), None);
|
||||
assert_eq!(di.update(4.0), None);
|
||||
assert_relative_eq!(di.update(6.0).unwrap(), 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_is_zero() {
|
||||
// Price equals its own mean -> zero disparity.
|
||||
let mut di = DisparityIndex::new(5).unwrap();
|
||||
for v in di.batch(&[42.0; 20]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_when_below_mean() {
|
||||
// SMA(3) of [10, 8, 6] = 8; price 6 -> 100 * (6 - 8) / 8 = -25.
|
||||
let mut di = DisparityIndex::new(3).unwrap();
|
||||
let v = di.batch(&[10.0, 8.0, 6.0]);
|
||||
assert_relative_eq!(v[2].unwrap(), -25.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_mean_returns_zero() {
|
||||
// A window summing to zero (mean 0) makes the percentage undefined; the
|
||||
// index returns 0.0 rather than a non-finite value.
|
||||
let mut di = DisparityIndex::new(2).unwrap();
|
||||
assert_eq!(di.update(-3.0), None);
|
||||
// SMA(2) of [-3, 3] = 0 -> guarded to 0.0.
|
||||
assert_relative_eq!(di.update(3.0).unwrap(), 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut di = DisparityIndex::new(5).unwrap();
|
||||
di.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(di.is_ready());
|
||||
di.reset();
|
||||
assert!(!di.is_ready());
|
||||
assert_eq!(di.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=30)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
|
||||
.collect();
|
||||
let mut a = DisparityIndex::new(7).unwrap();
|
||||
let mut b = DisparityIndex::new(7).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
//! Dynamic Momentum Index (Chande's volatility-adaptive RSI).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::indicators::std_dev::StdDev;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
// Chande's definitional constants.
|
||||
const STD_PERIOD: usize = 5; // volatility window
|
||||
const STD_AVG_PERIOD: usize = 10; // smoothing of the volatility
|
||||
const MIN_PERIOD: usize = 5; // fastest RSI lookback
|
||||
const MAX_PERIOD: usize = 30; // slowest RSI lookback
|
||||
|
||||
/// Dynamic Momentum Index — Tushar Chande's RSI whose lookback shrinks in
|
||||
/// volatile markets and lengthens in calm ones.
|
||||
///
|
||||
/// A standard RSI uses a fixed period; the DMI varies it from the recent
|
||||
/// volatility so the oscillator stays responsive when the market is fast and
|
||||
/// smooth when it is quiet:
|
||||
///
|
||||
/// ```text
|
||||
/// vol = StdDev(close, 5)
|
||||
/// vol_avg = SMA(vol, 10)
|
||||
/// Vi = vol / vol_avg (volatility index)
|
||||
/// td = clamp(round(period / Vi), 5, 30) (dynamic lookback)
|
||||
/// avg_gain, avg_loss = simple means of the last `td` price changes
|
||||
/// DMI = 100 * avg_gain / (avg_gain + avg_loss)
|
||||
/// ```
|
||||
///
|
||||
/// High volatility (`Vi > 1`) shortens `td` toward `5` (faster); low volatility
|
||||
/// lengthens it toward `30` (slower). The averages of gains and losses are
|
||||
/// simple means over the last `td` changes (not Wilder-smoothed), recomputed as
|
||||
/// the window length flexes. Output is bounded in `[0, 100]`; a flat market
|
||||
/// returns the neutral `50`.
|
||||
///
|
||||
/// The first value lands after `MAX_PERIOD + 1 = 31` inputs, so the change
|
||||
/// buffer always holds enough history for any dynamic lookback up to `30`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{DynamicMomentumIndex, Indicator};
|
||||
///
|
||||
/// let mut dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = dmi.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DynamicMomentumIndex {
|
||||
period: usize,
|
||||
vol: StdDev,
|
||||
vol_avg: Sma,
|
||||
prev_close: Option<f64>,
|
||||
/// The last `MAX_PERIOD` price changes, oldest at the front.
|
||||
changes: VecDeque<f64>,
|
||||
last_vol_avg: Option<f64>,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl DynamicMomentumIndex {
|
||||
/// Construct a DMI with the given base RSI period (Chande uses 14).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
vol: StdDev::new(STD_PERIOD)?,
|
||||
vol_avg: Sma::new(STD_AVG_PERIOD)?,
|
||||
prev_close: None,
|
||||
changes: VecDeque::with_capacity(MAX_PERIOD),
|
||||
last_vol_avg: None,
|
||||
last_value: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured base period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
|
||||
/// Dynamic lookback for the current volatility, clamped to `[5, 30]`.
|
||||
fn dynamic_period(&self, vol: f64, vol_avg: f64) -> usize {
|
||||
if vol_avg <= 0.0 || vol <= 0.0 {
|
||||
// No measurable volatility -> slowest (calmest) lookback.
|
||||
return MAX_PERIOD;
|
||||
}
|
||||
let vi = vol / vol_avg;
|
||||
let td = (self.period as f64 / vi).round();
|
||||
// td is finite and positive here; clamp into the valid band.
|
||||
(td as usize).clamp(MIN_PERIOD, MAX_PERIOD)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DynamicMomentumIndex {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last_value;
|
||||
}
|
||||
// Track the smoothed volatility on every close.
|
||||
if let Some(v) = self.vol.update(input) {
|
||||
self.last_vol_avg = self.vol_avg.update(v);
|
||||
}
|
||||
|
||||
// Record the price change.
|
||||
if let Some(prev) = self.prev_close {
|
||||
let change = input - prev;
|
||||
if self.changes.len() == MAX_PERIOD {
|
||||
self.changes.pop_front();
|
||||
}
|
||||
self.changes.push_back(change);
|
||||
}
|
||||
self.prev_close = Some(input);
|
||||
|
||||
let vol = self.vol.value()?;
|
||||
let vol_avg = self.last_vol_avg?;
|
||||
if self.changes.len() < MAX_PERIOD {
|
||||
return None;
|
||||
}
|
||||
|
||||
let td = self.dynamic_period(vol, vol_avg);
|
||||
// Average gains and losses over the last `td` changes.
|
||||
let mut sum_gain = 0.0;
|
||||
let mut sum_loss = 0.0;
|
||||
for &c in self.changes.iter().skip(MAX_PERIOD - td) {
|
||||
if c > 0.0 {
|
||||
sum_gain += c;
|
||||
} else if c < 0.0 {
|
||||
sum_loss -= c;
|
||||
}
|
||||
}
|
||||
let denom = sum_gain + sum_loss;
|
||||
let v = if denom == 0.0 {
|
||||
50.0
|
||||
} else {
|
||||
// Ratio first, then scale, so `100 * g / g` cannot round above 100.
|
||||
100.0 * (sum_gain / denom)
|
||||
};
|
||||
self.last_value = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.vol.reset();
|
||||
self.vol_avg.reset();
|
||||
self.prev_close = None;
|
||||
self.changes.clear();
|
||||
self.last_vol_avg = None;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The change buffer (MAX_PERIOD changes => MAX_PERIOD + 1 inputs) is the
|
||||
// binding constraint; the volatility chain (5 + 10 - 1 = 14) is shorter.
|
||||
MAX_PERIOD + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DynamicMomentumIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
DynamicMomentumIndex::new(0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
/// Cover the const accessors `period` + `value` and the Indicator-impl
|
||||
/// `warmup_period` + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
assert_eq!(dmi.period(), 14);
|
||||
assert_eq!(dmi.value(), None);
|
||||
assert_eq!(dmi.warmup_period(), 31);
|
||||
assert_eq!(dmi.name(), "DynamicMomentumIndex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let prices: Vec<f64> = (0..50)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 6.0)
|
||||
.collect();
|
||||
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
let out = dmi.batch(&prices);
|
||||
for (i, v) in out.iter().enumerate().take(30) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[30].is_some(), "first value at warmup_period - 1 = 30");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_is_one_hundred() {
|
||||
// Every change positive -> avg_loss 0 -> 100, regardless of dynamic period.
|
||||
let prices: Vec<f64> = (1..=60).map(f64::from).collect();
|
||||
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
let last = dmi.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_is_neutral() {
|
||||
// Constant prices: no volatility (dynamic period -> max) and no changes
|
||||
// -> neutral 50.
|
||||
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
let last = dmi.batch(&[42.0; 50]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_stays_in_range() {
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0 + (f64::from(i) * 0.07).cos() * 4.0)
|
||||
.collect();
|
||||
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
for v in dmi.batch(&prices).into_iter().flatten() {
|
||||
assert!((0.0..=100.0).contains(&v), "DMI {v} left [0, 100]");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_volatility_shortens_period() {
|
||||
let dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
// Vi = 2 (vol twice its average) -> td = round(14 / 2) = 7.
|
||||
assert_eq!(dmi.dynamic_period(2.0, 1.0), 7);
|
||||
// Vi = 0.5 (calm) -> td = round(14 / 0.5) = 28.
|
||||
assert_eq!(dmi.dynamic_period(0.5, 1.0), 28);
|
||||
// Extreme calm clamps to MAX_PERIOD; extreme volatility clamps to MIN.
|
||||
assert_eq!(dmi.dynamic_period(0.1, 1.0), MAX_PERIOD);
|
||||
assert_eq!(dmi.dynamic_period(100.0, 1.0), MIN_PERIOD);
|
||||
// Zero volatility -> slowest lookback.
|
||||
assert_eq!(dmi.dynamic_period(0.0, 1.0), MAX_PERIOD);
|
||||
assert_eq!(dmi.dynamic_period(1.0, 0.0), MAX_PERIOD);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
let ready = dmi
|
||||
.batch(&(0..40).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(dmi.update(f64::NAN), Some(ready));
|
||||
assert_eq!(dmi.update(f64::INFINITY), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
dmi.batch(&(0..40).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>());
|
||||
assert!(dmi.is_ready());
|
||||
dmi.reset();
|
||||
assert!(!dmi.is_ready());
|
||||
assert_eq!(dmi.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..80)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
|
||||
.collect();
|
||||
let mut a = DynamicMomentumIndex::new(14).unwrap();
|
||||
let mut b = DynamicMomentumIndex::new(14).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Elder Ray — Bull Power and Bear Power.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// One Elder Ray reading: the bull and bear power for a bar.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ElderRayOutput {
|
||||
/// `high − EMA(close)`: how far buyers pushed price above the trend mean.
|
||||
pub bull_power: f64,
|
||||
/// `low − EMA(close)`: how far sellers pushed price below the trend mean
|
||||
/// (negative in a normal market).
|
||||
pub bear_power: f64,
|
||||
}
|
||||
|
||||
/// Elder Ray — Alexander Elder's Bull Power / Bear Power oscillator.
|
||||
///
|
||||
/// An EMA of the close marks the market's consensus of value; the bar's high and
|
||||
/// low relative to it measure how far the bulls and bears could push price away
|
||||
/// from that consensus:
|
||||
///
|
||||
/// ```text
|
||||
/// ema = EMA(close, period)
|
||||
/// BullPower = high - ema
|
||||
/// BearPower = low - ema
|
||||
/// ```
|
||||
///
|
||||
/// Bull Power is normally positive (the high prints above the mean) and Bear
|
||||
/// Power normally negative (the low prints below it). Their behaviour relative
|
||||
/// to zero and to the EMA's slope drives Elder's signals: e.g. in an uptrend
|
||||
/// (rising EMA), a bounce in a negative-but-rising Bear Power is a buy setup.
|
||||
///
|
||||
/// The first reading lands once the inner EMA is seeded, at bar `period`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, ElderRay, Indicator};
|
||||
///
|
||||
/// let mut er = ElderRay::new(13).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1.0, i64::from(i)).unwrap();
|
||||
/// last = er.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ElderRay {
|
||||
period: usize,
|
||||
ema: Ema,
|
||||
}
|
||||
|
||||
impl ElderRay {
|
||||
/// Construct an Elder Ray with the given EMA period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
period,
|
||||
ema: Ema::new(period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ElderRay {
|
||||
type Input = Candle;
|
||||
type Output = ElderRayOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<ElderRayOutput> {
|
||||
let ema = self.ema.update(candle.close)?;
|
||||
Some(ElderRayOutput {
|
||||
bull_power: candle.high - ema,
|
||||
bear_power: candle.low - ema,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ema.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ema.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ElderRay"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new(close, high, low, close, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(ElderRay::new(0).is_err());
|
||||
}
|
||||
|
||||
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
|
||||
/// + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let er = ElderRay::new(13).unwrap();
|
||||
assert_eq!(er.period(), 13);
|
||||
assert_eq!(er.warmup_period(), 13);
|
||||
assert_eq!(er.name(), "ElderRay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_then_known_value() {
|
||||
// EMA(3) seeds at bar 3 with SMA([10,12,14]) = 12 (closes).
|
||||
// bar 3: high 16, low 13 -> bull = 16 - 12 = 4, bear = 13 - 12 = 1.
|
||||
let mut er = ElderRay::new(3).unwrap();
|
||||
assert_eq!(er.update(candle(11.0, 9.0, 10.0)), None);
|
||||
assert_eq!(er.update(candle(13.0, 11.0, 12.0)), None);
|
||||
let v = er.update(candle(16.0, 13.0, 14.0)).unwrap();
|
||||
assert_relative_eq!(v.bull_power, 4.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.bear_power, 1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_manual_ema() {
|
||||
let bars: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
|
||||
candle(base + 2.0, base - 2.0, base)
|
||||
})
|
||||
.collect();
|
||||
let mut er = ElderRay::new(13).unwrap();
|
||||
let mut ema = Ema::new(13).unwrap();
|
||||
for (i, c) in bars.iter().enumerate() {
|
||||
let got = er.update(*c);
|
||||
let want = ema.update(c.close).map(|e| (c.high - e, c.low - e));
|
||||
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
|
||||
if let (Some(g), Some((b, be))) = (got, want) {
|
||||
assert_relative_eq!(g.bull_power, b, epsilon = 1e-9);
|
||||
assert_relative_eq!(g.bear_power, be, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut er = ElderRay::new(5).unwrap();
|
||||
er.batch(
|
||||
&(0..20)
|
||||
.map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(er.is_ready());
|
||||
er.reset();
|
||||
assert!(!er.is_ready());
|
||||
assert_eq!(er.update(candle(2.0, 0.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let bars: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let base = 50.0 + f64::from(i);
|
||||
candle(base + 1.5, base - 1.5, base)
|
||||
})
|
||||
.collect();
|
||||
let mut a = ElderRay::new(7).unwrap();
|
||||
let mut b = ElderRay::new(7).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&bars),
|
||||
bars.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,15 @@ use crate::traits::Indicator;
|
||||
pub struct Ema {
|
||||
period: usize,
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -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<f64> {
|
||||
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<f64> {
|
||||
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::<f64>() / 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<f64> {
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Fisher-transformed RSI.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::rsi::Rsi;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Fisher RSI — the Fisher transform applied to a normalised [`Rsi`](crate::Rsi).
|
||||
///
|
||||
/// The RSI is bounded in `[0, 100]` and its distribution piles up near the
|
||||
/// middle, which blurs turning points. The Fisher transform reshapes a bounded
|
||||
/// input toward a Gaussian, sharpening the extremes into clear, near-symmetric
|
||||
/// peaks:
|
||||
///
|
||||
/// ```text
|
||||
/// rsi = RSI(price, period) in [0, 100]
|
||||
/// x = clamp((rsi - 50) / 50, ±0.999) normalise to (-1, 1)
|
||||
/// Fisher = 0.5 * ln((1 + x) / (1 - x))
|
||||
/// ```
|
||||
///
|
||||
/// The clamp keeps the logarithm finite when the RSI pins at `0` or `100`. The
|
||||
/// output is unbounded but in practice oscillates in roughly `[-3, 3]`, with
|
||||
/// sharp excursions marking momentum extremes. The first value lands with the
|
||||
/// inner RSI, after `period + 1` inputs.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{FisherRsi, Indicator};
|
||||
///
|
||||
/// let mut indicator = FisherRsi::new(9).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FisherRsi {
|
||||
period: usize,
|
||||
rsi: Rsi,
|
||||
}
|
||||
|
||||
impl FisherRsi {
|
||||
/// Construct a Fisher RSI with the given RSI period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
period,
|
||||
rsi: Rsi::new(period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for FisherRsi {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let rsi = self.rsi.update(input)?;
|
||||
let x = ((rsi - 50.0) / 50.0).clamp(-0.999, 0.999);
|
||||
Some(0.5 * ((1.0 + x) / (1.0 - x)).ln())
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.rsi.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.rsi.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.rsi.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FisherRSI"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(FisherRsi::new(0).is_err());
|
||||
}
|
||||
|
||||
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
|
||||
/// + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let f = FisherRsi::new(9).unwrap();
|
||||
assert_eq!(f.period(), 9);
|
||||
// RSI warmup is period + 1.
|
||||
assert_eq!(f.warmup_period(), 10);
|
||||
assert_eq!(f.name(), "FisherRSI");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_matches_rsi() {
|
||||
let mut f = FisherRsi::new(3).unwrap();
|
||||
// RSI(3) needs 4 inputs; the first three return None.
|
||||
assert_eq!(f.update(1.0), None);
|
||||
assert_eq!(f.update(2.0), None);
|
||||
assert_eq!(f.update(3.0), None);
|
||||
assert!(f.update(4.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_fisher_of_rsi() {
|
||||
// Fisher RSI must equal the Fisher transform of the standalone RSI.
|
||||
let prices: Vec<f64> = (0..60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
|
||||
.collect();
|
||||
let mut fr = FisherRsi::new(9).unwrap();
|
||||
let mut rsi = Rsi::new(9).unwrap();
|
||||
for (i, &p) in prices.iter().enumerate() {
|
||||
let got = fr.update(p);
|
||||
let want = rsi.update(p).map(|r| {
|
||||
let x = ((r - 50.0) / 50.0).clamp(-0.999, 0.999);
|
||||
0.5 * ((1.0 + x) / (1.0 - x)).ln()
|
||||
});
|
||||
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
|
||||
if let (Some(a), Some(b)) = (got, want) {
|
||||
assert_relative_eq!(a, b, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strong_uptrend_is_positive() {
|
||||
// A pure uptrend pins RSI near 100 -> x near +1 -> large positive Fisher.
|
||||
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
|
||||
let mut f = FisherRsi::new(9).unwrap();
|
||||
let last = f.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last > 1.0,
|
||||
"strong uptrend should give a large positive value, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_keeps_output_finite_at_extremes() {
|
||||
// Monotonic rise pins RSI at 100; the clamp must keep Fisher finite.
|
||||
let prices: Vec<f64> = (1..=30).map(f64::from).collect();
|
||||
let mut f = FisherRsi::new(5).unwrap();
|
||||
for v in f.batch(&prices).into_iter().flatten() {
|
||||
assert!(v.is_finite(), "Fisher RSI must stay finite, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut f = FisherRsi::new(5).unwrap();
|
||||
f.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(f.is_ready());
|
||||
f.reset();
|
||||
assert!(!f.is_ready());
|
||||
assert_eq!(f.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=40)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
|
||||
.collect();
|
||||
let mut a = FisherRsi::new(9).unwrap();
|
||||
let mut b = FisherRsi::new(9).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Bill Williams' Gator Oscillator (derived from the Alligator).
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::alligator::Alligator;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Gator Oscillator output: the two histogram bars drawn above and below the
|
||||
/// zero line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct GatorOscillatorOutput {
|
||||
/// Upper histogram `|jaw - teeth|`, always `>= 0`.
|
||||
pub upper: f64,
|
||||
/// Lower histogram `-|teeth - lips|`, always `<= 0`.
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
/// Bill Williams' Gator Oscillator: a convergence/divergence view of the
|
||||
/// [`Alligator`] lines. The upper bar is the absolute gap between Jaw and
|
||||
/// Teeth; the lower bar is the negated absolute gap between Teeth and Lips.
|
||||
///
|
||||
/// ```text
|
||||
/// upper = |jaw - teeth|
|
||||
/// lower = -|teeth - lips |
|
||||
/// ```
|
||||
///
|
||||
/// Widening bars mean the Alligator's mouth is opening (a trending market);
|
||||
/// shrinking bars mean it is closing (consolidation). Warmup matches the
|
||||
/// underlying Alligator — the first value appears once the slowest line (Jaw)
|
||||
/// has warmed up.
|
||||
///
|
||||
/// Reference: Bill Williams, *Trading Chaos*, 1995.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, GatorOscillator, Indicator};
|
||||
///
|
||||
/// let mut indicator = GatorOscillator::classic();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 1.0, base - 1.0, base, 1.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GatorOscillator {
|
||||
alligator: Alligator,
|
||||
}
|
||||
|
||||
impl GatorOscillator {
|
||||
/// Construct a Gator Oscillator from explicit Alligator periods
|
||||
/// `(jaw, teeth, lips)`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`](crate::error::Error::PeriodZero) if any period is zero.
|
||||
pub fn new(jaw_period: usize, teeth_period: usize, lips_period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
alligator: Alligator::new(jaw_period, teeth_period, lips_period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Bill Williams' classic parameters: `(jaw = 13, teeth = 8, lips = 5)`.
|
||||
pub fn classic() -> Self {
|
||||
Self {
|
||||
alligator: Alligator::classic(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Configured `(jaw_period, teeth_period, lips_period)`.
|
||||
pub const fn periods(&self) -> (usize, usize, usize) {
|
||||
self.alligator.periods()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for GatorOscillator {
|
||||
type Input = Candle;
|
||||
type Output = GatorOscillatorOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<GatorOscillatorOutput> {
|
||||
let lines = self.alligator.update(candle)?;
|
||||
Some(GatorOscillatorOutput {
|
||||
upper: (lines.jaw - lines.teeth).abs(),
|
||||
lower: -(lines.teeth - lines.lips).abs(),
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.alligator.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.alligator.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.alligator.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"GatorOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::Error;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, ts: i64) -> Candle {
|
||||
let close = f64::midpoint(high, low);
|
||||
Candle::new(close, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
GatorOscillator::new(0, 8, 5),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
GatorOscillator::new(13, 0, 5),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
GatorOscillator::new(13, 8, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let g = GatorOscillator::classic();
|
||||
assert_eq!(g.periods(), (13, 8, 5));
|
||||
assert_eq!(g.warmup_period(), 13);
|
||||
assert_eq!(g.name(), "GatorOscillator");
|
||||
assert!(!g.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_collapses_both_bars() {
|
||||
// All three Alligator lines equal the constant median -> zero spread.
|
||||
let mut g = GatorOscillator::classic();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(11.0, 9.0, i)).collect();
|
||||
let out = g.batch(&candles);
|
||||
let last = out.last().unwrap().unwrap();
|
||||
assert_relative_eq!(last.upper, 0.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.lower, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trending_series_opens_the_mouth() {
|
||||
// On a clean trend the lines separate -> upper > 0, lower < 0.
|
||||
let mut g = GatorOscillator::classic();
|
||||
let candles: Vec<Candle> = (0_i64..80)
|
||||
.map(|i| candle(10.0 + i as f64, 9.0 + i as f64, i))
|
||||
.collect();
|
||||
let last = g.batch(&candles).last().unwrap().unwrap();
|
||||
assert!(last.upper > 0.0, "upper {} should be positive", last.upper);
|
||||
assert!(last.lower < 0.0, "lower {} should be negative", last.lower);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_longest_period() {
|
||||
let mut g = GatorOscillator::new(5, 3, 2).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|i| candle(11.0, 9.0, i)).collect();
|
||||
let out = g.batch(&candles);
|
||||
for v in out.iter().take(4) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut g = GatorOscillator::classic();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(11.0, 9.0, i)).collect();
|
||||
g.batch(&candles);
|
||||
assert!(g.is_ready());
|
||||
g.reset();
|
||||
assert!(!g.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80_i64)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (i as f64 * 0.2).sin() * 5.0;
|
||||
candle(base + 1.0, base - 1.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = GatorOscillator::classic();
|
||||
let mut b = GatorOscillator::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Intraday Momentum Index (IMI).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Intraday Momentum Index — Tushar Chande's RSI built from the open-to-close
|
||||
/// move instead of the close-to-close move.
|
||||
///
|
||||
/// For each bar the body is an up-move when `close > open` and a down-move
|
||||
/// otherwise; the IMI sums those bodies over `period` bars and forms the
|
||||
/// RSI-style ratio:
|
||||
///
|
||||
/// ```text
|
||||
/// gain = max(close - open, 0), loss = max(open - close, 0)
|
||||
/// IMI = 100 * Σ gain / (Σ gain + Σ loss) over the last `period` bars
|
||||
/// ```
|
||||
///
|
||||
/// Because it measures *intraday* (body) momentum rather than the gap-inclusive
|
||||
/// close-to-close change, the IMI is a candle-pattern-flavoured overbought /
|
||||
/// oversold gauge: persistent white bodies push it up, black bodies down. It is
|
||||
/// bounded in `[0, 100]`; a window of doji-like bars (no net bodies) returns the
|
||||
/// neutral `50`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, IntradayMomentumIndex, Indicator};
|
||||
///
|
||||
/// let mut imi = IntradayMomentumIndex::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1.0, i64::from(i)).unwrap();
|
||||
/// last = imi.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IntradayMomentumIndex {
|
||||
period: usize,
|
||||
/// Per-bar `(gain, loss)` bodies, oldest at the front.
|
||||
window: VecDeque<(f64, f64)>,
|
||||
sum_gain: f64,
|
||||
sum_loss: f64,
|
||||
}
|
||||
|
||||
impl IntradayMomentumIndex {
|
||||
/// Construct an IMI over `period` bars.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_gain: 0.0,
|
||||
sum_loss: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if the window is full.
|
||||
pub fn value(&self) -> Option<f64> {
|
||||
if self.window.len() != self.period {
|
||||
return None;
|
||||
}
|
||||
let denom = self.sum_gain + self.sum_loss;
|
||||
if denom == 0.0 {
|
||||
Some(50.0)
|
||||
} else {
|
||||
Some(100.0 * self.sum_gain / denom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for IntradayMomentumIndex {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let body = candle.close - candle.open;
|
||||
let gain = if body > 0.0 { body } else { 0.0 };
|
||||
let loss = if body < 0.0 { -body } else { 0.0 };
|
||||
|
||||
if self.window.len() == self.period {
|
||||
let (old_g, old_l) = self.window.pop_front().expect("window full");
|
||||
self.sum_gain -= old_g;
|
||||
self.sum_loss -= old_l;
|
||||
}
|
||||
self.window.push_back((gain, loss));
|
||||
self.sum_gain += gain;
|
||||
self.sum_loss += loss;
|
||||
self.value()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum_gain = 0.0;
|
||||
self.sum_loss = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"IMI"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(open: f64, close: f64) -> Candle {
|
||||
let hi = open.max(close) + 1.0;
|
||||
let lo = open.min(close) - 1.0;
|
||||
Candle::new(open, hi, lo, close, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
IntradayMomentumIndex::new(0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
|
||||
/// + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let imi = IntradayMomentumIndex::new(14).unwrap();
|
||||
assert_eq!(imi.period(), 14);
|
||||
assert_eq!(imi.warmup_period(), 14);
|
||||
assert_eq!(imi.name(), "IMI");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_up_bodies_is_one_hundred() {
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
let bars = [candle(10.0, 11.0), candle(11.0, 13.0), candle(13.0, 14.0)];
|
||||
let out = imi.batch(&bars);
|
||||
assert!(out[0].is_none());
|
||||
assert!(out[1].is_none());
|
||||
assert_relative_eq!(out[2].unwrap(), 100.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_down_bodies_is_zero() {
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
let bars = [candle(14.0, 13.0), candle(13.0, 11.0), candle(11.0, 10.0)];
|
||||
assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_value_mixed_bodies() {
|
||||
// bodies: +1, -1, +2 -> sum_gain = 3, sum_loss = 1 -> 100*3/4 = 75.
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
let bars = [candle(10.0, 11.0), candle(11.0, 10.0), candle(10.0, 12.0)];
|
||||
assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 75.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doji_window_is_neutral() {
|
||||
// close == open every bar -> no bodies -> neutral 50.
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
let bars = [candle(10.0, 10.0), candle(11.0, 11.0), candle(12.0, 12.0)];
|
||||
assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slides_window() {
|
||||
// After [+1,-1,+2] (75) add +0 body window -> [-1,+2,0]: gain 2, loss 1 -> 66.67.
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
let bars = [
|
||||
candle(10.0, 11.0),
|
||||
candle(11.0, 10.0),
|
||||
candle(10.0, 12.0),
|
||||
candle(12.0, 12.0),
|
||||
];
|
||||
let out = imi.batch(&bars);
|
||||
assert_relative_eq!(out[3].unwrap(), 100.0 * 2.0 / 3.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
imi.batch(&[candle(10.0, 11.0), candle(11.0, 12.0), candle(12.0, 13.0)]);
|
||||
assert!(imi.is_ready());
|
||||
imi.reset();
|
||||
assert!(!imi.is_ready());
|
||||
assert_eq!(imi.update(candle(1.0, 2.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let bars: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
candle(base, base + (f64::from(i) * 0.5).sin())
|
||||
})
|
||||
.collect();
|
||||
let mut a = IntradayMomentumIndex::new(7).unwrap();
|
||||
let mut b = IntradayMomentumIndex::new(7).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&bars),
|
||||
bars.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Kase Permission Stochastic — a double-smoothed stochastic used as a
|
||||
//! trade-permission filter.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Kase Permission Stochastic output: a fast and a slow line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct KasePermissionStochasticOutput {
|
||||
/// Fast line: EMA of the raw `%K` over the smoothing period.
|
||||
pub fast: f64,
|
||||
/// Slow line: EMA of the fast line over the smoothing period.
|
||||
pub slow: f64,
|
||||
}
|
||||
|
||||
/// Cynthia Kase's Permission Stochastic: a stochastic oscillator smoothed twice,
|
||||
/// whose fast/slow relationship grants or denies "permission" to trade in the
|
||||
/// direction of a higher-timeframe signal.
|
||||
///
|
||||
/// ```text
|
||||
/// raw%K = 100 * (close - LL) / (HH - LL) over `length` (50 when HH == LL)
|
||||
/// fast = EMA(raw%K, smooth)
|
||||
/// slow = EMA(fast, smooth)
|
||||
/// ```
|
||||
///
|
||||
/// The raw stochastic is the usual `%K`, then an EMA produces the *fast* line
|
||||
/// and a second EMA of that produces the *slow* line. Kase uses the pair as a
|
||||
/// gate: a fast line above the slow line (and rising) gives permission for
|
||||
/// longs, the reverse for shorts. When the lookback window is perfectly flat
|
||||
/// (`HH == LL`), the raw stochastic is undefined and defaults to the neutral
|
||||
/// `50`.
|
||||
///
|
||||
/// Reference: Cynthia Kase, *Trading with the Odds*, 1996.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, KasePermissionStochastic};
|
||||
///
|
||||
/// let mut indicator = KasePermissionStochastic::new(9, 3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KasePermissionStochastic {
|
||||
length: usize,
|
||||
smooth: usize,
|
||||
window: VecDeque<(f64, f64)>,
|
||||
fast_ema: Ema,
|
||||
slow_ema: Ema,
|
||||
}
|
||||
|
||||
impl KasePermissionStochastic {
|
||||
/// Construct with the stochastic `length` and the EMA `smooth` period
|
||||
/// applied twice.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `length == 0` or `smooth == 0`.
|
||||
pub fn new(length: usize, smooth: usize) -> Result<Self> {
|
||||
if length == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
length,
|
||||
smooth,
|
||||
window: VecDeque::with_capacity(length),
|
||||
fast_ema: Ema::new(smooth)?,
|
||||
slow_ema: Ema::new(smooth)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Cynthia Kase's classic parameters: `length = 9`, `smooth = 3`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(9, 3).expect("classic Kase Permission Stochastic parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured `(length, smooth)`.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.length, self.smooth)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for KasePermissionStochastic {
|
||||
type Input = Candle;
|
||||
type Output = KasePermissionStochasticOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<KasePermissionStochasticOutput> {
|
||||
self.window.push_back((candle.high, candle.low));
|
||||
if self.window.len() > self.length {
|
||||
self.window.pop_front();
|
||||
}
|
||||
if self.window.len() < self.length {
|
||||
return None;
|
||||
}
|
||||
|
||||
let highest = self.window.iter().map(|w| w.0).fold(f64::MIN, f64::max);
|
||||
let lowest = self.window.iter().map(|w| w.1).fold(f64::MAX, f64::min);
|
||||
let raw_k = if highest > lowest {
|
||||
100.0 * (candle.close - lowest) / (highest - lowest)
|
||||
} else {
|
||||
50.0
|
||||
};
|
||||
|
||||
let fast = self.fast_ema.update(raw_k)?;
|
||||
let slow = self.slow_ema.update(fast)?;
|
||||
Some(KasePermissionStochasticOutput { fast, slow })
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.fast_ema.reset();
|
||||
self.slow_ema.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// raw%K ready after `length` bars; each EMA seeds over `smooth` values.
|
||||
self.length + 2 * self.smooth - 2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.slow_ema.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"KasePermissionStochastic"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
KasePermissionStochastic::new(0, 3),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
KasePermissionStochastic::new(9, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let k = KasePermissionStochastic::classic();
|
||||
assert_eq!(k.periods(), (9, 3));
|
||||
// 9 + 2*3 - 2 = 13.
|
||||
assert_eq!(k.warmup_period(), 13);
|
||||
assert_eq!(k.name(), "KasePermissionStochastic");
|
||||
assert!(!k.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_at_expected_bar() {
|
||||
let mut k = KasePermissionStochastic::new(3, 2).unwrap();
|
||||
// warmup = 3 + 2*2 - 2 = 5 -> first value at input 5 (index 4).
|
||||
let candles: Vec<Candle> = (0..8).map(|i| candle(11.0, 9.0, 10.5, i)).collect();
|
||||
let out = k.batch(&candles);
|
||||
assert!(out[3].is_none());
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_of_range_is_high() {
|
||||
// Close pinned at the top of a rising range -> raw%K near 100, both
|
||||
// smoothed lines high.
|
||||
let mut k = KasePermissionStochastic::new(5, 3).unwrap();
|
||||
let candles: Vec<Candle> = (0_i64..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
candle(base + 2.0, base - 2.0, base + 2.0, i)
|
||||
})
|
||||
.collect();
|
||||
let last = k.batch(&candles).last().unwrap().unwrap();
|
||||
assert!(last.fast > 80.0, "fast {} should be high", last.fast);
|
||||
assert!(last.slow > 80.0, "slow {} should be high", last.slow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_defaults_to_neutral() {
|
||||
// Constant high/low/close -> HH == LL -> raw%K defaults to 50, so both
|
||||
// EMAs converge to 50.
|
||||
let mut k = KasePermissionStochastic::new(4, 2).unwrap();
|
||||
let candles: Vec<Candle> = (0..20).map(|i| candle(10.0, 10.0, 10.0, i)).collect();
|
||||
let last = k.batch(&candles).last().unwrap().unwrap();
|
||||
assert_relative_eq!(last.fast, 50.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.slow, 50.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut k = KasePermissionStochastic::classic();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(11.0, 9.0, 10.5, i)).collect();
|
||||
k.batch(&candles);
|
||||
assert!(k.is_ready());
|
||||
k.reset();
|
||||
assert!(!k.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80_i64)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (i as f64 * 0.2).sin() * 5.0;
|
||||
candle(base + 2.0, base - 2.0, base + (i as f64 * 0.3).cos(), i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = KasePermissionStochastic::classic();
|
||||
let mut b = KasePermissionStochastic::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! MACD Histogram (standalone).
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::macd::MacdIndicator;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// MACD Histogram — the `macd − signal` bar of [`MacdIndicator`] as a
|
||||
/// standalone scalar indicator.
|
||||
///
|
||||
/// ```text
|
||||
/// macd = EMA(fast) − EMA(slow)
|
||||
/// signal = EMA(macd, signal)
|
||||
/// histogram = macd − signal
|
||||
/// ```
|
||||
///
|
||||
/// The histogram is the most actively traded part of MACD: it crosses zero
|
||||
/// exactly when the MACD line crosses its signal, and its slope measures
|
||||
/// whether that momentum is accelerating or fading. This wrapper exposes just
|
||||
/// that series for pipelines that want a plain `f64` stream rather than the
|
||||
/// full [`MacdOutput`](crate::MacdOutput); for the line and signal alongside
|
||||
/// it, use [`MacdIndicator`](crate::MacdIndicator) directly.
|
||||
///
|
||||
/// Standard parameters are `fast = 12`, `slow = 26`, `signal = 9`, so the
|
||||
/// first value lands after `slow + signal − 1` inputs — exactly when
|
||||
/// [`MacdIndicator`] emits its first full output.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, MacdHistogram};
|
||||
///
|
||||
/// let mut indicator = MacdHistogram::new(12, 26, 9).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MacdHistogram {
|
||||
macd: MacdIndicator,
|
||||
}
|
||||
|
||||
impl MacdHistogram {
|
||||
/// Construct a MACD histogram with the given periods.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if any period is zero, and
|
||||
/// [`Error::InvalidPeriod`] if `fast >= slow`.
|
||||
pub fn new(fast: usize, slow: usize, signal: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
macd: MacdIndicator::new(fast, slow, signal)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Default `(12, 26, 9)` configuration, matching every classical chart package.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(12, 26, 9).expect("classic MACD periods are valid")
|
||||
}
|
||||
|
||||
/// Configured periods as `(fast, slow, signal)`.
|
||||
pub const fn periods(&self) -> (usize, usize, usize) {
|
||||
self.macd.periods()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for MacdHistogram {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
self.macd.update(input).map(|out| out.histogram)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.macd.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.macd.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.macd.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"MacdHistogram"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::Error;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_periods() {
|
||||
assert!(matches!(
|
||||
MacdHistogram::new(0, 26, 9),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
MacdHistogram::new(12, 26, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
MacdHistogram::new(26, 12, 9),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let osc = MacdHistogram::classic();
|
||||
assert_eq!(osc.periods(), (12, 26, 9));
|
||||
assert_eq!(osc.name(), "MacdHistogram");
|
||||
assert_eq!(osc.warmup_period(), 26 + 9 - 1);
|
||||
assert!(!osc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equals_macd_histogram_field() {
|
||||
// The standalone series must be exactly MacdIndicator's histogram bar.
|
||||
let prices: Vec<f64> = (1..=120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 8.0)
|
||||
.collect();
|
||||
let hist = MacdHistogram::classic().batch(&prices);
|
||||
let full = MacdIndicator::classic().batch(&prices);
|
||||
assert_eq!(hist.len(), full.len());
|
||||
for (h, m) in hist.iter().zip(full.iter()) {
|
||||
assert_eq!(h.is_some(), m.is_some());
|
||||
if let (Some(h), Some(m)) = (h, m) {
|
||||
assert_relative_eq!(*h, m.histogram, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_warmup_period() {
|
||||
let mut osc = MacdHistogram::new(3, 6, 3).unwrap();
|
||||
let warmup = osc.warmup_period();
|
||||
assert_eq!(warmup, 6 + 3 - 1);
|
||||
for i in 1..warmup {
|
||||
assert!(osc.update(100.0 + i as f64).is_none());
|
||||
}
|
||||
assert!(osc.update(100.0 + warmup as f64).is_some());
|
||||
assert!(osc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_converges_to_zero() {
|
||||
let mut osc = MacdHistogram::classic();
|
||||
let out = osc.batch(&[100.0_f64; 200]);
|
||||
let last = out.iter().rev().flatten().next().expect("emits a value");
|
||||
assert_relative_eq!(*last, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=100)
|
||||
.map(|i| (f64::from(i) * 0.4).cos() * 10.0)
|
||||
.collect();
|
||||
let mut a = MacdHistogram::classic();
|
||||
let mut b = MacdHistogram::classic();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut osc = MacdHistogram::classic();
|
||||
osc.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(osc.is_ready());
|
||||
osc.reset();
|
||||
assert!(!osc.is_ready());
|
||||
assert_eq!(osc.update(1.0), None);
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,9 @@ mod dema;
|
||||
mod demand_index;
|
||||
mod demark_pivots;
|
||||
mod depth_slope;
|
||||
mod derivative_oscillator;
|
||||
mod detrended_std_dev;
|
||||
mod disparity_index;
|
||||
mod distance_ssd;
|
||||
mod doji;
|
||||
mod doji_star;
|
||||
@@ -104,11 +106,13 @@ mod dpo;
|
||||
mod dragonfly_doji;
|
||||
mod drawdown_duration;
|
||||
mod dx;
|
||||
mod dynamic_momentum_index;
|
||||
mod ease_of_movement;
|
||||
mod effective_spread;
|
||||
mod ehlers_stochastic;
|
||||
mod ehma;
|
||||
mod elder_impulse;
|
||||
mod elder_ray;
|
||||
mod ema;
|
||||
mod empirical_mode_decomposition;
|
||||
mod engulfing;
|
||||
@@ -126,6 +130,7 @@ mod fib_projection;
|
||||
mod fib_retracement;
|
||||
mod fib_time_zones;
|
||||
mod fibonacci_pivots;
|
||||
mod fisher_rsi;
|
||||
mod fisher_transform;
|
||||
mod flag_pennant;
|
||||
mod footprint;
|
||||
@@ -140,6 +145,7 @@ mod gain_loss_ratio;
|
||||
mod gap_side_by_side_white;
|
||||
mod garman_klass;
|
||||
mod gartley;
|
||||
mod gator_oscillator;
|
||||
mod generalized_dema;
|
||||
mod geometric_ma;
|
||||
mod golden_pocket;
|
||||
@@ -173,6 +179,7 @@ mod inertia;
|
||||
mod information_ratio;
|
||||
mod initial_balance;
|
||||
mod instantaneous_trendline;
|
||||
mod intraday_momentum_index;
|
||||
mod intraday_volatility_profile;
|
||||
mod inverse_fisher_transform;
|
||||
mod inverted_hammer;
|
||||
@@ -181,6 +188,7 @@ mod jump_indicator;
|
||||
mod kagi_bars;
|
||||
mod kalman_hedge_ratio;
|
||||
mod kama;
|
||||
mod kase_permission_stochastic;
|
||||
mod kelly_criterion;
|
||||
mod keltner;
|
||||
mod kicking;
|
||||
@@ -206,6 +214,7 @@ mod ma_envelope;
|
||||
mod macd;
|
||||
mod macd_ext;
|
||||
mod macd_fix;
|
||||
mod macd_histogram;
|
||||
mod mama;
|
||||
mod market_facilitation_index;
|
||||
mod marubozu;
|
||||
@@ -260,10 +269,14 @@ mod plus_di;
|
||||
mod plus_dm;
|
||||
mod pmo;
|
||||
mod point_and_figure_bars;
|
||||
mod polarized_fractal_efficiency;
|
||||
mod ppo;
|
||||
mod ppo_histogram;
|
||||
mod profit_factor;
|
||||
mod psar;
|
||||
mod pvi;
|
||||
mod qqe;
|
||||
mod qstick;
|
||||
mod quoted_spread;
|
||||
mod r_squared;
|
||||
mod realized_spread;
|
||||
@@ -276,6 +289,7 @@ mod renko_bars;
|
||||
mod renko_trailing_stop;
|
||||
mod rickshaw_man;
|
||||
mod rising_three_methods;
|
||||
mod rmi;
|
||||
mod roc;
|
||||
mod rocp;
|
||||
mod rocr;
|
||||
@@ -289,6 +303,7 @@ mod rolling_percentile_rank;
|
||||
mod rolling_quantile;
|
||||
mod roofing_filter;
|
||||
mod rsi;
|
||||
mod rsx;
|
||||
mod rvi;
|
||||
mod rvi_volatility;
|
||||
mod rwi;
|
||||
@@ -325,6 +340,7 @@ mod step_trailing_stop;
|
||||
mod stick_sandwich;
|
||||
mod stoch_rsi;
|
||||
mod stochastic;
|
||||
mod stochastic_cci;
|
||||
mod super_smoother;
|
||||
mod super_trend;
|
||||
mod t3;
|
||||
@@ -358,6 +374,7 @@ mod time_of_day_return_profile;
|
||||
mod tpo_profile;
|
||||
mod trade_imbalance;
|
||||
mod trend_label;
|
||||
mod trend_strength_index;
|
||||
mod treynor_ratio;
|
||||
mod triangle;
|
||||
mod trima;
|
||||
@@ -366,9 +383,11 @@ mod triple_top_bottom;
|
||||
mod trix;
|
||||
mod true_range;
|
||||
mod tsf;
|
||||
mod tsf_oscillator;
|
||||
mod tsi;
|
||||
mod tsv;
|
||||
mod ttm_squeeze;
|
||||
mod ttm_trend;
|
||||
mod turn_of_month;
|
||||
mod tweezer;
|
||||
mod two_crows;
|
||||
@@ -396,6 +415,7 @@ mod vwap;
|
||||
mod vwap_stddev_bands;
|
||||
mod vwma;
|
||||
mod vzo;
|
||||
mod wave_pm;
|
||||
mod wave_trend;
|
||||
mod wedge;
|
||||
mod weighted_close;
|
||||
@@ -494,7 +514,9 @@ pub use dema::Dema;
|
||||
pub use demand_index::DemandIndex;
|
||||
pub use demark_pivots::{DemarkPivots, DemarkPivotsOutput};
|
||||
pub use depth_slope::DepthSlope;
|
||||
pub use derivative_oscillator::DerivativeOscillator;
|
||||
pub use detrended_std_dev::DetrendedStdDev;
|
||||
pub use disparity_index::DisparityIndex;
|
||||
pub use distance_ssd::DistanceSsd;
|
||||
pub use doji::Doji;
|
||||
pub use doji_star::DojiStar;
|
||||
@@ -507,11 +529,13 @@ pub use dpo::Dpo;
|
||||
pub use dragonfly_doji::DragonflyDoji;
|
||||
pub use drawdown_duration::DrawdownDuration;
|
||||
pub use dx::Dx;
|
||||
pub use dynamic_momentum_index::DynamicMomentumIndex;
|
||||
pub use ease_of_movement::EaseOfMovement;
|
||||
pub use effective_spread::EffectiveSpread;
|
||||
pub use ehlers_stochastic::EhlersStochastic;
|
||||
pub use ehma::Ehma;
|
||||
pub use elder_impulse::ElderImpulse;
|
||||
pub use elder_ray::{ElderRay, ElderRayOutput};
|
||||
pub use ema::Ema;
|
||||
pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
|
||||
pub use engulfing::Engulfing;
|
||||
@@ -529,6 +553,7 @@ pub use fib_projection::{FibProjection, FibProjectionOutput};
|
||||
pub use fib_retracement::{FibRetracement, FibRetracementOutput};
|
||||
pub use fib_time_zones::{FibTimeZones, FibTimeZonesOutput};
|
||||
pub use fibonacci_pivots::{FibonacciPivots, FibonacciPivotsOutput};
|
||||
pub use fisher_rsi::FisherRsi;
|
||||
pub use fisher_transform::FisherTransform;
|
||||
pub use flag_pennant::FlagPennant;
|
||||
pub use footprint::{Footprint, FootprintLevel, FootprintOutput};
|
||||
@@ -543,6 +568,7 @@ pub use gain_loss_ratio::GainLossRatio;
|
||||
pub use gap_side_by_side_white::GapSideBySideWhite;
|
||||
pub use garman_klass::GarmanKlassVolatility;
|
||||
pub use gartley::Gartley;
|
||||
pub use gator_oscillator::{GatorOscillator, GatorOscillatorOutput};
|
||||
pub use generalized_dema::GeneralizedDema;
|
||||
pub use geometric_ma::GeometricMa;
|
||||
pub use golden_pocket::{GoldenPocket, GoldenPocketOutput};
|
||||
@@ -576,6 +602,7 @@ pub use inertia::Inertia;
|
||||
pub use information_ratio::InformationRatio;
|
||||
pub use initial_balance::{InitialBalance, InitialBalanceOutput};
|
||||
pub use instantaneous_trendline::InstantaneousTrendline;
|
||||
pub use intraday_momentum_index::IntradayMomentumIndex;
|
||||
pub use intraday_volatility_profile::{IntradayVolatilityProfile, IntradayVolatilityProfileOutput};
|
||||
pub use inverse_fisher_transform::InverseFisherTransform;
|
||||
pub use inverted_hammer::InvertedHammer;
|
||||
@@ -584,6 +611,7 @@ pub use jump_indicator::JumpIndicator;
|
||||
pub use kagi_bars::{KagiBar, KagiBars};
|
||||
pub use kalman_hedge_ratio::{KalmanHedgeRatio, KalmanHedgeRatioOutput};
|
||||
pub use kama::Kama;
|
||||
pub use kase_permission_stochastic::{KasePermissionStochastic, KasePermissionStochasticOutput};
|
||||
pub use kelly_criterion::KellyCriterion;
|
||||
pub use keltner::{Keltner, KeltnerOutput};
|
||||
pub use kicking::Kicking;
|
||||
@@ -609,6 +637,7 @@ pub use ma_envelope::{MaEnvelope, MaEnvelopeOutput};
|
||||
pub use macd::{MacdIndicator, MacdOutput};
|
||||
pub use macd_ext::{MaType, MacdExt};
|
||||
pub use macd_fix::MacdFix;
|
||||
pub use macd_histogram::MacdHistogram;
|
||||
pub use mama::{Mama, MamaOutput};
|
||||
pub use market_facilitation_index::MarketFacilitationIndex;
|
||||
pub use marubozu::Marubozu;
|
||||
@@ -663,10 +692,14 @@ pub use plus_di::PlusDi;
|
||||
pub use plus_dm::PlusDm;
|
||||
pub use pmo::Pmo;
|
||||
pub use point_and_figure_bars::{PnfColumn, PointAndFigureBars};
|
||||
pub use polarized_fractal_efficiency::PolarizedFractalEfficiency;
|
||||
pub use ppo::Ppo;
|
||||
pub use ppo_histogram::PpoHistogram;
|
||||
pub use profit_factor::ProfitFactor;
|
||||
pub use psar::Psar;
|
||||
pub use pvi::Pvi;
|
||||
pub use qqe::{Qqe, QqeOutput};
|
||||
pub use qstick::Qstick;
|
||||
pub use quoted_spread::QuotedSpread;
|
||||
pub use r_squared::RSquared;
|
||||
pub use realized_spread::RealizedSpread;
|
||||
@@ -679,6 +712,7 @@ pub use renko_bars::{RenkoBars, RenkoBrick};
|
||||
pub use renko_trailing_stop::RenkoTrailingStop;
|
||||
pub use rickshaw_man::RickshawMan;
|
||||
pub use rising_three_methods::RisingThreeMethods;
|
||||
pub use rmi::Rmi;
|
||||
pub use roc::Roc;
|
||||
pub use rocp::Rocp;
|
||||
pub use rocr::Rocr;
|
||||
@@ -692,6 +726,7 @@ pub use rolling_percentile_rank::RollingPercentileRank;
|
||||
pub use rolling_quantile::RollingQuantile;
|
||||
pub use roofing_filter::RoofingFilter;
|
||||
pub use rsi::Rsi;
|
||||
pub use rsx::Rsx;
|
||||
pub use rvi::Rvi;
|
||||
pub use rvi_volatility::RviVolatility;
|
||||
pub use rwi::{Rwi, RwiOutput};
|
||||
@@ -728,6 +763,7 @@ pub use step_trailing_stop::StepTrailingStop;
|
||||
pub use stick_sandwich::StickSandwich;
|
||||
pub use stoch_rsi::StochRsi;
|
||||
pub use stochastic::{Stochastic, StochasticOutput};
|
||||
pub use stochastic_cci::StochasticCci;
|
||||
pub use super_smoother::SuperSmoother;
|
||||
pub use super_trend::{SuperTrend, SuperTrendOutput};
|
||||
pub use t3::T3;
|
||||
@@ -761,6 +797,7 @@ pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProf
|
||||
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
|
||||
pub use trade_imbalance::TradeImbalance;
|
||||
pub use trend_label::TrendLabel;
|
||||
pub use trend_strength_index::TrendStrengthIndex;
|
||||
pub use treynor_ratio::TreynorRatio;
|
||||
pub use triangle::Triangle;
|
||||
pub use trima::Trima;
|
||||
@@ -769,9 +806,11 @@ pub use triple_top_bottom::TripleTopBottom;
|
||||
pub use trix::Trix;
|
||||
pub use true_range::TrueRange;
|
||||
pub use tsf::Tsf;
|
||||
pub use tsf_oscillator::TsfOscillator;
|
||||
pub use tsi::Tsi;
|
||||
pub use tsv::Tsv;
|
||||
pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput};
|
||||
pub use ttm_trend::TtmTrend;
|
||||
pub use turn_of_month::TurnOfMonth;
|
||||
pub use tweezer::Tweezer;
|
||||
pub use two_crows::TwoCrows;
|
||||
@@ -799,6 +838,7 @@ pub use vwap::{RollingVwap, Vwap};
|
||||
pub use vwap_stddev_bands::{VwapStdDevBands, VwapStdDevBandsOutput};
|
||||
pub use vwma::Vwma;
|
||||
pub use vzo::Vzo;
|
||||
pub use wave_pm::WavePm;
|
||||
pub use wave_trend::{WaveTrend, WaveTrendOutput};
|
||||
pub use wedge::Wedge;
|
||||
pub use weighted_close::WeightedClose;
|
||||
@@ -880,6 +920,16 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"Rocp",
|
||||
"Rocr",
|
||||
"Rocr100",
|
||||
"DisparityIndex",
|
||||
"FisherRsi",
|
||||
"Rsx",
|
||||
"DynamicMomentumIndex",
|
||||
"StochasticCci",
|
||||
"Rmi",
|
||||
"DerivativeOscillator",
|
||||
"ElderRay",
|
||||
"IntradayMomentumIndex",
|
||||
"Qqe",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -906,6 +956,13 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"MinusDi",
|
||||
"Dx",
|
||||
"TrendLabel",
|
||||
"TtmTrend",
|
||||
"TrendStrengthIndex",
|
||||
"Qstick",
|
||||
"PolarizedFractalEfficiency",
|
||||
"WavePm",
|
||||
"GatorOscillator",
|
||||
"KasePermissionStochastic",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -922,6 +979,9 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"ZeroLagMacd",
|
||||
"ElderImpulse",
|
||||
"Stc",
|
||||
"TsfOscillator",
|
||||
"MacdHistogram",
|
||||
"PpoHistogram",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1363,6 +1423,6 @@ mod family_tests {
|
||||
// the actual indicator count is the early-warning signal that an
|
||||
// indicator was added without being assigned a family.
|
||||
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
|
||||
assert_eq!(total, 403, "FAMILIES total drifted from indicator count");
|
||||
assert_eq!(total, 423, "FAMILIES total drifted from indicator count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Polarized Fractal Efficiency (PFE).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Polarized Fractal Efficiency: how efficiently price travelled over the last
|
||||
/// `period` bars, signed by direction and smoothed by an EMA.
|
||||
///
|
||||
/// ```text
|
||||
/// straight = sqrt((C_t - C_{t-n})^2 + n^2) (direct distance over n bars)
|
||||
/// path = Σ_{i=1..n} sqrt((C_{t-i+1} - C_{t-i})^2 + 1) (sum of single-bar steps)
|
||||
/// raw = 100 * sign(C_t - C_{t-n}) * straight / path
|
||||
/// PFE = EMA(raw, smoothing)
|
||||
/// ```
|
||||
///
|
||||
/// The ratio `straight / path` is the fractal efficiency: it is `1` when price
|
||||
/// moved in a perfectly straight line and falls toward `0` as the path becomes
|
||||
/// jagged. Polarizing it by the sign of the net move pushes the reading to
|
||||
/// `+100` for an efficient up-move and `-100` for an efficient down-move, with
|
||||
/// choppy markets oscillating near zero. Because each single-bar step and the
|
||||
/// `n`-bar diagonal both carry the bar count on the x-axis (`+1` and `+n^2`),
|
||||
/// the path length is always `>= n`, so the denominator can never be zero.
|
||||
///
|
||||
/// Reference: Hans Hannula, *Stocks & Commodities*, 1994.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, PolarizedFractalEfficiency};
|
||||
///
|
||||
/// let mut indicator = PolarizedFractalEfficiency::new(10, 5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PolarizedFractalEfficiency {
|
||||
period: usize,
|
||||
smoothing: usize,
|
||||
closes: VecDeque<f64>,
|
||||
prev_close: Option<f64>,
|
||||
segments: VecDeque<f64>,
|
||||
segment_sum: f64,
|
||||
ema: Ema,
|
||||
}
|
||||
|
||||
impl PolarizedFractalEfficiency {
|
||||
/// Construct a PFE with the fractal lookback `period` and the EMA
|
||||
/// `smoothing` period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` or `smoothing == 0`.
|
||||
pub fn new(period: usize, smoothing: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
smoothing,
|
||||
closes: VecDeque::with_capacity(period + 1),
|
||||
prev_close: None,
|
||||
segments: VecDeque::with_capacity(period),
|
||||
segment_sum: 0.0,
|
||||
ema: Ema::new(smoothing)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, smoothing)`.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.period, self.smoothing)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for PolarizedFractalEfficiency {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, close: f64) -> Option<f64> {
|
||||
if let Some(prev) = self.prev_close {
|
||||
let diff = close - prev;
|
||||
let segment = diff.mul_add(diff, 1.0).sqrt();
|
||||
self.segment_sum += segment;
|
||||
self.segments.push_back(segment);
|
||||
if self.segments.len() > self.period {
|
||||
self.segment_sum -= self.segments.pop_front().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
self.prev_close = Some(close);
|
||||
|
||||
self.closes.push_back(close);
|
||||
if self.closes.len() > self.period + 1 {
|
||||
self.closes.pop_front();
|
||||
}
|
||||
if self.closes.len() <= self.period {
|
||||
return None;
|
||||
}
|
||||
|
||||
let oldest = *self.closes.front().unwrap_or(&close);
|
||||
let net = close - oldest;
|
||||
let direction = if net > 0.0 {
|
||||
1.0
|
||||
} else if net < 0.0 {
|
||||
-1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let span = self.period as f64;
|
||||
let straight = net.mul_add(net, span * span).sqrt();
|
||||
let raw = 100.0 * direction * straight / self.segment_sum;
|
||||
self.ema.update(raw)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.closes.clear();
|
||||
self.prev_close = None;
|
||||
self.segments.clear();
|
||||
self.segment_sum = 0.0;
|
||||
self.ema.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + self.smoothing
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ema.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"PolarizedFractalEfficiency"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
PolarizedFractalEfficiency::new(0, 5),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
PolarizedFractalEfficiency::new(10, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let pfe = PolarizedFractalEfficiency::new(10, 5).unwrap();
|
||||
assert_eq!(pfe.periods(), (10, 5));
|
||||
assert_eq!(pfe.warmup_period(), 15);
|
||||
assert_eq!(pfe.name(), "PolarizedFractalEfficiency");
|
||||
assert!(!pfe.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_after_period_plus_smoothing() {
|
||||
let mut pfe = PolarizedFractalEfficiency::new(4, 2).unwrap();
|
||||
// raw needs period+1 = 5 closes; EMA(2) needs 2 raws -> first value at
|
||||
// input 6 (index 5).
|
||||
let inputs: Vec<f64> = (0..10).map(f64::from).collect();
|
||||
let out = pfe.batch(&inputs);
|
||||
assert!(out[4].is_none());
|
||||
assert!(out[5].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_uptrend_is_strongly_positive() {
|
||||
// A straight ramp: every step is +1, the diagonal is maximally
|
||||
// efficient, so PFE saturates near +100.
|
||||
let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
|
||||
let inputs: Vec<f64> = (0..30).map(f64::from).collect();
|
||||
let last = pfe.batch(&inputs).last().unwrap().unwrap();
|
||||
assert!(last > 99.0, "pfe {last} should be near +100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_downtrend_is_strongly_negative() {
|
||||
let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
|
||||
let inputs: Vec<f64> = (0..30).map(|i| -f64::from(i)).collect();
|
||||
let last = pfe.batch(&inputs).last().unwrap().unwrap();
|
||||
assert!(last < -99.0, "pfe {last} should be near -100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_returns_zero() {
|
||||
// No net move over the window -> direction 0 -> raw 0 -> PFE 0.
|
||||
let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
|
||||
let inputs = [10.0; 20];
|
||||
let last = pfe.batch(&inputs).last().unwrap().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choppy_market_is_inefficient() {
|
||||
// A sawtooth whip: the net move is tiny relative to the jagged path, so
|
||||
// efficiency stays well below the +-100 saturation of a clean trend.
|
||||
let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
|
||||
let inputs: Vec<f64> = (0..40)
|
||||
.map(|i| if i % 2 == 0 { 100.0 } else { 102.0 })
|
||||
.collect();
|
||||
let last = pfe.batch(&inputs).last().unwrap().unwrap();
|
||||
assert!(
|
||||
last.abs() < 60.0,
|
||||
"choppy pfe {last} should be far from +-100"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
|
||||
let inputs: Vec<f64> = (0..30).map(f64::from).collect();
|
||||
pfe.batch(&inputs);
|
||||
assert!(pfe.is_ready());
|
||||
pfe.reset();
|
||||
assert!(!pfe.is_ready());
|
||||
assert_eq!(pfe.periods(), (5, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let inputs: Vec<f64> = (0..80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = PolarizedFractalEfficiency::new(10, 5).unwrap();
|
||||
let mut b = PolarizedFractalEfficiency::new(10, 5).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&inputs),
|
||||
inputs.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Percentage Price Oscillator Histogram.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::indicators::ppo::Ppo;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// PPO Histogram — the `ppo − signal` bar of the Percentage Price Oscillator.
|
||||
///
|
||||
/// ```text
|
||||
/// ppo = 100 · (EMA_fast − EMA_slow) / EMA_slow
|
||||
/// signal = EMA(ppo, signal_period)
|
||||
/// histogram = ppo − signal
|
||||
/// ```
|
||||
///
|
||||
/// [`Ppo`](crate::Ppo) itself only emits the percentage line; this indicator
|
||||
/// adds the classic 9-period signal EMA on top and reports the resulting
|
||||
/// zero-centered histogram. Because PPO is scale-free (the EMA gap is divided
|
||||
/// by the slow EMA), the histogram is **comparable across instruments** — a
|
||||
/// PPO histogram of `0.4` means the same relative momentum on any asset, unlike
|
||||
/// the price-unit [`MacdHistogram`](crate::MacdHistogram).
|
||||
///
|
||||
/// With Appel's defaults `fast = 12`, `slow = 26`, `signal = 9`, the first
|
||||
/// value lands after `slow + signal − 1` inputs — the point at which the slow
|
||||
/// EMA and then the signal EMA are both seeded.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, PpoHistogram};
|
||||
///
|
||||
/// let mut indicator = PpoHistogram::new(12, 26, 9).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PpoHistogram {
|
||||
ppo: Ppo,
|
||||
signal_ema: Ema,
|
||||
signal_period: usize,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl PpoHistogram {
|
||||
/// Construct a PPO histogram with the `fast`/`slow` EMA periods and the
|
||||
/// `signal` EMA period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if any period is `0`, or
|
||||
/// [`Error::InvalidPeriod`] if `fast >= slow`.
|
||||
pub fn new(fast: usize, slow: usize, signal: usize) -> Result<Self> {
|
||||
if signal == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
ppo: Ppo::new(fast, slow)?,
|
||||
signal_ema: Ema::new(signal)?,
|
||||
signal_period: signal,
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Default `(12, 26, 9)` configuration.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(12, 26, 9).expect("classic PPO periods are valid")
|
||||
}
|
||||
|
||||
/// Configured periods as `(fast, slow, signal)`.
|
||||
pub const fn periods(&self) -> (usize, usize, usize) {
|
||||
let (fast, slow) = self.ppo.periods();
|
||||
(fast, slow, self.signal_period)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.current
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for PpoHistogram {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
// Guard before touching either stage so a non-finite input never
|
||||
// advances the signal EMA on a stale, re-fed PPO value.
|
||||
if !input.is_finite() {
|
||||
return self.current;
|
||||
}
|
||||
let ppo = self.ppo.update(input)?;
|
||||
let signal = self.signal_ema.update(ppo)?;
|
||||
let histogram = ppo - signal;
|
||||
self.current = Some(histogram);
|
||||
Some(histogram)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ppo.reset();
|
||||
self.signal_ema.reset();
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// Slow EMA seeds the PPO, then the signal EMA needs `signal − 1` more.
|
||||
self.ppo.warmup_period() + self.signal_period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"PpoHistogram"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_periods() {
|
||||
assert!(matches!(
|
||||
PpoHistogram::new(0, 26, 9),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
PpoHistogram::new(12, 0, 9),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
PpoHistogram::new(12, 26, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
PpoHistogram::new(26, 12, 9),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let osc = PpoHistogram::classic();
|
||||
assert_eq!(osc.periods(), (12, 26, 9));
|
||||
assert_eq!(osc.name(), "PpoHistogram");
|
||||
assert_eq!(osc.warmup_period(), 26 + 9 - 1);
|
||||
assert_eq!(osc.value(), None);
|
||||
assert!(!osc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equals_ppo_minus_signal_ema() {
|
||||
// The histogram must equal PPO minus an EMA(signal) composed by hand.
|
||||
let prices: Vec<f64> = (1..=120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 6.0)
|
||||
.collect();
|
||||
let got = PpoHistogram::new(12, 26, 9).unwrap().batch(&prices);
|
||||
|
||||
let mut ppo = Ppo::new(12, 26).unwrap();
|
||||
let mut sig = Ema::new(9).unwrap();
|
||||
let mut expected = Vec::with_capacity(prices.len());
|
||||
for p in &prices {
|
||||
let out = ppo
|
||||
.update(*p)
|
||||
.and_then(|line| sig.update(line).map(|signal| line - signal));
|
||||
expected.push(out);
|
||||
}
|
||||
assert_eq!(got, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_warmup_period() {
|
||||
let mut osc = PpoHistogram::new(3, 6, 3).unwrap();
|
||||
let warmup = osc.warmup_period();
|
||||
assert_eq!(warmup, 6 + 3 - 1);
|
||||
for i in 1..warmup {
|
||||
assert!(osc.update(100.0 + i as f64).is_none());
|
||||
}
|
||||
assert!(osc.update(100.0 + warmup as f64).is_some());
|
||||
assert!(osc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_converges_to_zero() {
|
||||
let mut osc = PpoHistogram::classic();
|
||||
let out = osc.batch(&[100.0_f64; 200]);
|
||||
let last = out.iter().rev().flatten().next().expect("emits a value");
|
||||
assert_relative_eq!(*last, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut osc = PpoHistogram::new(3, 6, 3).unwrap();
|
||||
let out = osc.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
|
||||
let before = *out.last().unwrap();
|
||||
assert!(before.is_some());
|
||||
assert_eq!(osc.update(f64::NAN), before);
|
||||
assert_eq!(osc.update(f64::INFINITY), before);
|
||||
assert_eq!(osc.value(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=100)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).cos() * 10.0)
|
||||
.collect();
|
||||
let mut a = PpoHistogram::classic();
|
||||
let mut b = PpoHistogram::classic();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut osc = PpoHistogram::classic();
|
||||
osc.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(osc.is_ready());
|
||||
osc.reset();
|
||||
assert!(!osc.is_ready());
|
||||
assert_eq!(osc.update(1.0), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
//! QQE — Quantitative Qualitative Estimation.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::indicators::rsi::Rsi;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// One QQE reading: the smoothed RSI and its volatility-trailing line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct QqeOutput {
|
||||
/// The EMA-smoothed RSI (the fast QQE line).
|
||||
pub rsi_ma: f64,
|
||||
/// The trailing line (the slow QQE line): an ATR-of-RSI trailing stop that
|
||||
/// the smoothed RSI rides above in an uptrend and below in a downtrend.
|
||||
pub trailing_line: f64,
|
||||
}
|
||||
|
||||
/// QQE — Quantitative Qualitative Estimation (Igor Livshin).
|
||||
///
|
||||
/// QQE smooths the RSI, then builds an "ATR of the RSI" trailing stop around it.
|
||||
/// Crossovers of the smoothed RSI and that trailing line give cleaner momentum
|
||||
/// signals than the raw RSI:
|
||||
///
|
||||
/// ```text
|
||||
/// rsi_ma = EMA(RSI(price, rsi_period), smoothing)
|
||||
/// atr_rsi = |rsi_ma − rsi_ma_prev|
|
||||
/// ma_atr = EMA(atr_rsi, 2·rsi_period − 1) // Wilder length
|
||||
/// dar = EMA(ma_atr, 2·rsi_period − 1) · factor // smoothed band width
|
||||
///
|
||||
/// long_band = (rsi_ma_prev > long_band_prev && rsi_ma > long_band_prev)
|
||||
/// ? max(long_band_prev, rsi_ma − dar) : rsi_ma − dar
|
||||
/// short_band = (rsi_ma_prev < short_band_prev && rsi_ma < short_band_prev)
|
||||
/// ? min(short_band_prev, rsi_ma + dar) : rsi_ma + dar
|
||||
/// trend = cross-up of short_band → +1, cross-down of long_band → −1, else hold
|
||||
/// trailing = trend == +1 ? long_band : short_band
|
||||
/// ```
|
||||
///
|
||||
/// The trailing line ratchets in the trend direction (only ever tightening until
|
||||
/// the smoothed RSI crosses it), exactly like a [`SuperTrend`](crate::SuperTrend)
|
||||
/// on the RSI. Livshin's defaults are `rsi_period = 14`, `smoothing = 5`,
|
||||
/// `factor = 4.236`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Qqe};
|
||||
///
|
||||
/// let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..200 {
|
||||
/// last = qqe.update(100.0 + (f64::from(i) * 0.1).sin() * 8.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Qqe {
|
||||
rsi: Rsi,
|
||||
rsi_ma: Ema,
|
||||
ma_atr: Ema,
|
||||
dar_ema: Ema,
|
||||
factor: f64,
|
||||
prev_rsi_ma: Option<f64>,
|
||||
bands: Option<(f64, f64, i8)>, // (long_band, short_band, trend)
|
||||
last_value: Option<QqeOutput>,
|
||||
}
|
||||
|
||||
impl Qqe {
|
||||
/// Construct a QQE with the RSI period, RSI smoothing, and band `factor`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `rsi_period` or `smoothing` is `0`, or
|
||||
/// [`Error::InvalidPeriod`] if `factor` is non-finite or not positive.
|
||||
pub fn new(rsi_period: usize, smoothing: usize, factor: f64) -> Result<Self> {
|
||||
if rsi_period == 0 || smoothing == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if !factor.is_finite() || factor <= 0.0 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "QQE factor must be a finite positive value",
|
||||
});
|
||||
}
|
||||
let wilders = 2 * rsi_period - 1;
|
||||
Ok(Self {
|
||||
rsi: Rsi::new(rsi_period)?,
|
||||
rsi_ma: Ema::new(smoothing)?,
|
||||
ma_atr: Ema::new(wilders)?,
|
||||
dar_ema: Ema::new(wilders)?,
|
||||
factor,
|
||||
prev_rsi_ma: None,
|
||||
bands: None,
|
||||
last_value: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured band factor.
|
||||
pub const fn factor(&self) -> f64 {
|
||||
self.factor
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<QqeOutput> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Qqe {
|
||||
type Input = f64;
|
||||
type Output = QqeOutput;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<QqeOutput> {
|
||||
let rsi = self.rsi.update(price)?;
|
||||
let rsi_ma = self.rsi_ma.update(rsi)?;
|
||||
|
||||
let Some(prev_ma) = self.prev_rsi_ma else {
|
||||
self.prev_rsi_ma = Some(rsi_ma);
|
||||
return None;
|
||||
};
|
||||
let atr_rsi = (rsi_ma - prev_ma).abs();
|
||||
self.prev_rsi_ma = Some(rsi_ma);
|
||||
|
||||
let ma_atr = self.ma_atr.update(atr_rsi)?;
|
||||
let dar = self.dar_ema.update(ma_atr)? * self.factor;
|
||||
|
||||
let new_long = rsi_ma - dar;
|
||||
let new_short = rsi_ma + dar;
|
||||
|
||||
let (long_band, short_band, trend) = match self.bands {
|
||||
Some((lb_prev, sb_prev, tr_prev)) => {
|
||||
let lb = if prev_ma > lb_prev && rsi_ma > lb_prev {
|
||||
lb_prev.max(new_long)
|
||||
} else {
|
||||
new_long
|
||||
};
|
||||
let sb = if prev_ma < sb_prev && rsi_ma < sb_prev {
|
||||
sb_prev.min(new_short)
|
||||
} else {
|
||||
new_short
|
||||
};
|
||||
let tr = if prev_ma <= sb_prev && rsi_ma > sb_prev {
|
||||
1
|
||||
} else if prev_ma >= lb_prev && rsi_ma < lb_prev {
|
||||
-1
|
||||
} else {
|
||||
tr_prev
|
||||
};
|
||||
(lb, sb, tr)
|
||||
}
|
||||
None => (new_long, new_short, 1),
|
||||
};
|
||||
self.bands = Some((long_band, short_band, trend));
|
||||
|
||||
let trailing_line = if trend == 1 { long_band } else { short_band };
|
||||
let out = QqeOutput {
|
||||
rsi_ma,
|
||||
trailing_line,
|
||||
};
|
||||
self.last_value = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.rsi.reset();
|
||||
self.rsi_ma.reset();
|
||||
self.ma_atr.reset();
|
||||
self.dar_ema.reset();
|
||||
self.prev_rsi_ma = None;
|
||||
self.bands = None;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// RSI (rsi_period + 1) -> rsi_ma EMA -> one bar for the first atr_rsi ->
|
||||
// ma_atr EMA -> dar EMA. Expressed via the component warmups so it stays
|
||||
// correct if those change.
|
||||
self.rsi.warmup_period()
|
||||
+ self.rsi_ma.warmup_period()
|
||||
+ self.ma_atr.warmup_period()
|
||||
+ self.dar_ema.warmup_period()
|
||||
- 2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"QQE"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
/// Independent reference replaying the full QQE recurrence.
|
||||
fn naive(
|
||||
prices: &[f64],
|
||||
rsi_period: usize,
|
||||
smoothing: usize,
|
||||
factor: f64,
|
||||
) -> Vec<Option<QqeOutput>> {
|
||||
let mut rsi = Rsi::new(rsi_period).unwrap();
|
||||
let mut rsi_ma = Ema::new(smoothing).unwrap();
|
||||
let wilders = 2 * rsi_period - 1;
|
||||
let mut ma_atr = Ema::new(wilders).unwrap();
|
||||
let mut dar_ema = Ema::new(wilders).unwrap();
|
||||
let mut prev_ma: Option<f64> = None;
|
||||
let mut bands: Option<(f64, f64, i8)> = None;
|
||||
let mut out = Vec::with_capacity(prices.len());
|
||||
for &p in prices {
|
||||
let v = (|| {
|
||||
let r = rsi.update(p)?;
|
||||
let m = rsi_ma.update(r)?;
|
||||
let Some(pm) = prev_ma else {
|
||||
prev_ma = Some(m);
|
||||
return None;
|
||||
};
|
||||
let atr = (m - pm).abs();
|
||||
prev_ma = Some(m);
|
||||
let ma = ma_atr.update(atr)?;
|
||||
let dar = dar_ema.update(ma)? * factor;
|
||||
let nl = m - dar;
|
||||
let ns = m + dar;
|
||||
let (lb, sb, tr) = match bands {
|
||||
Some((lbp, sbp, trp)) => {
|
||||
let lb = if pm > lbp && m > lbp { lbp.max(nl) } else { nl };
|
||||
let sb = if pm < sbp && m < sbp { sbp.min(ns) } else { ns };
|
||||
let tr = if pm <= sbp && m > sbp {
|
||||
1
|
||||
} else if pm >= lbp && m < lbp {
|
||||
-1
|
||||
} else {
|
||||
trp
|
||||
};
|
||||
(lb, sb, tr)
|
||||
}
|
||||
None => (nl, ns, 1),
|
||||
};
|
||||
bands = Some((lb, sb, tr));
|
||||
Some(QqeOutput {
|
||||
rsi_ma: m,
|
||||
trailing_line: if tr == 1 { lb } else { sb },
|
||||
})
|
||||
})();
|
||||
out.push(v);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_params() {
|
||||
assert!(matches!(Qqe::new(0, 5, 4.236), Err(Error::PeriodZero)));
|
||||
assert!(matches!(Qqe::new(14, 0, 4.236), Err(Error::PeriodZero)));
|
||||
assert!(matches!(
|
||||
Qqe::new(14, 5, 0.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Qqe::new(14, 5, f64::NAN),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
/// Cover the const accessors `factor` + `value` and the Indicator-impl
|
||||
/// `name`. `warmup_period` is covered by `first_emission_matches_warmup`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let qqe = Qqe::new(14, 5, 4.236).unwrap();
|
||||
assert_relative_eq!(qqe.factor(), 4.236, epsilon = 1e-12);
|
||||
assert_eq!(qqe.value(), None);
|
||||
assert_eq!(qqe.name(), "QQE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup() {
|
||||
// A long trend-up-then-down series exercises both trend flips and the
|
||||
// band tighten/reset branches.
|
||||
let prices: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.06).sin() * 20.0)
|
||||
.collect();
|
||||
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
|
||||
let out = qqe.batch(&prices);
|
||||
let warmup = qqe.warmup_period();
|
||||
for (i, v) in out.iter().enumerate().take(warmup - 1) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(
|
||||
out[warmup - 1].is_some(),
|
||||
"first value at warmup_period - 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_naive_over_full_cycle() {
|
||||
// Up, range, and down phases so every band/trend branch is traversed.
|
||||
let prices: Vec<f64> = (0..220)
|
||||
.map(|i| {
|
||||
let t = f64::from(i);
|
||||
100.0 + (t * 0.05).sin() * 18.0 + (t * 0.2).cos() * 4.0
|
||||
})
|
||||
.collect();
|
||||
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
|
||||
let got = qqe.batch(&prices);
|
||||
let want = naive(&prices, 14, 5, 4.236);
|
||||
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
|
||||
assert_eq!(g.is_some(), w.is_some(), "readiness mismatch at {i}");
|
||||
if let (Some(a), Some(b)) = (g, w) {
|
||||
assert_relative_eq!(a.rsi_ma, b.rsi_ma, epsilon = 1e-9);
|
||||
assert_relative_eq!(a.trailing_line, b.trailing_line, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_line_below_rsi_ma_in_uptrend() {
|
||||
// Sustained rise: trend resolves to +1 and the trailing (long) band sits
|
||||
// below the smoothed RSI.
|
||||
let prices: Vec<f64> = (1..=120).map(f64::from).collect();
|
||||
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
|
||||
let last = qqe.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last.trailing_line <= last.rsi_ma,
|
||||
"uptrend trailing {} should sit at/below rsi_ma {}",
|
||||
last.trailing_line,
|
||||
last.rsi_ma
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
|
||||
qqe.batch(
|
||||
&(0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.1).sin() * 8.0)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(qqe.is_ready());
|
||||
qqe.reset();
|
||||
assert!(!qqe.is_ready());
|
||||
assert_eq!(qqe.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..150)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.12).sin() * 12.0)
|
||||
.collect();
|
||||
let mut a = Qqe::new(14, 5, 4.236).unwrap();
|
||||
let mut b = Qqe::new(14, 5, 4.236).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Qstick — Tushar Chande's measure of buying vs. selling pressure.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Qstick: the simple moving average of the body `close - open` over `period`
|
||||
/// bars.
|
||||
///
|
||||
/// Positive values indicate a run of bars that closed above their open (net
|
||||
/// buying pressure); negative values indicate net selling pressure. A zero
|
||||
/// crossing is read as a shift in short-term sentiment.
|
||||
///
|
||||
/// ```text
|
||||
/// Qstick = SMA(close - open, period)
|
||||
/// ```
|
||||
///
|
||||
/// Reference: Tushar Chande, *The New Technical Trader*, 1994.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Qstick};
|
||||
///
|
||||
/// let mut indicator = Qstick::new(5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..20 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 1.0, base + 1.0, 1.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Qstick {
|
||||
period: usize,
|
||||
sma: Sma,
|
||||
}
|
||||
|
||||
impl Qstick {
|
||||
/// Construct a Qstick with the given averaging period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`](crate::error::Error::PeriodZero) if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
period,
|
||||
sma: Sma::new(period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured averaging period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Qstick {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.sma.update(candle.close - candle.open)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.sma.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.sma.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Qstick"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::Error;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(open: f64, close: f64, ts: i64) -> Candle {
|
||||
let high = open.max(close) + 1.0;
|
||||
let low = open.min(close) - 1.0;
|
||||
Candle::new(open, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Qstick::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let q = Qstick::new(5).unwrap();
|
||||
assert_eq!(q.period(), 5);
|
||||
assert_eq!(q.warmup_period(), 5);
|
||||
assert_eq!(q.name(), "Qstick");
|
||||
assert!(!q.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_period() {
|
||||
let mut q = Qstick::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..3).map(|i| candle(10.0, 11.0, i)).collect();
|
||||
let out = q.batch(&candles);
|
||||
assert!(out[0].is_none());
|
||||
assert!(out[1].is_none());
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_bodies_yield_the_body() {
|
||||
// Every bar closes 1.5 above its open -> Qstick converges to 1.5.
|
||||
let mut q = Qstick::new(4).unwrap();
|
||||
let candles: Vec<Candle> = (0..10).map(|i| candle(10.0, 11.5, i)).collect();
|
||||
let out = q.batch(&candles);
|
||||
assert_relative_eq!(out.last().unwrap().unwrap(), 1.5, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selling_pressure_is_negative() {
|
||||
let mut q = Qstick::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|i| candle(11.0, 10.0, i)).collect();
|
||||
let last = q.batch(&candles).last().unwrap().unwrap();
|
||||
assert!(last < 0.0, "qstick {last} should be negative");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut q = Qstick::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|i| candle(10.0, 11.0, i)).collect();
|
||||
q.batch(&candles);
|
||||
assert!(q.is_ready());
|
||||
q.reset();
|
||||
assert!(!q.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40_i64)
|
||||
.map(|i| {
|
||||
candle(
|
||||
100.0 + (i as f64 * 0.3).sin(),
|
||||
100.0 + (i as f64 * 0.4).cos(),
|
||||
i,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut a = Qstick::new(7).unwrap();
|
||||
let mut b = Qstick::new(7).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
//! Relative Momentum Index (RMI).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Relative Momentum Index — RSI generalised to a multi-bar momentum lookback.
|
||||
///
|
||||
/// Wilder's [`Rsi`](crate::Rsi) compares each close to the *previous* close.
|
||||
/// The RMI (Roger Altman, 1993) compares it to the close `momentum` bars ago,
|
||||
/// then applies the same Wilder-smoothed up/down accumulator over `period`:
|
||||
///
|
||||
/// ```text
|
||||
/// change_t = close_t - close_{t-momentum}
|
||||
/// gain = max(change, 0), loss = max(-change, 0)
|
||||
/// avg_gain, avg_loss = Wilder-smoothed over `period`
|
||||
/// RMI = 100 * avg_gain / (avg_gain + avg_loss)
|
||||
/// ```
|
||||
///
|
||||
/// `momentum = 1` reduces the RMI exactly to the RSI. Larger `momentum` makes
|
||||
/// the oscillator smoother and slower to flip, holding overbought/oversold
|
||||
/// readings longer in a trend. Output is bounded in `[0, 100]`; a flat market
|
||||
/// (no gains and no losses) returns the neutral `50`.
|
||||
///
|
||||
/// The first value lands after `momentum + period` inputs: `momentum` to fill
|
||||
/// the lookback, then `period` changes to seed Wilder's averages.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Rmi};
|
||||
///
|
||||
/// let mut indicator = Rmi::new(14, 5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Rmi {
|
||||
period: usize,
|
||||
momentum: usize,
|
||||
/// The last `momentum` prices, oldest at the front.
|
||||
window: VecDeque<f64>,
|
||||
seed_gains: Vec<f64>,
|
||||
seed_losses: Vec<f64>,
|
||||
avg_gain: Option<f64>,
|
||||
avg_loss: Option<f64>,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl Rmi {
|
||||
/// Construct an RMI with the given smoothing `period` and `momentum`
|
||||
/// lookback.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if either `period` or `momentum` is `0`.
|
||||
pub fn new(period: usize, momentum: usize) -> Result<Self> {
|
||||
if period == 0 || momentum == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
momentum,
|
||||
window: VecDeque::with_capacity(momentum),
|
||||
seed_gains: Vec::with_capacity(period),
|
||||
seed_losses: Vec::with_capacity(period),
|
||||
avg_gain: None,
|
||||
avg_loss: None,
|
||||
last_value: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured smoothing period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured momentum lookback.
|
||||
pub const fn momentum(&self) -> usize {
|
||||
self.momentum
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
|
||||
fn rmi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
|
||||
let denom = avg_gain + avg_loss;
|
||||
if denom == 0.0 {
|
||||
50.0
|
||||
} else {
|
||||
// Ratio first, then scale, so `100 * g / g` cannot round above 100.
|
||||
100.0 * (avg_gain / denom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Rmi {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last_value;
|
||||
}
|
||||
if self.window.len() < self.momentum {
|
||||
// Still filling the momentum lookback; no change to measure yet.
|
||||
self.window.push_back(input);
|
||||
return None;
|
||||
}
|
||||
let past = self.window.pop_front().expect("window full");
|
||||
self.window.push_back(input);
|
||||
|
||||
let change = input - past;
|
||||
let gain = if change > 0.0 { change } else { 0.0 };
|
||||
let loss = if change < 0.0 { -change } 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);
|
||||
let v = Self::rmi_from_avgs(new_ag, new_al);
|
||||
self.last_value = Some(v);
|
||||
return Some(v);
|
||||
}
|
||||
|
||||
self.seed_gains.push(gain);
|
||||
self.seed_losses.push(loss);
|
||||
if self.seed_gains.len() == self.period {
|
||||
let ag = self.seed_gains.iter().sum::<f64>() / self.period as f64;
|
||||
let al = self.seed_losses.iter().sum::<f64>() / self.period as f64;
|
||||
self.avg_gain = Some(ag);
|
||||
self.avg_loss = Some(al);
|
||||
let v = Self::rmi_from_avgs(ag, al);
|
||||
self.last_value = Some(v);
|
||||
return Some(v);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.seed_gains.clear();
|
||||
self.seed_losses.clear();
|
||||
self.avg_gain = None;
|
||||
self.avg_loss = None;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.momentum + self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"RMI"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::Rsi;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_params() {
|
||||
assert!(matches!(Rmi::new(0, 5), Err(Error::PeriodZero)));
|
||||
assert!(matches!(Rmi::new(14, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
/// Cover the const accessors `period` + `momentum` + `value` and the
|
||||
/// Indicator-impl `warmup_period` + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let rmi = Rmi::new(14, 5).unwrap();
|
||||
assert_eq!(rmi.period(), 14);
|
||||
assert_eq!(rmi.momentum(), 5);
|
||||
assert_eq!(rmi.value(), None);
|
||||
assert_eq!(rmi.warmup_period(), 19);
|
||||
assert_eq!(rmi.name(), "RMI");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn momentum_one_equals_rsi() {
|
||||
// With momentum = 1 the RMI is exactly Wilder's RSI.
|
||||
let prices: Vec<f64> = (0..60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
|
||||
.collect();
|
||||
let mut rmi = Rmi::new(14, 1).unwrap();
|
||||
let mut rsi = Rsi::new(14).unwrap();
|
||||
for (i, &p) in prices.iter().enumerate() {
|
||||
let got = rmi.update(p);
|
||||
let want = rsi.update(p);
|
||||
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
|
||||
if let (Some(a), Some(b)) = (got, want) {
|
||||
assert_relative_eq!(a, b, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_then_emits() {
|
||||
// momentum + period = 3 + 2 = 5 inputs before the first value.
|
||||
let mut rmi = Rmi::new(2, 3).unwrap();
|
||||
let out = rmi.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
|
||||
for (i, v) in out.iter().enumerate().take(4) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[4].is_some(), "first value at warmup_period - 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_is_one_hundred() {
|
||||
// Every momentum-spaced change is positive -> avg_loss 0 -> RMI 100.
|
||||
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
|
||||
let mut rmi = Rmi::new(5, 3).unwrap();
|
||||
let last = rmi.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_is_neutral() {
|
||||
// No change -> no gains and no losses -> neutral 50.
|
||||
let mut rmi = Rmi::new(3, 2).unwrap();
|
||||
let last = rmi.batch(&[7.0; 20]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut rmi = Rmi::new(2, 2).unwrap();
|
||||
let ready = rmi
|
||||
.batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(rmi.update(f64::NAN), Some(ready));
|
||||
assert_eq!(rmi.update(f64::INFINITY), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut rmi = Rmi::new(3, 2).unwrap();
|
||||
rmi.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(rmi.is_ready());
|
||||
rmi.reset();
|
||||
assert!(!rmi.is_ready());
|
||||
assert_eq!(rmi.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=40)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
|
||||
.collect();
|
||||
let mut a = Rmi::new(14, 5).unwrap();
|
||||
let mut b = Rmi::new(14, 5).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,13 +25,24 @@ use crate::traits::Indicator;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Rsi {
|
||||
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,
|
||||
// then transitions to recursive smoothing.
|
||||
seed_buf_gains: Vec<f64>,
|
||||
seed_buf_losses: Vec<f64>,
|
||||
avg_gain: Option<f64>,
|
||||
avg_loss: Option<f64>,
|
||||
/// Smoothed average gain / loss, valid once `avgs_seeded` is set. Bare `f64`s
|
||||
/// + 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>,
|
||||
}
|
||||
|
||||
@@ -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::<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_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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
//! RSX — Jurik-style smoothed RSI.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// RSX — a noise-free RSI built from Jurik's three-stage smoothing cascade.
|
||||
///
|
||||
/// Where Wilder's [`Rsi`](crate::Rsi) smooths the up/down moves with a single
|
||||
/// EMA, the RSX runs the signed price change *and* its absolute value through
|
||||
/// three cascaded "double-EMA with overshoot" stages (each stage is
|
||||
/// `x = 1.5·a − 0.5·b`, the same lag-cancelling trick as a DEMA), then forms the
|
||||
/// RSI-style ratio from the two smoothed streams:
|
||||
///
|
||||
/// ```text
|
||||
/// f18 = 3 / (length + 2), f20 = 1 - f18
|
||||
/// each stage: a = f20·a + f18·in; b = f18·a + f20·b; out = 1.5·a − 0.5·b
|
||||
/// v14 = stage3(signed change), v1C = stage3(|change|)
|
||||
/// RSX = clamp((v14 / v1C + 1) · 50, 0, 100) (50 when v1C == 0)
|
||||
/// ```
|
||||
///
|
||||
/// The result is an oscillator in `[0, 100]` that tracks the RSI but is far
|
||||
/// smoother for the same responsiveness — it has very little of the RSI's
|
||||
/// bar-to-bar jitter, so threshold crosses and divergences are cleaner. A flat
|
||||
/// market returns the neutral `50`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Rsx};
|
||||
///
|
||||
/// let mut indicator = Rsx::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Rsx {
|
||||
length: usize,
|
||||
f18: f64,
|
||||
f20: f64,
|
||||
prev: Option<f64>,
|
||||
count: usize,
|
||||
// Signed-change cascade (three stages: a/b pairs).
|
||||
s_a0: f64,
|
||||
s_b0: f64,
|
||||
s_a1: f64,
|
||||
s_b1: f64,
|
||||
s_a2: f64,
|
||||
s_b2: f64,
|
||||
// Absolute-change cascade.
|
||||
a_a0: f64,
|
||||
a_b0: f64,
|
||||
a_a1: f64,
|
||||
a_b1: f64,
|
||||
a_a2: f64,
|
||||
a_b2: f64,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl Rsx {
|
||||
/// Construct an RSX with the given smoothing length.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `length == 0`.
|
||||
pub fn new(length: usize) -> Result<Self> {
|
||||
if length == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
let f18 = 3.0 / (length as f64 + 2.0);
|
||||
Ok(Self {
|
||||
length,
|
||||
f18,
|
||||
f20: 1.0 - f18,
|
||||
prev: None,
|
||||
count: 0,
|
||||
s_a0: 0.0,
|
||||
s_b0: 0.0,
|
||||
s_a1: 0.0,
|
||||
s_b1: 0.0,
|
||||
s_a2: 0.0,
|
||||
s_b2: 0.0,
|
||||
a_a0: 0.0,
|
||||
a_b0: 0.0,
|
||||
a_a1: 0.0,
|
||||
a_b1: 0.0,
|
||||
a_a2: 0.0,
|
||||
a_b2: 0.0,
|
||||
last_value: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured length.
|
||||
pub const fn length(&self) -> usize {
|
||||
self.length
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
|
||||
/// One double-EMA-with-overshoot stage: updates the `(a, b)` pair in place
|
||||
/// and returns `1.5·a − 0.5·b`.
|
||||
fn stage(&self, a: &mut f64, b: &mut f64, input: f64) -> f64 {
|
||||
*a = self.f20 * *a + self.f18 * input;
|
||||
*b = self.f18 * *a + self.f20 * *b;
|
||||
1.5 * *a - 0.5 * *b
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Rsx {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last_value;
|
||||
}
|
||||
let Some(prev) = self.prev else {
|
||||
self.prev = Some(price);
|
||||
return None;
|
||||
};
|
||||
self.prev = Some(price);
|
||||
|
||||
let change = price - prev;
|
||||
|
||||
// Signed-change cascade.
|
||||
let (mut sa0, mut sb0) = (self.s_a0, self.s_b0);
|
||||
let v_c = self.stage(&mut sa0, &mut sb0, change);
|
||||
self.s_a0 = sa0;
|
||||
self.s_b0 = sb0;
|
||||
let (mut sa1, mut sb1) = (self.s_a1, self.s_b1);
|
||||
let v_10 = self.stage(&mut sa1, &mut sb1, v_c);
|
||||
self.s_a1 = sa1;
|
||||
self.s_b1 = sb1;
|
||||
let (mut sa2, mut sb2) = (self.s_a2, self.s_b2);
|
||||
let v_14 = self.stage(&mut sa2, &mut sb2, v_10);
|
||||
self.s_a2 = sa2;
|
||||
self.s_b2 = sb2;
|
||||
|
||||
// Absolute-change cascade.
|
||||
let abs = change.abs();
|
||||
let (mut aa0, mut ab0) = (self.a_a0, self.a_b0);
|
||||
let v_c1 = self.stage(&mut aa0, &mut ab0, abs);
|
||||
self.a_a0 = aa0;
|
||||
self.a_b0 = ab0;
|
||||
let (mut aa1, mut ab1) = (self.a_a1, self.a_b1);
|
||||
let v_18 = self.stage(&mut aa1, &mut ab1, v_c1);
|
||||
self.a_a1 = aa1;
|
||||
self.a_b1 = ab1;
|
||||
let (mut aa2, mut ab2) = (self.a_a2, self.a_b2);
|
||||
let v_1c = self.stage(&mut aa2, &mut ab2, v_18);
|
||||
self.a_a2 = aa2;
|
||||
self.a_b2 = ab2;
|
||||
|
||||
let v4 = if v_1c > 0.0 {
|
||||
(v_14 / v_1c + 1.0) * 50.0
|
||||
} else {
|
||||
50.0
|
||||
};
|
||||
let rsx = v4.clamp(0.0, 100.0);
|
||||
|
||||
self.count += 1;
|
||||
self.last_value = Some(rsx);
|
||||
if self.count >= self.length {
|
||||
Some(rsx)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
*self = Self::new(self.length).expect("length already validated");
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// One input to seed `prev`, then `length` changes to settle the cascade.
|
||||
self.length + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.count >= self.length
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"RSX"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_length() {
|
||||
assert!(matches!(Rsx::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
/// Cover the const accessors `length` + `value` and the Indicator-impl
|
||||
/// `warmup_period` + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let rsx = Rsx::new(14).unwrap();
|
||||
assert_eq!(rsx.length(), 14);
|
||||
assert_eq!(rsx.value(), None);
|
||||
assert_eq!(rsx.warmup_period(), 15);
|
||||
assert_eq!(rsx.name(), "RSX");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_then_emits() {
|
||||
let mut rsx = Rsx::new(3).unwrap();
|
||||
// 1 input seeds prev; then 3 changes settle -> first Some on input 4.
|
||||
assert_eq!(rsx.update(10.0), None);
|
||||
assert_eq!(rsx.update(11.0), None);
|
||||
assert_eq!(rsx.update(12.0), None);
|
||||
assert!(rsx.update(13.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_is_neutral() {
|
||||
// No movement -> absolute cascade is zero -> neutral 50.
|
||||
let mut rsx = Rsx::new(5).unwrap();
|
||||
let last = rsx.batch(&[7.0; 40]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_stays_in_range() {
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.35).sin() * 12.0)
|
||||
.collect();
|
||||
let mut rsx = Rsx::new(14).unwrap();
|
||||
for v in rsx.batch(&prices).into_iter().flatten() {
|
||||
assert!((0.0..=100.0).contains(&v), "RSX {v} left [0, 100]");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strong_uptrend_is_high() {
|
||||
// A sustained rise drives RSX well above the neutral 50.
|
||||
let prices: Vec<f64> = (1..=60).map(f64::from).collect();
|
||||
let mut rsx = Rsx::new(14).unwrap();
|
||||
let last = rsx.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last > 80.0,
|
||||
"strong uptrend should push RSX high, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut rsx = Rsx::new(3).unwrap();
|
||||
let ready = rsx
|
||||
.batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(rsx.update(f64::NAN), Some(ready));
|
||||
assert_eq!(rsx.update(f64::INFINITY), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut rsx = Rsx::new(5).unwrap();
|
||||
rsx.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(rsx.is_ready());
|
||||
rsx.reset();
|
||||
assert!(!rsx.is_ready());
|
||||
assert_eq!(rsx.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=60)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
|
||||
.collect();
|
||||
let mut a = Rsx::new(14).unwrap();
|
||||
let mut b = Rsx::new(14).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<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,
|
||||
/// 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<f64> {
|
||||
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() {
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Stochastic CCI — a stochastic oscillator applied to the CCI.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::cci::Cci;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Stochastic CCI — the stochastic oscillator computed over the
|
||||
/// [`Cci`](crate::Cci) instead of price.
|
||||
///
|
||||
/// The CCI is unbounded and spends most of its time inside `±100`, which makes
|
||||
/// fixed overbought/oversold lines awkward. Running a stochastic over the CCI
|
||||
/// re-scales it to `[0, 100]` relative to its own recent range, turning it into
|
||||
/// a bounded, self-normalising momentum oscillator:
|
||||
///
|
||||
/// ```text
|
||||
/// cci = CCI(typical price, period)
|
||||
/// %K = 100 * (cci - lowest(cci, period)) / (highest(cci, period) - lowest(cci, period))
|
||||
/// ```
|
||||
///
|
||||
/// The same `period` is used for the CCI and the stochastic lookback. When the
|
||||
/// CCI range over the window is zero (a flat market, where the CCI is pinned at
|
||||
/// `0`) the oscillator returns the neutral `50`. The first value lands after
|
||||
/// `2·period − 1` bars: `period` to seed the CCI, then `period` CCI values to
|
||||
/// fill the stochastic window.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, StochasticCci, Indicator};
|
||||
///
|
||||
/// let mut sc = StochasticCci::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 10.0;
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1.0, i64::from(i)).unwrap();
|
||||
/// last = sc.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StochasticCci {
|
||||
period: usize,
|
||||
cci: Cci,
|
||||
/// The last `period` CCI values.
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl StochasticCci {
|
||||
/// Construct a Stochastic CCI with the given period (shared by the CCI and
|
||||
/// the stochastic lookback).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
period,
|
||||
cci: Cci::new(period)?,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for StochasticCci {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let cci = self.cci.update(candle)?;
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(cci);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let mut lo = f64::MAX;
|
||||
let mut hi = f64::MIN;
|
||||
for &v in &self.window {
|
||||
if v < lo {
|
||||
lo = v;
|
||||
}
|
||||
if v > hi {
|
||||
hi = v;
|
||||
}
|
||||
}
|
||||
let range = hi - lo;
|
||||
if range == 0.0 {
|
||||
return Some(50.0);
|
||||
}
|
||||
// Ratio first, then scale: `100 * x / x` can round to 100.0000…1.
|
||||
Some(100.0 * ((cci - lo) / range))
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.cci.reset();
|
||||
self.window.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// CCI seeds at `period`, then `period` CCI values fill the stochastic window.
|
||||
2 * self.period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"StochasticCCI"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new(close, high, low, close, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(StochasticCci::new(0).is_err());
|
||||
}
|
||||
|
||||
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
|
||||
/// + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let sc = StochasticCci::new(14).unwrap();
|
||||
assert_eq!(sc.period(), 14);
|
||||
assert_eq!(sc.warmup_period(), 27);
|
||||
assert_eq!(sc.name(), "StochasticCCI");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let bars: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.4).sin() * 8.0;
|
||||
candle(base + 1.0, base - 1.0, base)
|
||||
})
|
||||
.collect();
|
||||
let mut sc = StochasticCci::new(5).unwrap();
|
||||
let out = sc.batch(&bars);
|
||||
let warmup = sc.warmup_period();
|
||||
assert_eq!(warmup, 9);
|
||||
for (i, v) in out.iter().enumerate().take(warmup - 1) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[warmup - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_zero_to_hundred() {
|
||||
let bars: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.35).sin() * 12.0;
|
||||
candle(base + 2.0, base - 2.0, base)
|
||||
})
|
||||
.collect();
|
||||
let mut sc = StochasticCci::new(9).unwrap();
|
||||
for v in sc.batch(&bars).into_iter().flatten() {
|
||||
assert!((0.0..=100.0).contains(&v), "%K {v} left [0, 100]");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_is_neutral() {
|
||||
// Constant candles -> CCI pinned at 0 -> zero range -> neutral 50.
|
||||
let mut sc = StochasticCci::new(4).unwrap();
|
||||
let bars = vec![candle(10.0, 10.0, 10.0); 20];
|
||||
let last = sc.batch(&bars).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highest_cci_in_window_is_hundred() {
|
||||
// When the latest CCI is the window maximum, %K must be 100.
|
||||
// A long rise then makes the final CCI the highest in its window.
|
||||
let mut bars: Vec<Candle> = (0..20)
|
||||
.map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
|
||||
.collect();
|
||||
// Strong final push so the last CCI tops its window.
|
||||
bars.push(candle(100.0, 98.0, 100.0));
|
||||
let mut sc = StochasticCci::new(5).unwrap();
|
||||
let last = sc.batch(&bars).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut sc = StochasticCci::new(5).unwrap();
|
||||
sc.batch(
|
||||
&(0..30)
|
||||
.map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(sc.is_ready());
|
||||
sc.reset();
|
||||
assert!(!sc.is_ready());
|
||||
assert_eq!(sc.update(candle(2.0, 0.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let bars: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let base = 50.0 + (f64::from(i) * 0.5).sin() * 10.0;
|
||||
candle(base + 1.5, base - 1.5, base)
|
||||
})
|
||||
.collect();
|
||||
let mut a = StochasticCci::new(9).unwrap();
|
||||
let mut b = StochasticCci::new(9).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&bars),
|
||||
bars.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Trend Strength Index — the signed coefficient of determination of a linear
|
||||
//! regression of price against time.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Trend Strength Index: fits an ordinary-least-squares line to the last
|
||||
/// `period` prices against their bar index and reports the coefficient of
|
||||
/// determination `r^2`, signed by the slope of the fit.
|
||||
///
|
||||
/// ```text
|
||||
/// regress y = close on x = 0..period-1
|
||||
/// r^2 = (n·Σxy − Σx·Σy)^2 / [ (n·Σx² − (Σx)²)(n·Σy² − (Σy)²) ]
|
||||
/// TSI = sign(slope) · r^2 (slope sign = sign of n·Σxy − Σx·Σy)
|
||||
/// ```
|
||||
///
|
||||
/// `r^2` in `[0, 1]` measures how well a straight line explains the price over
|
||||
/// the window — how *trendy* the segment is, regardless of direction. Carrying
|
||||
/// the slope sign turns it into a directional reading in `[-1, 1]`: values near
|
||||
/// `+1` are a strong, clean uptrend; near `-1` a strong downtrend; near `0` a
|
||||
/// flat or noisy market with no linear structure. A window of constant prices
|
||||
/// (zero variance in `y`) has no defined trend and returns `0`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, TrendStrengthIndex};
|
||||
///
|
||||
/// let mut indicator = TrendStrengthIndex::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// // A clean ramp is a perfect uptrend -> r^2 = 1.
|
||||
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrendStrengthIndex {
|
||||
period: usize,
|
||||
buf: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl TrendStrengthIndex {
|
||||
/// Construct a Trend Strength Index over the given window.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`, or [`Error::InvalidPeriod`]
|
||||
/// if `period == 1` (a regression needs at least two points).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if period == 1 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "period must be >= 2 for a regression",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
buf: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window length.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TrendStrengthIndex {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
self.buf.push_back(price);
|
||||
if self.buf.len() > self.period {
|
||||
self.buf.pop_front();
|
||||
}
|
||||
if self.buf.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
|
||||
let count = self.period as f64;
|
||||
let mut sum_x = 0.0;
|
||||
let mut sum_xx = 0.0;
|
||||
let mut sum_y = 0.0;
|
||||
let mut sum_yy = 0.0;
|
||||
let mut sum_xy = 0.0;
|
||||
for (idx, &price) in self.buf.iter().enumerate() {
|
||||
let x = idx as f64;
|
||||
sum_x += x;
|
||||
sum_xx += x * x;
|
||||
sum_y += price;
|
||||
sum_yy += price * price;
|
||||
sum_xy += x * price;
|
||||
}
|
||||
|
||||
let cov = count.mul_add(sum_xy, -(sum_x * sum_y));
|
||||
let var_x = count.mul_add(sum_xx, -(sum_x * sum_x));
|
||||
let var_y = count.mul_add(sum_yy, -(sum_y * sum_y));
|
||||
if var_y <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let r2 = (cov * cov) / (var_x * var_y);
|
||||
Some(if cov >= 0.0 { r2 } else { -r2 })
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.buf.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.buf.len() >= self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TrendStrengthIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_period() {
|
||||
assert!(matches!(TrendStrengthIndex::new(0), Err(Error::PeriodZero)));
|
||||
assert!(matches!(
|
||||
TrendStrengthIndex::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let tsi = TrendStrengthIndex::new(20).unwrap();
|
||||
assert_eq!(tsi.period(), 20);
|
||||
assert_eq!(tsi.warmup_period(), 20);
|
||||
assert_eq!(tsi.name(), "TrendStrengthIndex");
|
||||
assert!(!tsi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_at_period() {
|
||||
let mut tsi = TrendStrengthIndex::new(4).unwrap();
|
||||
let inputs: Vec<f64> = (0..6).map(f64::from).collect();
|
||||
let out = tsi.batch(&inputs);
|
||||
assert!(out[2].is_none());
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_uptrend_is_plus_one() {
|
||||
let mut tsi = TrendStrengthIndex::new(10).unwrap();
|
||||
let inputs: Vec<f64> = (0..10).map(f64::from).collect();
|
||||
let last = tsi.batch(&inputs).last().unwrap().unwrap();
|
||||
assert_relative_eq!(last, 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_downtrend_is_minus_one() {
|
||||
let mut tsi = TrendStrengthIndex::new(10).unwrap();
|
||||
let inputs: Vec<f64> = (0..10).map(|i| 100.0 - f64::from(i)).collect();
|
||||
let last = tsi.batch(&inputs).last().unwrap().unwrap();
|
||||
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_returns_zero() {
|
||||
let mut tsi = TrendStrengthIndex::new(8).unwrap();
|
||||
let inputs = [42.0; 12];
|
||||
let last = tsi.batch(&inputs).last().unwrap().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noisy_trend_is_between() {
|
||||
// An upward drift with noise: positive but not a perfect fit.
|
||||
let mut tsi = TrendStrengthIndex::new(12).unwrap();
|
||||
let inputs: Vec<f64> = (0..12)
|
||||
.map(|i| f64::from(i) + if i % 2 == 0 { 0.0 } else { 3.0 })
|
||||
.collect();
|
||||
let last = tsi.batch(&inputs).last().unwrap().unwrap();
|
||||
assert!(last > 0.0 && last < 1.0, "tsi {last} should be in (0, 1)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut tsi = TrendStrengthIndex::new(10).unwrap();
|
||||
let inputs: Vec<f64> = (0..10).map(f64::from).collect();
|
||||
tsi.batch(&inputs);
|
||||
assert!(tsi.is_ready());
|
||||
tsi.reset();
|
||||
assert!(!tsi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let inputs: Vec<f64> = (0..80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = TrendStrengthIndex::new(15).unwrap();
|
||||
let mut b = TrendStrengthIndex::new(15).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&inputs),
|
||||
inputs.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Time Series Forecast Oscillator (TSF Oscillator).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::tsf::Tsf;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Time Series Forecast Oscillator — the percentage gap between the close and
|
||||
/// the **one-bar-ahead** time-series forecast of the close.
|
||||
///
|
||||
/// ```text
|
||||
/// TSFOsc_t = 100 · (close_t − TSF(close, period)_t) / close_t
|
||||
/// ```
|
||||
///
|
||||
/// where [`Tsf`](crate::Tsf) projects the rolling least-squares line one bar
|
||||
/// past the window (`a + b·period`). It is the close-relative companion to
|
||||
/// [`Cfo`](crate::Cfo), which measures the same percentage gap against the
|
||||
/// regression value at the *current* bar (`a + b·(period − 1)`). Because `TSF`
|
||||
/// advances one bar further than `LinearRegression`, the two differ by exactly
|
||||
/// the slope term `100·b/close`: on a trending series `TSFOsc` reads more
|
||||
/// negative in an uptrend (the forecast has already stepped above price) and
|
||||
/// more positive in a downtrend.
|
||||
///
|
||||
/// Positive readings mean the close sits *above* its forward forecast (price
|
||||
/// has overshot the projected trend); negative readings mean it sits below.
|
||||
/// Wraps the existing `Tsf` so the warmup matches.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, TsfOscillator};
|
||||
///
|
||||
/// let mut indicator = TsfOscillator::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TsfOscillator {
|
||||
period: usize,
|
||||
tsf: Tsf,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl TsfOscillator {
|
||||
/// Construct a new TSF oscillator over `period` inputs.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line is
|
||||
/// undefined for fewer than two points.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "TSF oscillator needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
tsf: Tsf::new(period)?,
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TsfOscillator {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let forecast = self.tsf.update(input)?;
|
||||
// Hold the previous value if the close is zero — the percentage form
|
||||
// is undefined and a return of inf would propagate badly.
|
||||
if input == 0.0 {
|
||||
return self.current;
|
||||
}
|
||||
let value = 100.0 * (input - forecast) / input;
|
||||
self.current = Some(value);
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.tsf.reset();
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TsfOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_short_period() {
|
||||
assert!(matches!(
|
||||
TsfOscillator::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
TsfOscillator::new(0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let osc = TsfOscillator::new(14).unwrap();
|
||||
assert_eq!(osc.period(), 14);
|
||||
assert_eq!(osc.warmup_period(), 14);
|
||||
assert_eq!(osc.name(), "TsfOscillator");
|
||||
assert!(!osc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// period 3 over [1, 2, 9]: fit y = 0 + 4x, one-bar-ahead TSF at x = 3
|
||||
// is 12. With close = 9, TSFOsc = 100·(9 − 12)/9 = −33.3333…%.
|
||||
let mut osc = TsfOscillator::new(3).unwrap();
|
||||
let out = osc.batch(&[1.0_f64, 2.0, 9.0]);
|
||||
assert!(out[0].is_none());
|
||||
assert!(out[1].is_none());
|
||||
assert_relative_eq!(out[2].unwrap(), -100.0 / 3.0, epsilon = 1e-9);
|
||||
assert!(osc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// On a flat series the regression slope is 0, so the one-bar-ahead TSF
|
||||
// equals the constant and close − forecast is exactly 0.
|
||||
let mut osc = TsfOscillator::new(5).unwrap();
|
||||
let out = osc.batch(&[42.0_f64; 30]);
|
||||
for v in out.iter().skip(4).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_uptrend_reads_negative() {
|
||||
// Unlike CFO (evaluated at the current bar), the forecast steps one bar
|
||||
// ahead, so on a rising line the projection sits above the close and the
|
||||
// oscillator is negative: TSFOsc = −100·slope/close.
|
||||
let mut osc = TsfOscillator::new(5).unwrap();
|
||||
let prices: Vec<f64> = (1..=20).map(|i| f64::from(i) * 2.0).collect();
|
||||
let out = osc.batch(&prices);
|
||||
for v in out.iter().skip(4).flatten() {
|
||||
assert!(*v < 0.0, "uptrend forecast overshoots close, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_period() {
|
||||
let mut osc = TsfOscillator::new(3).unwrap();
|
||||
assert_eq!(osc.update(1.0), None);
|
||||
assert_eq!(osc.update(2.0), None);
|
||||
assert!(osc.update(3.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = TsfOscillator::new(14).unwrap();
|
||||
let mut b = TsfOscillator::new(14).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut osc = TsfOscillator::new(5).unwrap();
|
||||
osc.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(osc.is_ready());
|
||||
osc.reset();
|
||||
assert!(!osc.is_ready());
|
||||
assert_eq!(osc.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_close_holds_value() {
|
||||
let mut osc = TsfOscillator::new(3).unwrap();
|
||||
osc.batch(&[1.0_f64, 2.0, 3.0]);
|
||||
let before = osc.current;
|
||||
assert_eq!(osc.update(0.0), before);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! TTM Trend — John Carter's bar-coloring trend filter.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// TTM Trend: compares the current close to the simple moving average of the
|
||||
/// recent median prices `(high + low) / 2`. A close above that reference colors
|
||||
/// the bar as an uptrend (`+1.0`); a close at or below it as a downtrend
|
||||
/// (`-1.0`).
|
||||
///
|
||||
/// ```text
|
||||
/// reference = SMA((high + low) / 2, period)
|
||||
/// TTM Trend = +1 if close > reference
|
||||
/// -1 otherwise
|
||||
/// ```
|
||||
///
|
||||
/// The classic TTM Trend uses the trailing six bars. The signal is a regime
|
||||
/// label rather than a level: it stays `None` during warmup and then emits
|
||||
/// `±1.0` on every bar.
|
||||
///
|
||||
/// Reference: John Carter, *Mastering the Trade*, 2005.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, TtmTrend};
|
||||
///
|
||||
/// let mut indicator = TtmTrend::new(6).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..20 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert_eq!(last, Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TtmTrend {
|
||||
period: usize,
|
||||
sma: Sma,
|
||||
}
|
||||
|
||||
impl TtmTrend {
|
||||
/// Construct a TTM Trend over the given lookback.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`](crate::error::Error::PeriodZero) if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
period,
|
||||
sma: Sma::new(period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured lookback period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TtmTrend {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let median = f64::midpoint(candle.high, candle.low);
|
||||
let reference = self.sma.update(median)?;
|
||||
Some(if candle.close > reference { 1.0 } else { -1.0 })
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.sma.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.sma.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TtmTrend"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::Error;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(TtmTrend::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let t = TtmTrend::new(6).unwrap();
|
||||
assert_eq!(t.period(), 6);
|
||||
assert_eq!(t.warmup_period(), 6);
|
||||
assert_eq!(t.name(), "TtmTrend");
|
||||
assert!(!t.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_then_emits() {
|
||||
let mut t = TtmTrend::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..3).map(|i| candle(13.0, 9.0, 12.0, i)).collect();
|
||||
let out = t.batch(&candles);
|
||||
assert!(out[0].is_none());
|
||||
assert!(out[1].is_none());
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_above_reference_is_uptrend() {
|
||||
// Close (12) sits above the median reference (13 + 9) / 2 = 11 -> +1.
|
||||
let mut t = TtmTrend::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|i| candle(13.0, 9.0, 12.0, i)).collect();
|
||||
assert_eq!(t.batch(&candles).last().unwrap().unwrap(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_at_or_below_reference_is_downtrend() {
|
||||
// Constant median 10, close equal to the reference -> not strictly above -> -1.
|
||||
let mut t = TtmTrend::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|i| candle(11.0, 9.0, 10.0, i)).collect();
|
||||
assert_eq!(t.batch(&candles).last().unwrap().unwrap(), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = TtmTrend::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|i| candle(13.0, 9.0, 12.0, i)).collect();
|
||||
t.batch(&candles);
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40_i64)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (i as f64 * 0.25).sin() * 4.0;
|
||||
candle(base + 1.0, base - 1.0, base + (i as f64 * 0.5).cos(), i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = TtmTrend::new(6).unwrap();
|
||||
let mut b = TtmTrend::new(6).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Wave PM — Cynthia Kase's peak-momentum statistic (Wickra reconstruction).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Wave PM (Peak Momentum): a `0..100` statistic that rises when the current
|
||||
/// `length`-bar momentum is large relative to its own recent energy — Cynthia
|
||||
/// Kase's gauge of how "peaked" the move is.
|
||||
///
|
||||
/// ```text
|
||||
/// m = close_t - close_{t-length} (length-bar momentum)
|
||||
/// energy = EMA(m^2, length) (mean squared momentum)
|
||||
/// raw = 1 - exp( -m^2 / (2 * energy) ) (0 if energy == 0)
|
||||
/// WavePM = 100 * EMA(raw, smoothing)
|
||||
/// ```
|
||||
///
|
||||
/// The momentum `m` is normalised by its recent variance (`energy`): a move that
|
||||
/// merely matches its typical energy sits at the baseline
|
||||
/// `100·(1 − e^{−1/2}) ≈ 39.35`, while a momentum *spike* that exceeds recent
|
||||
/// energy drives the reading toward `100`. A flat market (`m = 0`) reads `0`.
|
||||
/// High readings mark a peaking, possibly exhausted move rather than a fresh one.
|
||||
///
|
||||
/// Kase's published `WavePM` is platform-specific; this is Wickra's faithful
|
||||
/// reconstruction of its variance-normalised peak-momentum form. The exact
|
||||
/// constants differ from any single vendor implementation, but the shape — flat
|
||||
/// at zero, a fixed baseline on a steady trend, and saturation on an
|
||||
/// acceleration — matches the indicator's intent.
|
||||
///
|
||||
/// Reference: Cynthia Kase, *Trading with the Odds*, 1996 (Wickra reconstruction).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, WavePm};
|
||||
///
|
||||
/// let mut indicator = WavePm::new(10, 3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WavePm {
|
||||
length: usize,
|
||||
smoothing: usize,
|
||||
closes: VecDeque<f64>,
|
||||
energy_ema: Ema,
|
||||
smooth_ema: Ema,
|
||||
}
|
||||
|
||||
impl WavePm {
|
||||
/// Construct a Wave PM with the momentum `length` and the output `smoothing`
|
||||
/// period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `length == 0` or `smoothing == 0`.
|
||||
pub fn new(length: usize, smoothing: usize) -> Result<Self> {
|
||||
if length == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
length,
|
||||
smoothing,
|
||||
closes: VecDeque::with_capacity(length + 1),
|
||||
energy_ema: Ema::new(length)?,
|
||||
smooth_ema: Ema::new(smoothing)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(length, smoothing)`.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.length, self.smoothing)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for WavePm {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, close: f64) -> Option<f64> {
|
||||
self.closes.push_back(close);
|
||||
if self.closes.len() > self.length + 1 {
|
||||
self.closes.pop_front();
|
||||
}
|
||||
if self.closes.len() <= self.length {
|
||||
return None;
|
||||
}
|
||||
|
||||
let oldest = *self.closes.front().unwrap_or(&close);
|
||||
let momentum = close - oldest;
|
||||
let energy = self.energy_ema.update(momentum * momentum)?;
|
||||
let raw = if energy <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
1.0 - (-(momentum * momentum) / (2.0 * energy)).exp()
|
||||
};
|
||||
self.smooth_ema.update(raw).map(|v| v * 100.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.closes.clear();
|
||||
self.energy_ema.reset();
|
||||
self.smooth_ema.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2 * self.length + self.smoothing - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.smooth_ema.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"WavePm"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(WavePm::new(0, 3), Err(Error::PeriodZero)));
|
||||
assert!(matches!(WavePm::new(10, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let w = WavePm::new(10, 3).unwrap();
|
||||
assert_eq!(w.periods(), (10, 3));
|
||||
// 2*10 + 3 - 1 = 22.
|
||||
assert_eq!(w.warmup_period(), 22);
|
||||
assert_eq!(w.name(), "WavePm");
|
||||
assert!(!w.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_at_expected_bar() {
|
||||
let mut w = WavePm::new(3, 2).unwrap();
|
||||
// warmup = 2*3 + 2 - 1 = 7 -> first value at input 7 (index 6).
|
||||
let inputs: Vec<f64> = (0..12).map(f64::from).collect();
|
||||
let out = w.batch(&inputs);
|
||||
assert!(out[5].is_none());
|
||||
assert!(out[6].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_reads_zero() {
|
||||
let mut w = WavePm::new(4, 2).unwrap();
|
||||
let inputs = [50.0; 20];
|
||||
let last = w.batch(&inputs).last().unwrap().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steady_trend_reads_baseline() {
|
||||
// Constant-slope ramp: momentum equals its own energy every bar, so the
|
||||
// reading pins to the baseline 100*(1 - e^-0.5).
|
||||
let mut w = WavePm::new(10, 3).unwrap();
|
||||
let inputs: Vec<f64> = (0..60).map(|i| f64::from(i) * 5.0).collect();
|
||||
let last = w.batch(&inputs).last().unwrap().unwrap();
|
||||
let baseline = 100.0 * (1.0 - (-0.5_f64).exp());
|
||||
assert_relative_eq!(last, baseline, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acceleration_reads_above_baseline() {
|
||||
// A quadratic path: momentum keeps outrunning its lagged energy, so the
|
||||
// reading sits above the steady-trend baseline.
|
||||
let mut w = WavePm::new(10, 3).unwrap();
|
||||
let inputs: Vec<f64> = (0..60).map(|i| f64::from(i * i) * 0.1).collect();
|
||||
let last = w.batch(&inputs).last().unwrap().unwrap();
|
||||
let baseline = 100.0 * (1.0 - (-0.5_f64).exp());
|
||||
assert!(
|
||||
last > baseline,
|
||||
"accelerating wpm {last} should exceed {baseline}"
|
||||
);
|
||||
assert!(last <= 100.0, "wpm {last} must stay <= 100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut w = WavePm::new(10, 3).unwrap();
|
||||
let inputs: Vec<f64> = (0..60).map(|i| f64::from(i) * 5.0).collect();
|
||||
w.batch(&inputs);
|
||||
assert!(w.is_ready());
|
||||
w.reset();
|
||||
assert!(!w.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let inputs: Vec<f64> = (0..80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = WavePm::new(10, 3).unwrap();
|
||||
let mut b = WavePm::new(10, 3).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&inputs),
|
||||
inputs.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -72,70 +72,73 @@ pub use indicators::{
|
||||
ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock, Counterattack, Crab,
|
||||
CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle, CyberneticCycle, Cypher,
|
||||
DayOfWeekProfile, DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex,
|
||||
DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, DistanceSsd, Doji, DojiStar,
|
||||
Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger,
|
||||
DoubleBollingerOutput, DoubleTopBottom, DownsideGapThreeMethods, Dpo, DragonflyDoji,
|
||||
DrawdownDuration, Dx, EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma, ElderImpulse,
|
||||
Ema, EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, Expectancy,
|
||||
DemarkPivots, DemarkPivotsOutput, DepthSlope, DerivativeOscillator, DetrendedStdDev,
|
||||
DisparityIndex, DistanceSsd, Doji, DojiStar, Donchian, DonchianOutput, DonchianStop,
|
||||
DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, DoubleTopBottom,
|
||||
DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, Dx, DynamicMomentumIndex,
|
||||
EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma, ElderImpulse, ElderRay,
|
||||
ElderRayOutput, Ema, EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, Expectancy,
|
||||
FallingThreeMethods, Fama, FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence,
|
||||
FibConfluenceOutput, FibExtension, FibExtensionOutput, FibFan, FibFanOutput, FibProjection,
|
||||
FibProjectionOutput, FibRetracement, FibRetracementOutput, FibTimeZones, FibTimeZonesOutput,
|
||||
FibonacciPivots, FibonacciPivotsOutput, FisherTransform, FlagPennant, Footprint,
|
||||
FibonacciPivots, FibonacciPivotsOutput, FisherRsi, FisherTransform, FlagPennant, Footprint,
|
||||
FootprintOutput, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis,
|
||||
FundingRate, FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite,
|
||||
GarmanKlassVolatility, Gartley, GeneralizedDema, GeometricMa, GoldenPocket, GoldenPocketOutput,
|
||||
GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi,
|
||||
HeikinAshiOutput, HiLoActivator, HighLowIndex, HighLowRange, HighWave, Hikkake,
|
||||
HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HomingPigeon,
|
||||
HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel, HurstChannelOutput,
|
||||
HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck, Inertia,
|
||||
InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
|
||||
IntradayVolatilityProfile, IntradayVolatilityProfileOutput, InverseFisherTransform,
|
||||
InvertedHammer, Jma, JumpIndicator, KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama,
|
||||
KellyCriterion, Keltner, KeltnerOutput, Kicking, KickingByLength, Kst, KstOutput, Kurtosis,
|
||||
Kvo, KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation,
|
||||
LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput,
|
||||
LinRegIntercept, LinRegSlope, LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput,
|
||||
LogReturn, LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt,
|
||||
MacdFix, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu,
|
||||
MassIndex, MatHold, MatchingLow, MaxDrawdown, McClellanOscillator, McClellanSummationIndex,
|
||||
McGinleyDynamic, MedianAbsoluteDeviation, MedianMa, MedianPrice, Mfi, Microprice, MidPoint,
|
||||
MidPrice, MinusDi, MinusDm, Mom, MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows,
|
||||
Nvi, OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta,
|
||||
OpeningMarubozu, OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull,
|
||||
OrderBookImbalanceTop1, OrderBookImbalanceTopN, OrderFlowImbalance, OuHalfLife, OvernightGap,
|
||||
OvernightIntradayReturn, OvernightIntradayReturnOutput, PainIndex, PairSpreadZScore,
|
||||
PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa, PercentB,
|
||||
PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo, PointAndFigureBars, Ppo,
|
||||
ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, RealizedSpread, RealizedVolatility,
|
||||
GarmanKlassVolatility, Gartley, GatorOscillator, GatorOscillatorOutput, GeneralizedDema,
|
||||
GeometricMa, GoldenPocket, GoldenPocketOutput, GrangerCausality, GravestoneDoji, Hammer,
|
||||
HangingMan, Harami, HeadAndShoulders, HeikinAshi, HeikinAshiOutput, HiLoActivator,
|
||||
HighLowIndex, HighLowRange, HighWave, Hikkake, HikkakeModified, HilbertDominantCycle,
|
||||
HistoricalVolatility, Hma, HoltWinters, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput,
|
||||
HtTrendMode, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput,
|
||||
IdenticalThreeCrows, InNeck, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput,
|
||||
InstantaneousTrendline, IntradayMomentumIndex, IntradayVolatilityProfile,
|
||||
IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, Jma, JumpIndicator,
|
||||
KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KasePermissionStochastic,
|
||||
KasePermissionStochasticOutput, KellyCriterion, Keltner, KeltnerOutput, Kicking,
|
||||
KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LadderBottom, LaguerreRsi,
|
||||
LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel,
|
||||
LinRegChannelOutput, LinRegIntercept, LinRegSlope, LinearRegression, LiquidationFeatures,
|
||||
LiquidationFeaturesOutput, LogReturn, LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope,
|
||||
MaEnvelopeOutput, MacdExt, MacdFix, MacdHistogram, MacdIndicator, MacdOutput, Mama, MamaOutput,
|
||||
MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown,
|
||||
McClellanOscillator, McClellanSummationIndex, McGinleyDynamic, MedianAbsoluteDeviation,
|
||||
MedianMa, MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi, MinusDm, Mom,
|
||||
MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nvi, OIPriceDivergence, OIWeighted,
|
||||
Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu, OpeningRange, OpeningRangeOutput,
|
||||
OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, OrderFlowImbalance,
|
||||
OuHalfLife, OvernightGap, OvernightIntradayReturn, OvernightIntradayReturnOutput, PainIndex,
|
||||
PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa,
|
||||
PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo,
|
||||
PointAndFigureBars, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Psar, Pvi,
|
||||
Qqe, QqeOutput, Qstick, QuotedSpread, RSquared, RealizedSpread, RealizedVolatility,
|
||||
RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB, RelativeStrengthOutput,
|
||||
RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Roc, Rocp, Rocr, Rocr100,
|
||||
RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi, Roc, Rocp, Rocr, Rocr100,
|
||||
RogersSatchellVolatility, RollMeasure, RollingCorrelation, RollingCovariance, RollingIqr,
|
||||
RollingPercentileRank, RollingQuantile, RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility,
|
||||
Rwi, RwiOutput, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionHighLowOutput,
|
||||
SessionRange, SessionRangeOutput, SessionVwap, Shark, SharpeRatio, ShootingStar, ShortLine,
|
||||
SignedVolume, SineWave, SineWeightedMa, Skewness, Sma, Smi, Smma, SortinoRatio,
|
||||
SpearmanCorrelation, SpinningTop, SpreadAr1Coefficient, SpreadBollingerBands,
|
||||
RollingPercentileRank, RollingQuantile, RollingVwap, RoofingFilter, Rsi, Rsx, Rvi,
|
||||
RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow,
|
||||
SessionHighLowOutput, SessionRange, SessionRangeOutput, SessionVwap, Shark, SharpeRatio,
|
||||
ShootingStar, ShortLine, SignedVolume, SineWave, SineWeightedMa, Skewness, Sma, Smi, Smma,
|
||||
SortinoRatio, SpearmanCorrelation, SpinningTop, SpreadAr1Coefficient, SpreadBollingerBands,
|
||||
SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError, StandardErrorBands,
|
||||
StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop,
|
||||
StickSandwich, StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend,
|
||||
SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker,
|
||||
TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
|
||||
StickSandwich, StochRsi, Stochastic, StochasticCci, StochasticOutput, SuperSmoother,
|
||||
SuperTrend, SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown,
|
||||
TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
|
||||
TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential,
|
||||
TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeDrives, ThreeInside,
|
||||
ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex,
|
||||
Tii, TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
|
||||
TradeImbalance, TrendLabel, TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Trix,
|
||||
TrueRange, Tsf, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TurnOfMonth, Tweezer, TwoCrows,
|
||||
TypicalPrice, UlcerIndex, UltimateOscillator, UniqueThreeRiver, UpDownVolumeRatio,
|
||||
UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance,
|
||||
VarianceRatio, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeByTimeProfile,
|
||||
VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend, VolumeProfile,
|
||||
VolumeProfileOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands, VwapStdDevBandsOutput,
|
||||
Vwma, Vzo, WaveTrend, WaveTrendOutput, Wedge, WeightedClose, WickRatio, WilliamsFractals,
|
||||
WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots, WoodiePivotsOutput,
|
||||
YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput,
|
||||
Zlema, FAMILIES, T3,
|
||||
TradeImbalance, TrendLabel, TrendStrengthIndex, TreynorRatio, Triangle, Trima, Trin,
|
||||
TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput,
|
||||
TtmTrend, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator,
|
||||
UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea,
|
||||
ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya,
|
||||
VoltyStop, VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend,
|
||||
VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
|
||||
VwapStdDevBandsOutput, Vwma, Vzo, WavePm, WaveTrend, WaveTrendOutput, Wedge, WeightedClose,
|
||||
WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots,
|
||||
WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput,
|
||||
ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
|
||||
};
|
||||
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
|
||||
// line so the indicator-count tooling (which scans the braced block above and
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ That includes:
|
||||
[Python](https://docs.wickra.org/Quickstart-Python),
|
||||
[Node](https://docs.wickra.org/Quickstart-Node), and
|
||||
[WASM](https://docs.wickra.org/Quickstart-WASM).
|
||||
- A per-indicator deep dive for every one of the **403 indicators** across
|
||||
- A per-indicator deep dive for every one of the **423 indicators** across
|
||||
the sixteen families (Moving Averages, Momentum Oscillators, Trend &
|
||||
Directional, Price Oscillators, Volatility & Bands, Bands & Channels,
|
||||
Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots &
|
||||
|
||||
Generated
+7
-7
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"../../bindings/node": {
|
||||
"name": "wickra",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.9",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
@@ -26,12 +26,12 @@
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-darwin-arm64": "0.5.5",
|
||||
"wickra-darwin-x64": "0.5.5",
|
||||
"wickra-linux-arm64-gnu": "0.5.5",
|
||||
"wickra-linux-x64-gnu": "0.5.5",
|
||||
"wickra-win32-arm64-msvc": "0.5.5",
|
||||
"wickra-win32-x64-msvc": "0.5.5"
|
||||
"wickra-darwin-arm64": "0.5.9",
|
||||
"wickra-darwin-x64": "0.5.9",
|
||||
"wickra-linux-arm64-gnu": "0.5.9",
|
||||
"wickra-linux-x64-gnu": "0.5.9",
|
||||
"wickra-win32-arm64-msvc": "0.5.9",
|
||||
"wickra-win32-x64-msvc": "0.5.9"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! `Ema(20)`. This target now covers every scalar indicator in the catalogue.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use wickra_core::{AdaptiveCycle, AdaptiveLaguerreFilter, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BollingerBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DetrendedStdDev, DoubleBollinger, Dpo, DrawdownDuration, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, Expectancy, Fama, FisherTransform, Frama, GainLossRatio, GeneralizedDema, GeometricMa, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, JumpIndicator, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, MaEnvelope, MaType, MacdExt, MacdFix, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, Ppo, ProfitFactor, RSquared, RealizedVolatility, RecoveryFactor, RegimeLabel, RenkoTrailingStop, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, RviVolatility, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, Trima, Trix, Tsf, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3};
|
||||
use wickra_core::{AdaptiveCycle, AdaptiveLaguerreFilter, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BollingerBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DerivativeOscillator, DetrendedStdDev, DisparityIndex, DoubleBollinger, Dpo, DrawdownDuration, DynamicMomentumIndex, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, Expectancy, Fama, FisherRsi, FisherTransform, Frama, GainLossRatio, GeneralizedDema, GeometricMa, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, JumpIndicator, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, MaEnvelope, MaType, MacdExt, MacdFix, MacdHistogram, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Qqe, RSquared, RealizedVolatility, RecoveryFactor, RegimeLabel, RenkoTrailingStop, Rmi, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, Rsx, RviVolatility, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, TrendStrengthIndex, Trima, Trix, Tsf, TsfOscillator, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, WavePm, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3};
|
||||
|
||||
/// Drive a single streaming + batch run through one scalar indicator. Marked
|
||||
/// `#[inline(never)]` so a panic backtrace pin-points the specific indicator.
|
||||
@@ -67,6 +67,15 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
drive(|| T3::new(14, 0.7).unwrap(), &data);
|
||||
drive(|| Mom::new(14).unwrap(), &data);
|
||||
drive(|| Cmo::new(14).unwrap(), &data);
|
||||
drive(|| DisparityIndex::new(14).unwrap(), &data);
|
||||
drive(|| FisherRsi::new(14).unwrap(), &data);
|
||||
drive(|| Rsx::new(14).unwrap(), &data);
|
||||
drive(|| DynamicMomentumIndex::new(14).unwrap(), &data);
|
||||
drive(|| Rmi::new(14, 5).unwrap(), &data);
|
||||
drive(|| DerivativeOscillator::new(14, 5, 3, 9).unwrap(), &data);
|
||||
drive(|| TrendStrengthIndex::new(20).unwrap(), &data);
|
||||
drive(|| PolarizedFractalEfficiency::new(10, 5).unwrap(), &data);
|
||||
drive(|| WavePm::new(32, 3).unwrap(), &data);
|
||||
drive(|| Tsi::new(25, 13).unwrap(), &data);
|
||||
drive(|| Pmo::new(35, 20).unwrap(), &data);
|
||||
drive(|| Tii::new(60, 30).unwrap(), &data);
|
||||
@@ -75,6 +84,9 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
drive(|| Ppo::new(12, 26).unwrap(), &data);
|
||||
drive(|| Apo::new(12, 26).unwrap(), &data);
|
||||
drive(|| Cfo::new(14).unwrap(), &data);
|
||||
drive(|| TsfOscillator::new(14).unwrap(), &data);
|
||||
drive(|| MacdHistogram::new(12, 26, 9).unwrap(), &data);
|
||||
drive(|| PpoHistogram::new(12, 26, 9).unwrap(), &data);
|
||||
drive(|| ElderImpulse::classic(), &data);
|
||||
drive(|| Stc::classic(), &data);
|
||||
drive(|| Coppock::new(14, 11, 10).unwrap(), &data);
|
||||
@@ -123,6 +135,16 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
let _ = Kst::classic().batch(&data);
|
||||
}
|
||||
|
||||
// QQE is scalar-input but emits `QqeOutput`, so it bypasses the generic
|
||||
// `drive` helper. Streaming + batch are still both exercised.
|
||||
{
|
||||
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
|
||||
for &x in &data {
|
||||
let _ = qqe.update(x);
|
||||
}
|
||||
let _ = Qqe::new(14, 5, 4.236).unwrap().batch(&data);
|
||||
}
|
||||
|
||||
// Zero-Lag MACD shares MACD's multi-output topology, so it gets the
|
||||
// same hand-rolled streaming + batch drive as classic MACD below.
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
//! WeightedClose.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayVolatilityProfile, InvertedHammer, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Psar, Pvi, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeOfDayReturnProfile, TpoProfile, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
|
||||
use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, ElderRay, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Psar, Pvi, Qstick, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, StochasticCci, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeOfDayReturnProfile, TpoProfile, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
|
||||
|
||||
/// Convert a flat `f64` stream into a `Vec<Candle>` by chunking it into
|
||||
/// `[open, high, low, close, volume]` groups. Tuples that fail OHLCV
|
||||
@@ -87,6 +87,10 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
drive(|| YoyoExit::new(14, 2.0).unwrap(), &candles);
|
||||
|
||||
// --- Trend & Directional ---
|
||||
drive(|| KasePermissionStochastic::new(9, 3).unwrap(), &candles);
|
||||
drive(|| GatorOscillator::new(13, 8, 5).unwrap(), &candles);
|
||||
drive(|| Qstick::new(10).unwrap(), &candles);
|
||||
drive(|| TtmTrend::new(6).unwrap(), &candles);
|
||||
drive(|| Adx::new(14).unwrap(), &candles);
|
||||
drive(|| Adxr::new(14).unwrap(), &candles);
|
||||
drive(|| PlusDm::new(14).unwrap(), &candles);
|
||||
@@ -105,6 +109,9 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
|
||||
// --- Momentum & Oscillators ---
|
||||
drive(|| Cci::new(20).unwrap(), &candles);
|
||||
drive(|| StochasticCci::new(14).unwrap(), &candles);
|
||||
drive(|| ElderRay::new(13).unwrap(), &candles);
|
||||
drive(|| IntradayMomentumIndex::new(14).unwrap(), &candles);
|
||||
drive(|| Rvi::new(10).unwrap(), &candles);
|
||||
drive(|| Inertia::new(14, 20).unwrap(), &candles);
|
||||
drive(|| Pgo::new(14).unwrap(), &candles);
|
||||
|
||||
Reference in New Issue
Block a user