Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83e34c6f71 | |||
| 67feec598a | |||
| 3dfbc415c5 | |||
| 6b8c6a0e7f | |||
| db186b18d3 | |||
| 654da5722f | |||
| aacb9280f1 | |||
| d2bc000892 | |||
| 1f4bf9e3a6 | |||
| d36d514f56 | |||
| 6e0464930e | |||
| 13bc801f89 |
@@ -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
|
||||
|
||||
@@ -180,14 +180,14 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
cd docs-count
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md overview.md Indicators-Overview.md
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md overview.md Indicators-Overview.md .vitepress/config.ts
|
||||
if git diff --quiet; then
|
||||
echo "Docs indicator count unchanged."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add index.md overview.md Indicators-Overview.md
|
||||
git add index.md overview.md Indicators-Overview.md .vitepress/config.ts
|
||||
git commit -m "chore: sync indicator count to ${n}"
|
||||
if ! git push 2>/dev/null; then
|
||||
echo "::warning::push to wickra-lib/wickra-docs failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
|
||||
|
||||
+55
-1
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.1] - 2026-06-07
|
||||
- **Projection Oscillator** — Widner projection oscillator: close position inside the projection bands, scaled 0..100 (`ProjectionOscillator`).
|
||||
- **Projection Bands** — Widner projection bands: forward-projected high/low regression envelope (`ProjectionBands`).
|
||||
- **Median Channel** — robust median +/- multiplier*MAD envelope (`MedianChannel`).
|
||||
- **Bomar Bands** — adaptive percentage bands containing a target coverage fraction of recent closes (`BomarBands`).
|
||||
- **Quartile Bands** — rolling 25th/50th/75th-percentile (Q1/median/Q3) envelope (`QuartileBands`).
|
||||
|
||||
## [0.6.0] - 2026-06-06
|
||||
- **Volatility Cone** — volatility cone: current realized volatility within its historical min/median/max envelope (`VolatilityCone`).
|
||||
- **VolatilityRatio** — Schwager's volatility ratio: true range over the EMA of prior true ranges (`VolatilityRatio`).
|
||||
- **BipowerVariation** — jump-robust realized bipower variation (pi/2 sum of adjacent absolute log-return products) (`BipowerVariation`).
|
||||
- **VolatilityOfVolatility** — vol-of-vol: sample stddev of a rolling realized-volatility series (`VolatilityOfVolatility`).
|
||||
- **Garch11** — GARCH(1,1) conditional volatility with a long-run-variance anchor (`Garch11`).
|
||||
- **EwmaVolatility** — RiskMetrics exponentially-weighted volatility of log returns (lambda decay) (`EwmaVolatility`).
|
||||
|
||||
## [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`).
|
||||
@@ -1259,7 +1308,12 @@ 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.6...HEAD
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.6.1...HEAD
|
||||
[0.6.1]: https://github.com/wickra-lib/wickra/compare/v0.6.0...v0.6.1
|
||||
[0.6.0]: https://github.com/wickra-lib/wickra/compare/v0.5.9...v0.6.0
|
||||
[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
|
||||
|
||||
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.6"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1876,9 +1953,21 @@ dependencies = [
|
||||
"wickra-data",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wickra-bench"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"kand",
|
||||
"ta",
|
||||
"wickra",
|
||||
"wickra-data",
|
||||
"yata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.5.6"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
@@ -1888,7 +1977,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.5.6"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"csv",
|
||||
@@ -1905,7 +1994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-examples"
|
||||
version = "0.0.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -1915,7 +2004,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-node"
|
||||
version = "0.5.6"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -1925,7 +2014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.5.6"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -1934,7 +2023,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.5.6"
|
||||
version = "0.6.1"
|
||||
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.6"
|
||||
version = "0.6.1"
|
||||
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.6" }
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.6.1" }
|
||||
|
||||
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=413" 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=434" 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 413 indicators; start at the
|
||||
every one of the 434 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**: 434 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
|
||||
|
||||
413 streaming-first indicators across twenty-four families. Every one passes the
|
||||
434 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).
|
||||
@@ -145,10 +197,10 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
|
||||
|--------|-----------|
|
||||
| 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), 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 |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
|
||||
| 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 |
|
||||
| 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, Volatility Cone |
|
||||
| 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, Quartile Bands, Bomar Bands, Median Channel, Projection Bands, Projection Oscillator |
|
||||
| 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 |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands, Spread AR(1) Coefficient |
|
||||
@@ -245,9 +297,10 @@ A Python live-trading example using the public `websockets` package lives at
|
||||
```
|
||||
wickra/
|
||||
├── crates/
|
||||
│ ├── wickra-core/ core engine + all 413 indicators
|
||||
│ ├── wickra-core/ core engine + all 434 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://star-history.com/#wickra-lib/wickra&Date">
|
||||
<img alt="Wickra star history" width="640"
|
||||
src="https://api.star-history.com/svg?repos=wickra-lib/wickra&type=Date&theme=dark">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -28,6 +28,16 @@ function num(v) {
|
||||
// --- Scalar indicators: update(value) vs batch(prices) ---
|
||||
|
||||
const scalarFactories = {
|
||||
BipowerVariation: () => new wickra.BipowerVariation(20),
|
||||
VolatilityOfVolatility: () => new wickra.VolatilityOfVolatility(20, 20),
|
||||
Garch11: () => new wickra.Garch11(0.000002, 0.1, 0.88),
|
||||
EwmaVolatility: () => new wickra.EwmaVolatility(0.94),
|
||||
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),
|
||||
@@ -342,6 +352,10 @@ const candleScalar = {
|
||||
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) },
|
||||
VolatilityRatio: { make: () => new wickra.VolatilityRatio(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
ProjectionOscillator: { make: () => new wickra.ProjectionOscillator(14), 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(candleScalar)) {
|
||||
@@ -426,6 +440,13 @@ const multi = {
|
||||
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) },
|
||||
VolatilityCone: { make: () => new wickra.VolatilityCone(20, 60), fields: ['current', 'min', 'median', 'max', 'percentile'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
QuartileBands: { make: () => new wickra.QuartileBands(4), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
BomarBands: { make: () => new wickra.BomarBands(4, 0.85), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
MedianChannel: { make: () => new wickra.MedianChannel(5, 2.0), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
ProjectionBands: { make: () => new wickra.ProjectionBands(3), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(multi)) {
|
||||
|
||||
Vendored
+228
@@ -5,6 +5,17 @@
|
||||
|
||||
/** Library version (matches the Rust crate version). */
|
||||
export declare function version(): string
|
||||
/**
|
||||
* Volatility-cone result: current realized volatility and its lookback
|
||||
* envelope (min / median / max) plus the percentile rank of `current`.
|
||||
*/
|
||||
export interface VolatilityConeValue {
|
||||
current: number
|
||||
min: number
|
||||
median: number
|
||||
max: number
|
||||
percentile: number
|
||||
}
|
||||
/** Lead/lag result: the offset that maximises correlation, and that correlation. */
|
||||
export interface LeadLagValue {
|
||||
/** Offset that maximises `|corr(a, b shifted)|`. Positive ⇒ `a` leads `b`. */
|
||||
@@ -77,6 +88,14 @@ 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
|
||||
@@ -180,6 +199,26 @@ export interface StandardErrorBandsValue {
|
||||
middle: number
|
||||
lower: number
|
||||
}
|
||||
export interface QuartileBandsValue {
|
||||
upper: number
|
||||
middle: number
|
||||
lower: number
|
||||
}
|
||||
export interface BomarBandsValue {
|
||||
upper: number
|
||||
middle: number
|
||||
lower: number
|
||||
}
|
||||
export interface MedianChannelValue {
|
||||
upper: number
|
||||
middle: number
|
||||
lower: number
|
||||
}
|
||||
export interface ProjectionBandsValue {
|
||||
upper: number
|
||||
middle: number
|
||||
lower: number
|
||||
}
|
||||
export interface DoubleBollingerValue {
|
||||
upperOuter: number
|
||||
upperInner: number
|
||||
@@ -961,6 +1000,69 @@ export declare class DynamicMomentumIndex {
|
||||
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 BipowerVariationNode = BipowerVariation
|
||||
export declare class BipowerVariation {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type EwmaVolatilityNode = EwmaVolatility
|
||||
export declare class EwmaVolatility {
|
||||
constructor(lambda: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type Garch11Node = Garch11
|
||||
export declare class Garch11 {
|
||||
constructor(omega: number, alpha: number, beta: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type VolatilityOfVolatilityNode = VolatilityOfVolatility
|
||||
export declare class VolatilityOfVolatility {
|
||||
constructor(volWindow: number, vovWindow: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type VolatilityConeNode = VolatilityCone
|
||||
export declare class VolatilityCone {
|
||||
constructor(window: number, lookback: number)
|
||||
update(high: number, low: number, close: number): VolatilityConeValue | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type JumpIndicatorNode = JumpIndicator
|
||||
export declare class JumpIndicator {
|
||||
constructor(period: number, threshold: number)
|
||||
@@ -1494,6 +1596,78 @@ export declare class ElderRay {
|
||||
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 VolatilityRatioNode = VolatilityRatio
|
||||
export declare class VolatilityRatio {
|
||||
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 ProjectionOscillatorNode = ProjectionOscillator
|
||||
export declare class ProjectionOscillator {
|
||||
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 StochNode = Stochastic
|
||||
export declare class Stochastic {
|
||||
constructor(kPeriod: number, dPeriod: number)
|
||||
@@ -1838,6 +2012,24 @@ export declare class DerivativeOscillator {
|
||||
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)
|
||||
@@ -2464,6 +2656,42 @@ export declare class StandardErrorBands {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type QuartileBandsNode = QuartileBands
|
||||
export declare class QuartileBands {
|
||||
constructor(period: number)
|
||||
update(value: number): QuartileBandsValue | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type BomarBandsNode = BomarBands
|
||||
export declare class BomarBands {
|
||||
constructor(period: number, coverage: number)
|
||||
update(value: number): BomarBandsValue | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type MedianChannelNode = MedianChannel
|
||||
export declare class MedianChannel {
|
||||
constructor(period: number, multiplier: number)
|
||||
update(value: number): MedianChannelValue | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type ProjectionBandsNode = ProjectionBands
|
||||
export declare class ProjectionBands {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number): ProjectionBandsValue | null
|
||||
batch(high: Array<number>, low: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type DoubleBollingerNode = DoubleBollinger
|
||||
export declare class DoubleBollinger {
|
||||
constructor(period: number, kInner: number, kOuter: number)
|
||||
|
||||
+22
-1
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-arm64",
|
||||
"version": "0.5.6",
|
||||
"version": "0.6.1",
|
||||
"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.6",
|
||||
"version": "0.6.1",
|
||||
"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.6",
|
||||
"version": "0.6.1",
|
||||
"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.6",
|
||||
"version": "0.6.1",
|
||||
"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.6",
|
||||
"version": "0.6.1",
|
||||
"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.6",
|
||||
"version": "0.6.1",
|
||||
"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.6",
|
||||
"version": "0.6.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra",
|
||||
"version": "0.5.6",
|
||||
"version": "0.6.1",
|
||||
"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.6",
|
||||
"wickra-darwin-x64": "0.5.6",
|
||||
"wickra-linux-arm64-gnu": "0.5.6",
|
||||
"wickra-linux-x64-gnu": "0.5.6",
|
||||
"wickra-win32-arm64-msvc": "0.5.6",
|
||||
"wickra-win32-x64-msvc": "0.5.6"
|
||||
"wickra-darwin-arm64": "0.6.1",
|
||||
"wickra-darwin-x64": "0.6.1",
|
||||
"wickra-linux-arm64-gnu": "0.6.1",
|
||||
"wickra-linux-x64-gnu": "0.6.1",
|
||||
"wickra-win32-arm64-msvc": "0.6.1",
|
||||
"wickra-win32-x64-msvc": "0.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/cli": {
|
||||
@@ -41,8 +41,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-arm64": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.6.tgz",
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.6.1.tgz",
|
||||
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -57,8 +57,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-x64": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.6.tgz",
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.6.1.tgz",
|
||||
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -73,8 +73,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-arm64-gnu": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.6.tgz",
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.6.1.tgz",
|
||||
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -89,8 +89,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-x64-gnu": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.6.tgz",
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.6.1.tgz",
|
||||
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -105,8 +105,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-arm64-msvc": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.6.tgz",
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.6.1.tgz",
|
||||
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -121,8 +121,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-x64-msvc": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.6.tgz",
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.6.1.tgz",
|
||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.5.6",
|
||||
"version": "0.6.1",
|
||||
"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.6",
|
||||
"wickra-linux-arm64-gnu": "0.5.6",
|
||||
"wickra-darwin-x64": "0.5.6",
|
||||
"wickra-darwin-arm64": "0.5.6",
|
||||
"wickra-win32-x64-msvc": "0.5.6",
|
||||
"wickra-win32-arm64-msvc": "0.5.6"
|
||||
"wickra-linux-x64-gnu": "0.6.1",
|
||||
"wickra-linux-arm64-gnu": "0.6.1",
|
||||
"wickra-darwin-x64": "0.6.1",
|
||||
"wickra-darwin-arm64": "0.6.1",
|
||||
"wickra-win32-x64-msvc": "0.6.1",
|
||||
"wickra-win32-arm64-msvc": "0.6.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "napi build --platform --release",
|
||||
|
||||
@@ -214,6 +214,205 @@ node_scalar_indicator!(
|
||||
"DynamicMomentumIndex",
|
||||
wc::DynamicMomentumIndex
|
||||
);
|
||||
node_scalar_indicator!(
|
||||
TrendStrengthIndexNode,
|
||||
"TREND_STRENGTH_INDEX",
|
||||
wc::TrendStrengthIndex
|
||||
);
|
||||
node_scalar_indicator!(TsfOscillatorNode, "TsfOscillator", wc::TsfOscillator);
|
||||
node_scalar_indicator!(
|
||||
BipowerVariationNode,
|
||||
"BipowerVariation",
|
||||
wc::BipowerVariation
|
||||
);
|
||||
|
||||
#[napi(js_name = "EwmaVolatility")]
|
||||
pub struct EwmaVolatilityNode {
|
||||
inner: wc::EwmaVolatility,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl EwmaVolatilityNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(lambda: f64) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::EwmaVolatility::new(lambda).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 = "Garch11")]
|
||||
pub struct Garch11Node {
|
||||
inner: wc::Garch11,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl Garch11Node {
|
||||
#[napi(constructor)]
|
||||
pub fn new(omega: f64, alpha: f64, beta: f64) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Garch11::new(omega, alpha, beta).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 = "VolatilityOfVolatility")]
|
||||
pub struct VolatilityOfVolatilityNode {
|
||||
inner: wc::VolatilityOfVolatility,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl VolatilityOfVolatilityNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(vol_window: u32, vov_window: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::VolatilityOfVolatility::new(vol_window as usize, vov_window 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
|
||||
}
|
||||
}
|
||||
|
||||
/// Volatility-cone result: current realized volatility and its lookback
|
||||
/// envelope (min / median / max) plus the percentile rank of `current`.
|
||||
#[napi(object)]
|
||||
pub struct VolatilityConeValue {
|
||||
pub current: f64,
|
||||
pub min: f64,
|
||||
pub median: f64,
|
||||
pub max: f64,
|
||||
pub percentile: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "VolatilityCone")]
|
||||
pub struct VolatilityConeNode {
|
||||
inner: wc::VolatilityCone,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl VolatilityConeNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(window: u32, lookback: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::VolatilityCone::new(window as usize, lookback as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
) -> napi::Result<Option<VolatilityConeValue>> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(cnd(high, low, close, 0.0)?)
|
||||
.map(|o| VolatilityConeValue {
|
||||
current: o.current,
|
||||
min: o.min,
|
||||
median: o.median,
|
||||
max: o.max,
|
||||
percentile: o.percentile,
|
||||
}))
|
||||
}
|
||||
#[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 * 5];
|
||||
for i in 0..n {
|
||||
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
|
||||
out[i * 5] = o.current;
|
||||
out[i * 5 + 1] = o.min;
|
||||
out[i * 5 + 2] = o.median;
|
||||
out[i * 5 + 3] = o.max;
|
||||
out[i * 5 + 4] = o.percentile;
|
||||
}
|
||||
}
|
||||
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 = "JumpIndicator")]
|
||||
pub struct JumpIndicatorNode {
|
||||
inner: wc::JumpIndicator,
|
||||
@@ -2295,6 +2494,434 @@ impl ElderRayNode {
|
||||
}
|
||||
}
|
||||
|
||||
#[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(js_name = "VolatilityRatio")]
|
||||
pub struct VolatilityRatioNode {
|
||||
inner: wc::VolatilityRatio,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl VolatilityRatioNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::VolatilityRatio::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 = "ProjectionOscillator")]
|
||||
pub struct ProjectionOscillatorNode {
|
||||
inner: wc::ProjectionOscillator,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl ProjectionOscillatorNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::ProjectionOscillator::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(object)]
|
||||
pub struct StochValue {
|
||||
pub k: f64,
|
||||
@@ -4206,6 +4833,82 @@ impl DerivativeOscillatorNode {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== 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")]
|
||||
@@ -7879,6 +8582,239 @@ impl StandardErrorBandsNode {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Quartile Bands ----------
|
||||
|
||||
#[napi(object)]
|
||||
pub struct QuartileBandsValue {
|
||||
pub upper: f64,
|
||||
pub middle: f64,
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "QuartileBands")]
|
||||
pub struct QuartileBandsNode {
|
||||
inner: wc::QuartileBands,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl QuartileBandsNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::QuartileBands::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<QuartileBandsValue> {
|
||||
self.inner.update(value).map(|o| QuartileBandsValue {
|
||||
upper: o.upper,
|
||||
middle: o.middle,
|
||||
lower: o.lower,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
let mut out = vec![f64::NAN; prices.len() * 3];
|
||||
for (i, p) in prices.iter().enumerate() {
|
||||
if let Some(o) = self.inner.update(*p) {
|
||||
out[i * 3] = o.upper;
|
||||
out[i * 3 + 1] = o.middle;
|
||||
out[i * 3 + 2] = o.lower;
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Bomar Bands ----------
|
||||
|
||||
#[napi(object)]
|
||||
pub struct BomarBandsValue {
|
||||
pub upper: f64,
|
||||
pub middle: f64,
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "BomarBands")]
|
||||
pub struct BomarBandsNode {
|
||||
inner: wc::BomarBands,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl BomarBandsNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, coverage: f64) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::BomarBands::new(period as usize, coverage).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<BomarBandsValue> {
|
||||
self.inner.update(value).map(|o| BomarBandsValue {
|
||||
upper: o.upper,
|
||||
middle: o.middle,
|
||||
lower: o.lower,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
let mut out = vec![f64::NAN; prices.len() * 3];
|
||||
for (i, p) in prices.iter().enumerate() {
|
||||
if let Some(o) = self.inner.update(*p) {
|
||||
out[i * 3] = o.upper;
|
||||
out[i * 3 + 1] = o.middle;
|
||||
out[i * 3 + 2] = o.lower;
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Median Channel ----------
|
||||
|
||||
#[napi(object)]
|
||||
pub struct MedianChannelValue {
|
||||
pub upper: f64,
|
||||
pub middle: f64,
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "MedianChannel")]
|
||||
pub struct MedianChannelNode {
|
||||
inner: wc::MedianChannel,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl MedianChannelNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, multiplier: f64) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::MedianChannel::new(period as usize, multiplier).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<MedianChannelValue> {
|
||||
self.inner.update(value).map(|o| MedianChannelValue {
|
||||
upper: o.upper,
|
||||
middle: o.middle,
|
||||
lower: o.lower,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
let mut out = vec![f64::NAN; prices.len() * 3];
|
||||
for (i, p) in prices.iter().enumerate() {
|
||||
if let Some(o) = self.inner.update(*p) {
|
||||
out[i * 3] = o.upper;
|
||||
out[i * 3 + 1] = o.middle;
|
||||
out[i * 3 + 2] = o.lower;
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Projection Bands ----------
|
||||
|
||||
#[napi(object)]
|
||||
pub struct ProjectionBandsValue {
|
||||
pub upper: f64,
|
||||
pub middle: f64,
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "ProjectionBands")]
|
||||
pub struct ProjectionBandsNode {
|
||||
inner: wc::ProjectionBands,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl ProjectionBandsNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::ProjectionBands::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, high: f64, low: f64) -> napi::Result<Option<ProjectionBandsValue>> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(cnd(high, low, low, 0.0)?)
|
||||
.map(|o| ProjectionBandsValue {
|
||||
upper: o.upper,
|
||||
middle: o.middle,
|
||||
lower: o.lower,
|
||||
}))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, high: Vec<f64>, low: Vec<f64>) -> napi::Result<Vec<f64>> {
|
||||
if high.len() != low.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"high and low must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let n = high.len();
|
||||
let mut out = vec![f64::NAN; n * 3];
|
||||
for i in 0..n {
|
||||
if let Some(o) = self.inner.update(cnd(high[i], low[i], low[i], 0.0)?) {
|
||||
out[i * 3] = o.upper;
|
||||
out[i * 3 + 1] = o.middle;
|
||||
out[i * 3 + 2] = 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
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Double Bollinger ----------
|
||||
|
||||
#[napi(object)]
|
||||
|
||||
@@ -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.6"
|
||||
version = "0.6.1"
|
||||
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,23 @@ from __future__ import annotations
|
||||
|
||||
from ._wickra import (
|
||||
__version__,
|
||||
ProjectionOscillator,
|
||||
VolatilityCone,
|
||||
VolatilityRatio,
|
||||
BipowerVariation,
|
||||
VolatilityOfVolatility,
|
||||
Garch11,
|
||||
EwmaVolatility,
|
||||
PpoHistogram,
|
||||
MacdHistogram,
|
||||
TsfOscillator,
|
||||
Qstick,
|
||||
GatorOscillator,
|
||||
KasePermissionStochastic,
|
||||
WAVE_PM,
|
||||
POLARIZED_FRACTAL_EFFICIENCY,
|
||||
TREND_STRENGTH_INDEX,
|
||||
TTM_TREND,
|
||||
QQE,
|
||||
IMI,
|
||||
ElderRay,
|
||||
@@ -247,6 +264,10 @@ from ._wickra import (
|
||||
MAMA,
|
||||
FAMA,
|
||||
# Bands & Channels
|
||||
ProjectionBands,
|
||||
MedianChannel,
|
||||
BomarBands,
|
||||
QuartileBands,
|
||||
MaEnvelope,
|
||||
AccelerationBands,
|
||||
StarcBands,
|
||||
@@ -466,6 +487,23 @@ from ._wickra import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ProjectionOscillator",
|
||||
"VolatilityCone",
|
||||
"VolatilityRatio",
|
||||
"BipowerVariation",
|
||||
"VolatilityOfVolatility",
|
||||
"Garch11",
|
||||
"EwmaVolatility",
|
||||
"PpoHistogram",
|
||||
"MacdHistogram",
|
||||
"TsfOscillator",
|
||||
"Qstick",
|
||||
"GatorOscillator",
|
||||
"KasePermissionStochastic",
|
||||
"WAVE_PM",
|
||||
"POLARIZED_FRACTAL_EFFICIENCY",
|
||||
"TREND_STRENGTH_INDEX",
|
||||
"TTM_TREND",
|
||||
"QQE",
|
||||
"IMI",
|
||||
"ElderRay",
|
||||
@@ -689,6 +727,10 @@ __all__ = [
|
||||
"MAMA",
|
||||
"FAMA",
|
||||
# Bands & Channels
|
||||
"ProjectionBands",
|
||||
"MedianChannel",
|
||||
"BomarBands",
|
||||
"QuartileBands",
|
||||
"MaEnvelope",
|
||||
"AccelerationBands",
|
||||
"StarcBands",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,16 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
# --- Scalar (f64 -> f64) indicators ---------------------------------------
|
||||
|
||||
SCALAR = [
|
||||
(ta.BipowerVariation, (20,)),
|
||||
(ta.VolatilityOfVolatility, (20, 20)),
|
||||
(ta.Garch11, (0.000002, 0.1, 0.88)),
|
||||
(ta.EwmaVolatility, (0.94,)),
|
||||
(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,)),
|
||||
@@ -163,6 +173,9 @@ SCALAR = [
|
||||
# Family 05 band/channel indicators with scalar input and multi-output.
|
||||
# `cols` is the expected number of band columns from `batch`.
|
||||
SCALAR_MULTI = {
|
||||
"MedianChannel": (lambda: ta.MedianChannel(5, 2.0), 3),
|
||||
"BomarBands": (lambda: ta.BomarBands(4, 0.85), 3),
|
||||
"QuartileBands": (lambda: ta.QuartileBands(4), 3),
|
||||
"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),
|
||||
@@ -355,6 +368,9 @@ def test_relative_strength_streaming_matches_batch():
|
||||
# 6-tuple candle; the batch helper takes only the columns it needs.
|
||||
|
||||
CANDLE_SCALAR = {
|
||||
"ProjectionOscillator": (lambda: ta.ProjectionOscillator(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
"VolatilityRatio": (lambda: ta.VolatilityRatio(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
"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.
|
||||
@@ -892,6 +908,26 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
|
||||
# --- Candle-input, multi-output indicators --------------------------------
|
||||
|
||||
MULTI = {
|
||||
"ProjectionBands": (
|
||||
lambda: ta.ProjectionBands(3),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
3,
|
||||
),
|
||||
"VolatilityCone": (
|
||||
lambda: ta.VolatilityCone(20, 60),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
5,
|
||||
),
|
||||
"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),
|
||||
@@ -2779,6 +2815,158 @@ def test_imi_reference():
|
||||
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)
|
||||
|
||||
|
||||
def test_ewma_volatility_reference():
|
||||
t = ta.EwmaVolatility(0.94)
|
||||
assert t.update(100.0) is None
|
||||
assert t.update(110.0) == pytest.approx(0.09531017980432493)
|
||||
assert t.update(99.0) == pytest.approx(0.0959428936787596)
|
||||
|
||||
|
||||
def test_garch11_reference():
|
||||
t = ta.Garch11(0.000002, 0.1, 0.88)
|
||||
assert t.update(100.0) is None
|
||||
assert t.update(110.0) == pytest.approx(0.009999999999999995)
|
||||
assert t.update(99.0) == pytest.approx(0.031597516317477786)
|
||||
|
||||
|
||||
def test_volatility_cone_reference():
|
||||
t = ta.VolatilityCone(20, 60)
|
||||
|
||||
|
||||
def test_quartile_bands_reference():
|
||||
t = ta.QuartileBands(4)
|
||||
assert t.update(40.0) is None
|
||||
assert t.update(30.0) is None
|
||||
assert t.update(20.0) is None
|
||||
assert t.update(10.0) == pytest.approx((32.5, 25.0, 17.5))
|
||||
|
||||
|
||||
def test_bomar_bands_reference():
|
||||
t = ta.BomarBands(4, 0.85)
|
||||
assert t.update(100.0) is None
|
||||
assert t.update(102.0) is None
|
||||
assert t.update(98.0) is None
|
||||
assert t.update(104.0) == pytest.approx((104.0, 101.0, 98.0))
|
||||
|
||||
|
||||
def test_median_channel_reference():
|
||||
t = ta.MedianChannel(5, 2.0)
|
||||
assert t.update(1.0) is None
|
||||
assert t.update(2.0) is None
|
||||
assert t.update(3.0) is None
|
||||
assert t.update(4.0) is None
|
||||
assert t.update(5.0) == pytest.approx((5.0, 3.0, 1.0))
|
||||
|
||||
|
||||
def test_projection_bands_reference():
|
||||
t = ta.ProjectionBands(3)
|
||||
assert t.update((8.0, 10.0, 8.0, 9.0, 1.0, 0)) is None
|
||||
assert t.update((9.0, 12.0, 9.0, 11.0, 1.0, 1)) is None
|
||||
assert t.update((10.0, 11.0, 10.0, 11.0, 1.0, 2)) == pytest.approx((12.5, 11.25, 10.0))
|
||||
|
||||
|
||||
def test_projection_oscillator_reference():
|
||||
# Same window as ProjectionBands: upper 12.5, lower 10; close 11 -> 40.
|
||||
t = ta.ProjectionOscillator(3)
|
||||
assert t.update((8.0, 10.0, 8.0, 9.0, 1.0, 0)) is None
|
||||
assert t.update((9.0, 12.0, 9.0, 11.0, 1.0, 1)) is None
|
||||
assert t.update((10.0, 11.0, 10.0, 11.0, 1.0, 2)) == pytest.approx(40.0)
|
||||
|
||||
# --- 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);
|
||||
@@ -2255,6 +2263,295 @@ impl WasmElderRay {
|
||||
}
|
||||
}
|
||||
|
||||
#[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 = VolatilityRatio)]
|
||||
pub struct WasmVolatilityRatio {
|
||||
inner: wc::VolatilityRatio,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = VolatilityRatio)]
|
||||
impl WasmVolatilityRatio {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmVolatilityRatio, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::VolatilityRatio::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 = ProjectionOscillator)]
|
||||
pub struct WasmProjectionOscillator {
|
||||
inner: wc::ProjectionOscillator,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = ProjectionOscillator)]
|
||||
impl WasmProjectionOscillator {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmProjectionOscillator, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::ProjectionOscillator::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 = Stochastic)]
|
||||
pub struct WasmStoch {
|
||||
inner: wc::Stochastic,
|
||||
@@ -5585,6 +5882,219 @@ impl WasmStandardErrorBands {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Quartile Bands (scalar input, 3 outputs) ----------
|
||||
|
||||
#[wasm_bindgen(js_name = QuartileBands)]
|
||||
pub struct WasmQuartileBands {
|
||||
inner: wc::QuartileBands,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = QuartileBands)]
|
||||
impl WasmQuartileBands {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmQuartileBands, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::QuartileBands::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, value: f64) -> JsValue {
|
||||
match self.inner.update(value) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok();
|
||||
Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok();
|
||||
Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
}
|
||||
}
|
||||
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
|
||||
let n = prices.len();
|
||||
let mut out = vec![f64::NAN; n * 3];
|
||||
for (i, p) in prices.iter().enumerate() {
|
||||
if let Some(o) = self.inner.update(*p) {
|
||||
out[i * 3] = o.upper;
|
||||
out[i * 3 + 1] = o.middle;
|
||||
out[i * 3 + 2] = o.lower;
|
||||
}
|
||||
}
|
||||
Float64Array::from(out.as_slice())
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[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()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Bomar Bands (scalar input, 3 outputs) ----------
|
||||
|
||||
#[wasm_bindgen(js_name = BomarBands)]
|
||||
pub struct WasmBomarBands {
|
||||
inner: wc::BomarBands,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = BomarBands)]
|
||||
impl WasmBomarBands {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize, coverage: f64) -> Result<WasmBomarBands, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::BomarBands::new(period, coverage).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, value: f64) -> JsValue {
|
||||
match self.inner.update(value) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok();
|
||||
Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok();
|
||||
Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
}
|
||||
}
|
||||
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
|
||||
let n = prices.len();
|
||||
let mut out = vec![f64::NAN; n * 3];
|
||||
for (i, p) in prices.iter().enumerate() {
|
||||
if let Some(o) = self.inner.update(*p) {
|
||||
out[i * 3] = o.upper;
|
||||
out[i * 3 + 1] = o.middle;
|
||||
out[i * 3 + 2] = o.lower;
|
||||
}
|
||||
}
|
||||
Float64Array::from(out.as_slice())
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[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()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Median Channel (scalar input, 3 outputs) ----------
|
||||
|
||||
#[wasm_bindgen(js_name = MedianChannel)]
|
||||
pub struct WasmMedianChannel {
|
||||
inner: wc::MedianChannel,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = MedianChannel)]
|
||||
impl WasmMedianChannel {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize, multiplier: f64) -> Result<WasmMedianChannel, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::MedianChannel::new(period, multiplier).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, value: f64) -> JsValue {
|
||||
match self.inner.update(value) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok();
|
||||
Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok();
|
||||
Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
}
|
||||
}
|
||||
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
|
||||
let n = prices.len();
|
||||
let mut out = vec![f64::NAN; n * 3];
|
||||
for (i, p) in prices.iter().enumerate() {
|
||||
if let Some(o) = self.inner.update(*p) {
|
||||
out[i * 3] = o.upper;
|
||||
out[i * 3 + 1] = o.middle;
|
||||
out[i * 3 + 2] = o.lower;
|
||||
}
|
||||
}
|
||||
Float64Array::from(out.as_slice())
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[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()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Projection Bands (high/low input, 3 outputs) ----------
|
||||
|
||||
#[wasm_bindgen(js_name = ProjectionBands)]
|
||||
pub struct WasmProjectionBands {
|
||||
inner: wc::ProjectionBands,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = ProjectionBands)]
|
||||
impl WasmProjectionBands {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmProjectionBands, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::ProjectionBands::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, high: f64, low: f64) -> Result<JsValue, JsError> {
|
||||
let candle = make_candle(high, low, low, 0.0)?;
|
||||
match self.inner.update(candle) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok();
|
||||
Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok();
|
||||
Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok();
|
||||
Ok(obj.into())
|
||||
}
|
||||
None => Ok(JsValue::NULL),
|
||||
}
|
||||
}
|
||||
pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result<Float64Array, JsError> {
|
||||
if high.len() != low.len() {
|
||||
return Err(JsError::new("high and low must be equal length"));
|
||||
}
|
||||
let n = high.len();
|
||||
let mut out = vec![f64::NAN; n * 3];
|
||||
for i in 0..n {
|
||||
let candle = make_candle(high[i], low[i], low[i], 0.0)?;
|
||||
if let Some(o) = self.inner.update(candle) {
|
||||
out[i * 3] = o.upper;
|
||||
out[i * 3 + 1] = o.middle;
|
||||
out[i * 3 + 2] = o.lower;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[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()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Double Bollinger (scalar input, 5 outputs) ----------
|
||||
|
||||
#[wasm_bindgen(js_name = DoubleBollinger)]
|
||||
@@ -10431,6 +10941,80 @@ 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);
|
||||
wasm_scalar_indicator!(WasmBipowerVariation, "BipowerVariation", wc::BipowerVariation, period: usize);
|
||||
wasm_scalar_indicator!(WasmEwmaVolatility, "EwmaVolatility", wc::EwmaVolatility, lambda: f64);
|
||||
wasm_scalar_indicator!(WasmGarch11, "Garch11", wc::Garch11, omega: f64, alpha: f64, beta: f64);
|
||||
wasm_scalar_indicator!(WasmVolatilityOfVolatility, "VolatilityOfVolatility", wc::VolatilityOfVolatility, vol_window: usize, vov_window: usize);
|
||||
|
||||
// --- VolatilityCone: Candle in, struct out (current/min/median/max/percentile) ---
|
||||
|
||||
#[wasm_bindgen(js_name = VolatilityCone)]
|
||||
pub struct WasmVolatilityCone {
|
||||
inner: wc::VolatilityCone,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = VolatilityCone)]
|
||||
impl WasmVolatilityCone {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(window: usize, lookback: usize) -> Result<WasmVolatilityCone, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::VolatilityCone::new(window, lookback).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
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, &"current".into(), &o.current.into()).ok();
|
||||
Reflect::set(&obj, &"min".into(), &o.min.into()).ok();
|
||||
Reflect::set(&obj, &"median".into(), &o.median.into()).ok();
|
||||
Reflect::set(&obj, &"max".into(), &o.max.into()).ok();
|
||||
Reflect::set(&obj, &"percentile".into(), &o.percentile.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
})
|
||||
}
|
||||
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 * 5];
|
||||
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 * 5] = o.current;
|
||||
out[i * 5 + 1] = o.min;
|
||||
out[i * 5 + 2] = o.median;
|
||||
out[i * 5 + 3] = o.max;
|
||||
out[i * 5 + 4] = o.percentile;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[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()
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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 {
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Realized Bipower Variation — a jump-robust quadratic-variation estimator.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Realized Bipower Variation — the sum of *adjacent* absolute log-return
|
||||
/// products over the trailing `period` returns, scaled to estimate integrated
|
||||
/// variance.
|
||||
///
|
||||
/// ```text
|
||||
/// r_t = ln(price_t / price_{t−1})
|
||||
/// BV = (π / 2) · Σ |r_t| · |r_{t−1}| over the window
|
||||
/// ```
|
||||
///
|
||||
/// Bipower variation (Barndorff-Nielsen & Shephard 2004) estimates the same
|
||||
/// integrated variance as [`RealizedVolatility`](crate::RealizedVolatility)'s
|
||||
/// `Σ r²`, but by multiplying *neighbouring* absolute returns rather than
|
||||
/// squaring a single one. A price jump inflates exactly one return; because that
|
||||
/// return appears in a product with its (ordinary) neighbour rather than squared,
|
||||
/// its contribution stays bounded — so `BV` is **robust to jumps** while realized
|
||||
/// variance is not. The constant `π / 2 = μ₁⁻²` (with `μ₁ = E|Z| = √(2/π)` for a
|
||||
/// standard normal) debiases the product of two half-normal magnitudes back to a
|
||||
/// variance scale.
|
||||
///
|
||||
/// The output is on the **variance** scale (the jump-robust counterpart of
|
||||
/// realized *variance*, not volatility); take its square root for a volatility,
|
||||
/// and compare `RV − BV` to isolate the jump contribution. A window of `period`
|
||||
/// returns contributes `period − 1` adjacent products; each `update` is O(1) via
|
||||
/// a running sum.
|
||||
///
|
||||
/// Non-finite and non-positive prices are ignored (the log return would be
|
||||
/// undefined): the tick is dropped, state is left untouched, and the last value
|
||||
/// is returned.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{BipowerVariation, Indicator};
|
||||
///
|
||||
/// let mut indicator = BipowerVariation::new(20).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 BipowerVariation {
|
||||
period: usize,
|
||||
prev_price: Option<f64>,
|
||||
/// Rolling window of the last `period` log returns.
|
||||
window: VecDeque<f64>,
|
||||
/// Running sum of adjacent absolute-return products inside the window.
|
||||
sum_adjacent: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl BipowerVariation {
|
||||
/// Construct a new bipower-variation indicator.
|
||||
///
|
||||
/// `period` is the number of log returns in the rolling window; the estimate
|
||||
/// uses the `period − 1` adjacent products between them.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`, or
|
||||
/// [`Error::InvalidPeriod`] if `period == 1` (an adjacent product needs at
|
||||
/// least two returns).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "bipower variation period must be >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
prev_price: None,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_adjacent: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
/// `μ₁⁻² = π / 2`, the debiasing constant for a product of half-normal returns.
|
||||
const MU1_INV_SQ: f64 = std::f64::consts::FRAC_PI_2;
|
||||
|
||||
impl Indicator for BipowerVariation {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
// Non-finite / non-positive prices are skipped: `ln(input / prev)` is
|
||||
// undefined, so the tick must not enter the return window.
|
||||
if !input.is_finite() || input <= 0.0 {
|
||||
return self.last;
|
||||
}
|
||||
let Some(prev) = self.prev_price else {
|
||||
self.prev_price = Some(input);
|
||||
return None;
|
||||
};
|
||||
self.prev_price = Some(input);
|
||||
// `prev` came from `self.prev_price`, gated by the guard above, so it is
|
||||
// finite and positive — the log return is always well-defined.
|
||||
let r = (input / prev).ln();
|
||||
// The incoming return forms a product with the current last return.
|
||||
if let Some(&back) = self.window.back() {
|
||||
self.sum_adjacent += back.abs() * r.abs();
|
||||
}
|
||||
self.window.push_back(r);
|
||||
if self.window.len() > self.period {
|
||||
let first = self.window.pop_front().expect("window is non-empty");
|
||||
// The product between the dropped return and the new front leaves.
|
||||
let second = *self.window.front().expect("window still has >= 1 element");
|
||||
self.sum_adjacent -= first.abs() * second.abs();
|
||||
}
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
// Products are non-negative; the rolling subtraction can leave a tiny
|
||||
// negative residual when returns are ~0, so clamp before scaling.
|
||||
let bv = MU1_INV_SQ * self.sum_adjacent.max(0.0);
|
||||
self.last = Some(bv);
|
||||
Some(bv)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_price = None;
|
||||
self.window.clear();
|
||||
self.sum_adjacent = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The first log return needs a previous price, then the window fills.
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"BipowerVariation"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(BipowerVariation::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_period_one() {
|
||||
assert!(matches!(
|
||||
BipowerVariation::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let bv = BipowerVariation::new(20).unwrap();
|
||||
assert_eq!(bv.period(), 20);
|
||||
assert_eq!(bv.warmup_period(), 21);
|
||||
assert_eq!(bv.name(), "BipowerVariation");
|
||||
assert!(!bv.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut bv = BipowerVariation::new(5).unwrap();
|
||||
let out = bv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
for v in out.iter().take(5) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[5].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_value() {
|
||||
// period = 2: one adjacent product. r1 = ln(1.1), r2 = ln(0.9).
|
||||
// BV = (π/2)·|r1|·|r2|.
|
||||
let mut bv = BipowerVariation::new(2).unwrap();
|
||||
let out = bv.batch(&[100.0, 110.0, 99.0]);
|
||||
assert!(out[1].is_none());
|
||||
let r1 = (110.0_f64 / 100.0).ln();
|
||||
let r2 = (99.0_f64 / 110.0).ln();
|
||||
let expected = std::f64::consts::FRAC_PI_2 * r1.abs() * r2.abs();
|
||||
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_window_drops_oldest_product() {
|
||||
// period = 2, four prices -> two emissions, each a single product.
|
||||
let mut bv = BipowerVariation::new(2).unwrap();
|
||||
let out = bv.batch(&[100.0, 110.0, 99.0, 105.0]);
|
||||
let r2 = (99.0_f64 / 110.0).ln();
|
||||
let r3 = (105.0_f64 / 99.0).ln();
|
||||
let expected = std::f64::consts::FRAC_PI_2 * r2.abs() * r3.abs();
|
||||
assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
let mut bv = BipowerVariation::new(10).unwrap();
|
||||
for v in bv.batch(&[100.0; 40]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_non_negative() {
|
||||
let mut bv = BipowerVariation::new(20).unwrap();
|
||||
let prices: Vec<f64> = (1..=200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
|
||||
.collect();
|
||||
for v in bv.batch(&prices).into_iter().flatten() {
|
||||
assert!(v >= 0.0, "bipower variation must be non-negative, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut bv = BipowerVariation::new(5).unwrap();
|
||||
let out = bv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
let last = *out.last().unwrap();
|
||||
assert!(last.is_some());
|
||||
assert_eq!(bv.update(f64::NAN), last);
|
||||
assert_eq!(bv.update(f64::INFINITY), last);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_non_positive_prices() {
|
||||
let mut bv = BipowerVariation::new(5).unwrap();
|
||||
let warmup = bv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
let baseline = warmup.last().copied().flatten().expect("warmed up");
|
||||
assert_eq!(bv.update(-5.0), Some(baseline));
|
||||
assert_eq!(bv.update(0.0), Some(baseline));
|
||||
// State untouched: a clone advanced by the same real tick agrees.
|
||||
let mut control = bv.clone();
|
||||
let after = bv.update(21.0).expect("ready");
|
||||
assert_eq!(control.update(21.0).expect("ready"), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut bv = BipowerVariation::new(5).unwrap();
|
||||
bv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(bv.is_ready());
|
||||
bv.reset();
|
||||
assert!(!bv.is_ready());
|
||||
assert_eq!(bv.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = BipowerVariation::new(20).unwrap().batch(&prices);
|
||||
let mut b = BipowerVariation::new(20).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -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,256 @@
|
||||
//! Bomar Bands — adaptive percentage bands that contain a target fraction of
|
||||
//! recent price.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::rolling_quantile::quantile_sorted;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Bomar Bands output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct BomarBandsOutput {
|
||||
/// Upper band: `middle + |middle| · p`.
|
||||
pub upper: f64,
|
||||
/// Middle line: the simple moving average over the window.
|
||||
pub middle: f64,
|
||||
/// Lower band: `middle − |middle| · p`.
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
/// Bomar Bands: percentage bands whose width adapts so that a fixed `coverage`
|
||||
/// fraction of recent closes falls inside them.
|
||||
///
|
||||
/// The Bomar Bands predate Bollinger Bands; John Bollinger cites them as an
|
||||
/// inspiration — percentage bands around a moving average, with the percentage
|
||||
/// tuned so a fixed share (classically ~85%) of price stayed within. Wickra
|
||||
/// realises that idea deterministically: the half-width is the `coverage`
|
||||
/// quantile of the relative deviations from the midline, so by construction
|
||||
/// `coverage` of the window's closes lie inside the bands.
|
||||
///
|
||||
/// ```text
|
||||
/// middle = SMA(close, period)
|
||||
/// dev_i = | close_i / middle − 1 | // relative distance from midline
|
||||
/// p = coverage-quantile of { dev_i } // type-7 interpolation
|
||||
/// upper = middle + |middle| · p
|
||||
/// lower = middle − |middle| · p
|
||||
/// ```
|
||||
///
|
||||
/// Unlike the fixed-percentage [`MaEnvelope`](crate::MaEnvelope), the offset
|
||||
/// here is data-driven: the bands widen in turbulent regimes and tighten in
|
||||
/// quiet ones without a volatility input. Unlike Bollinger Bands, the width is
|
||||
/// an order statistic of the actual deviations rather than a multiple of the
|
||||
/// standard deviation, so it is unaffected by the shape of the tails beyond the
|
||||
/// `coverage` rank. When the midline is zero the relative deviation is
|
||||
/// undefined and the bands collapse onto the midline.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{BomarBands, Indicator};
|
||||
///
|
||||
/// let mut indicator = BomarBands::new(20, 0.85).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(100.0 + f64::from(i % 7));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BomarBands {
|
||||
period: usize,
|
||||
coverage: f64,
|
||||
window: VecDeque<f64>,
|
||||
scratch: Vec<f64>,
|
||||
}
|
||||
|
||||
impl BomarBands {
|
||||
/// Construct new Bomar Bands.
|
||||
///
|
||||
/// `coverage` is the target fraction of closes to contain, in `(0.0, 1.0]`.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`, or
|
||||
/// [`Error::InvalidParameter`] if `coverage` is not a finite value in
|
||||
/// `(0.0, 1.0]`.
|
||||
pub fn new(period: usize, coverage: f64) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if !coverage.is_finite() || coverage <= 0.0 || coverage > 1.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "bomar bands coverage must be a finite value in (0.0, 1.0]",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
coverage,
|
||||
window: VecDeque::with_capacity(period),
|
||||
scratch: Vec::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured coverage fraction.
|
||||
pub const fn coverage(&self) -> f64 {
|
||||
self.coverage
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for BomarBands {
|
||||
type Input = f64;
|
||||
type Output = BomarBandsOutput;
|
||||
|
||||
fn update(&mut self, value: f64) -> Option<BomarBandsOutput> {
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(value);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let sum: f64 = self.window.iter().sum();
|
||||
let middle = sum / (self.period as f64);
|
||||
let denom = middle.abs();
|
||||
|
||||
self.scratch.clear();
|
||||
for &v in &self.window {
|
||||
let dev = if denom == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
((v - middle) / denom).abs()
|
||||
};
|
||||
self.scratch.push(dev);
|
||||
}
|
||||
self.scratch.sort_by(f64::total_cmp);
|
||||
let p = quantile_sorted(&self.scratch, self.coverage);
|
||||
let offset = denom * p;
|
||||
|
||||
Some(BomarBandsOutput {
|
||||
upper: middle + offset,
|
||||
middle,
|
||||
lower: middle - offset,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.scratch.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"BomarBands"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(BomarBands::new(0, 0.85), Err(Error::PeriodZero)));
|
||||
assert!(BomarBands::new(1, 0.85).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_out_of_range_coverage() {
|
||||
assert!(matches!(
|
||||
BomarBands::new(20, 0.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
BomarBands::new(20, 1.1),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
BomarBands::new(20, -0.5),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
BomarBands::new(20, f64::NAN),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let bb = BomarBands::new(20, 0.85).unwrap();
|
||||
assert_eq!(bb.period(), 20);
|
||||
assert_relative_eq!(bb.coverage(), 0.85, epsilon = 1e-12);
|
||||
assert_eq!(bb.warmup_period(), 20);
|
||||
assert_eq!(bb.name(), "BomarBands");
|
||||
assert!(!bb.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warms_up_then_emits() {
|
||||
let mut bb = BomarBands::new(4, 0.85).unwrap();
|
||||
assert!(bb.update(100.0).is_none());
|
||||
assert!(bb.update(102.0).is_none());
|
||||
assert!(bb.update(98.0).is_none());
|
||||
assert!(bb.update(104.0).is_some());
|
||||
assert!(bb.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_bands() {
|
||||
// mean=101; |dev| = {1,1,3,3}/101; coverage 0.85 quantile -> 3/101.
|
||||
// offset = 101 * 3/101 = 3 -> upper 104, lower 98.
|
||||
let mut bb = BomarBands::new(4, 0.85).unwrap();
|
||||
let out = bb.batch(&[100.0, 102.0, 98.0, 104.0]);
|
||||
let last = out[3].unwrap();
|
||||
assert_relative_eq!(last.middle, 101.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.upper, 104.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.lower, 98.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_midline_collapses_bands() {
|
||||
// Window mean exactly zero -> relative deviation undefined -> collapse.
|
||||
let mut bb = BomarBands::new(2, 0.85).unwrap();
|
||||
let out = bb.batch(&[3.0, -3.0]);
|
||||
let last = out[1].unwrap();
|
||||
assert_relative_eq!(last.middle, 0.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.upper, 0.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.lower, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_window_evicts_oldest() {
|
||||
// Eight values through a period-4 window: only the last four survive,
|
||||
// reproducing the `known_bands` window.
|
||||
let mut bb = BomarBands::new(4, 0.85).unwrap();
|
||||
let out = bb.batch(&[50.0, 50.0, 50.0, 50.0, 100.0, 102.0, 98.0, 104.0]);
|
||||
let last = out[7].unwrap();
|
||||
assert_relative_eq!(last.middle, 101.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.upper, 104.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.lower, 98.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut bb = BomarBands::new(4, 0.85).unwrap();
|
||||
for v in [100.0, 102.0, 98.0, 104.0] {
|
||||
bb.update(v);
|
||||
}
|
||||
assert!(bb.is_ready());
|
||||
bb.reset();
|
||||
assert!(!bb.is_ready());
|
||||
assert!(bb.update(100.0).is_none());
|
||||
}
|
||||
}
|
||||
@@ -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,264 @@
|
||||
//! EWMA Volatility — `RiskMetrics` exponentially-weighted volatility.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// EWMA Volatility — the `RiskMetrics` exponentially-weighted estimate of the
|
||||
/// volatility of log returns.
|
||||
///
|
||||
/// ```text
|
||||
/// r_t = ln(price_t / price_{t−1})
|
||||
/// σ²_t = λ · σ²_{t−1} + (1 − λ) · r²_t
|
||||
/// EWMA = √σ²_t
|
||||
/// ```
|
||||
///
|
||||
/// Unlike [`HistoricalVolatility`](crate::HistoricalVolatility) — an equally
|
||||
/// weighted, mean-centred sample standard deviation over a fixed window — the
|
||||
/// EWMA estimator weights recent squared returns geometrically by the decay
|
||||
/// factor `λ`. The most recent return carries weight `1 − λ`, the one before it
|
||||
/// `λ(1 − λ)`, and so on, so the estimate reacts to a volatility shock
|
||||
/// immediately and then forgets it at rate `λ`. This is the J.P. Morgan
|
||||
/// `RiskMetrics` one-parameter model; the standard daily decay is `λ = 0.94`
|
||||
/// (monthly `0.97`). No mean is subtracted: squared returns *are* the variance
|
||||
/// contribution, which matches the `RiskMetrics` assumption of a zero conditional
|
||||
/// mean over short horizons.
|
||||
///
|
||||
/// The recursion is seeded with the first squared return (`σ²₁ = r²₁`) and emits
|
||||
/// from the first return onward, so the very first reading is a one-observation
|
||||
/// estimate that the decay then refines. Each `update` is O(1).
|
||||
///
|
||||
/// Non-finite and non-positive prices are ignored (the log return would be
|
||||
/// undefined): the tick is dropped, state is left untouched, and the last value
|
||||
/// is returned.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{EwmaVolatility, Indicator};
|
||||
///
|
||||
/// let mut indicator = EwmaVolatility::new(0.94).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 EwmaVolatility {
|
||||
lambda: f64,
|
||||
prev_price: Option<f64>,
|
||||
/// Exponentially-weighted variance of log returns; `None` until seeded.
|
||||
variance: Option<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl EwmaVolatility {
|
||||
/// Construct a new EWMA-volatility indicator.
|
||||
///
|
||||
/// `lambda` is the decay factor, strictly between `0` and `1` (`RiskMetrics`
|
||||
/// uses `0.94` for daily data). Larger `lambda` means a longer memory and a
|
||||
/// smoother estimate.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidParameter`] if `lambda` is not finite or not in
|
||||
/// the open interval `(0, 1)`.
|
||||
pub fn new(lambda: f64) -> Result<Self> {
|
||||
if !lambda.is_finite() || lambda <= 0.0 || lambda >= 1.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "EWMA volatility lambda must be in the open interval (0, 1)",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
lambda,
|
||||
prev_price: None,
|
||||
variance: None,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured decay factor.
|
||||
pub const fn lambda(&self) -> f64 {
|
||||
self.lambda
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for EwmaVolatility {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
// Non-finite / non-positive prices are skipped: `ln(input / prev)` is
|
||||
// undefined, so the tick must not enter the variance recursion.
|
||||
if !input.is_finite() || input <= 0.0 {
|
||||
return self.last;
|
||||
}
|
||||
let Some(prev) = self.prev_price else {
|
||||
self.prev_price = Some(input);
|
||||
return None;
|
||||
};
|
||||
self.prev_price = Some(input);
|
||||
// `prev` came from `self.prev_price`, gated by the guard above, so it is
|
||||
// finite and positive — the log return is always well-defined.
|
||||
let r = (input / prev).ln();
|
||||
let var = match self.variance {
|
||||
// Seed the recursion with the first squared return.
|
||||
None => r * r,
|
||||
Some(prev_var) => self.lambda * prev_var + (1.0 - self.lambda) * r * r,
|
||||
};
|
||||
self.variance = Some(var);
|
||||
// `var` is a convex combination of non-negative terms, but rounding can
|
||||
// leave a tiny negative residual when every return is ~0; clamp first.
|
||||
let vol = var.max(0.0).sqrt();
|
||||
self.last = Some(vol);
|
||||
Some(vol)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_price = None;
|
||||
self.variance = None;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The first log return needs a previous price; the estimate is seeded
|
||||
// and emitted on that first return.
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"EwmaVolatility"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_lambda() {
|
||||
for bad in [0.0, 1.0, -0.5, 1.5, f64::NAN, f64::INFINITY] {
|
||||
assert!(matches!(
|
||||
EwmaVolatility::new(bad),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let ewma = EwmaVolatility::new(0.94).unwrap();
|
||||
assert_relative_eq!(ewma.lambda(), 0.94);
|
||||
assert_eq!(ewma.warmup_period(), 2);
|
||||
assert_eq!(ewma.name(), "EwmaVolatility");
|
||||
assert!(!ewma.is_ready());
|
||||
assert_eq!(ewma.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut ewma = EwmaVolatility::new(0.94).unwrap();
|
||||
assert_eq!(ewma.update(100.0), None);
|
||||
let out = ewma.update(110.0);
|
||||
assert!(out.is_some());
|
||||
assert!(ewma.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_value() {
|
||||
// r1 = ln(110/100), r2 = ln(99/110). Seed σ²₁ = r1²; then
|
||||
// σ²₂ = λ·r1² + (1−λ)·r2².
|
||||
let lambda = 0.94;
|
||||
let mut ewma = EwmaVolatility::new(lambda).unwrap();
|
||||
let out = ewma.batch(&[100.0, 110.0, 99.0]);
|
||||
let r1 = (110.0_f64 / 100.0).ln();
|
||||
let r2 = (99.0_f64 / 110.0).ln();
|
||||
assert_relative_eq!(out[1].unwrap(), r1.abs(), epsilon = 1e-12);
|
||||
let var2 = lambda * r1 * r1 + (1.0 - lambda) * r2 * r2;
|
||||
assert_relative_eq!(out[2].unwrap(), var2.sqrt(), epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
let mut ewma = EwmaVolatility::new(0.9).unwrap();
|
||||
for v in ewma.batch(&[100.0; 40]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_non_negative() {
|
||||
let mut ewma = EwmaVolatility::new(0.94).unwrap();
|
||||
let prices: Vec<f64> = (1..=200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
|
||||
.collect();
|
||||
for v in ewma.batch(&prices).into_iter().flatten() {
|
||||
assert!(v >= 0.0, "EWMA volatility must be non-negative, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut ewma = EwmaVolatility::new(0.94).unwrap();
|
||||
let out = ewma.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
let last = *out.last().unwrap();
|
||||
assert!(last.is_some());
|
||||
assert_eq!(ewma.update(f64::NAN), last);
|
||||
assert_eq!(ewma.update(f64::INFINITY), last);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_non_positive_prices() {
|
||||
let mut ewma = EwmaVolatility::new(0.94).unwrap();
|
||||
let warmup = ewma.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
let baseline = warmup.last().copied().flatten().expect("warmed up");
|
||||
assert_eq!(ewma.update(-5.0), Some(baseline));
|
||||
assert_eq!(ewma.update(0.0), Some(baseline));
|
||||
// State untouched: a clone advanced by the same real tick agrees.
|
||||
let mut control = ewma.clone();
|
||||
let after = ewma.update(21.0).expect("ready");
|
||||
assert_eq!(control.update(21.0).expect("ready"), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_non_positive_before_first_price() {
|
||||
// The skip guard fires before any previous price exists.
|
||||
let mut ewma = EwmaVolatility::new(0.94).unwrap();
|
||||
assert_eq!(ewma.update(0.0), None);
|
||||
assert_eq!(ewma.update(f64::NAN), None);
|
||||
assert_eq!(ewma.update(100.0), None);
|
||||
assert!(ewma.update(110.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut ewma = EwmaVolatility::new(0.94).unwrap();
|
||||
ewma.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(ewma.is_ready());
|
||||
ewma.reset();
|
||||
assert!(!ewma.is_ready());
|
||||
assert_eq!(ewma.value(), None);
|
||||
assert_eq!(ewma.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = EwmaVolatility::new(0.94).unwrap().batch(&prices);
|
||||
let mut b = EwmaVolatility::new(0.94).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
//! GARCH(1,1) — conditional volatility with a long-run-variance anchor.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// GARCH(1,1) conditional volatility — the square root of the
|
||||
/// generalized-autoregressive-conditional-heteroskedasticity variance recursion.
|
||||
///
|
||||
/// ```text
|
||||
/// r_t = ln(price_t / price_{t−1})
|
||||
/// σ²_t = ω + α · r²_{t−1} + β · σ²_{t−1}
|
||||
/// out = √σ²_t
|
||||
/// ```
|
||||
///
|
||||
/// GARCH(1,1) (Bollerslev 1986) generalizes the
|
||||
/// [`EwmaVolatility`](crate::EwmaVolatility) recursion by adding a constant `ω`,
|
||||
/// which pins the process to a finite long-run (unconditional) variance
|
||||
/// `ω / (1 − α − β)`. The `α` term gives weight to the latest squared return
|
||||
/// (the "ARCH" shock) and `β` to the previous variance (the "GARCH"
|
||||
/// persistence). When `ω = 0` and `α + β = 1` the model degenerates to EWMA; a
|
||||
/// proper GARCH keeps `ω > 0` and `α + β < 1` so volatility mean-reverts rather
|
||||
/// than drifting.
|
||||
///
|
||||
/// The recursion is seeded with the unconditional variance (`σ²₁ = ω / (1 − α −
|
||||
/// β)`) and emits from the first log return onward. Unlike EWMA — which decays to
|
||||
/// zero on a flat series — a flat series here mean-reverts toward `ω / (1 − β)`
|
||||
/// (the `α`-term vanishes but the `ω` floor and the `β` carry remain), so the
|
||||
/// output is always strictly positive. Each `update` is O(1).
|
||||
///
|
||||
/// Non-finite and non-positive prices are ignored (the log return would be
|
||||
/// undefined): the tick is dropped, state is left untouched, and the last value
|
||||
/// is returned.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Garch11, Indicator};
|
||||
///
|
||||
/// // Typical equity daily estimate.
|
||||
/// let mut indicator = Garch11::new(0.000_002, 0.10, 0.88).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 Garch11 {
|
||||
omega: f64,
|
||||
alpha: f64,
|
||||
beta: f64,
|
||||
unconditional: f64,
|
||||
prev_price: Option<f64>,
|
||||
/// `(σ²_{t−1}, r²_{t−1})` — previous variance and previous squared return.
|
||||
state: Option<(f64, f64)>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl Garch11 {
|
||||
/// Construct a new GARCH(1,1) indicator from its three parameters.
|
||||
///
|
||||
/// `omega` (`ω`) is the constant variance floor, `alpha` (`α`) the weight on
|
||||
/// the latest squared return, and `beta` (`β`) the persistence of the
|
||||
/// previous variance.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidParameter`] unless every parameter is finite,
|
||||
/// `omega > 0`, `alpha >= 0`, `beta >= 0`, and `alpha + beta < 1` (the
|
||||
/// covariance-stationarity condition that gives a finite long-run variance).
|
||||
pub fn new(omega: f64, alpha: f64, beta: f64) -> Result<Self> {
|
||||
if !omega.is_finite() || !alpha.is_finite() || !beta.is_finite() {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "GARCH(1,1) parameters must be finite",
|
||||
});
|
||||
}
|
||||
if omega <= 0.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "GARCH(1,1) omega must be > 0",
|
||||
});
|
||||
}
|
||||
if alpha < 0.0 || beta < 0.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "GARCH(1,1) alpha and beta must be >= 0",
|
||||
});
|
||||
}
|
||||
if alpha + beta >= 1.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "GARCH(1,1) requires alpha + beta < 1 (covariance stationarity)",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
omega,
|
||||
alpha,
|
||||
beta,
|
||||
unconditional: omega / (1.0 - alpha - beta),
|
||||
prev_price: None,
|
||||
state: None,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(omega, alpha, beta)`.
|
||||
pub const fn params(&self) -> (f64, f64, f64) {
|
||||
(self.omega, self.alpha, self.beta)
|
||||
}
|
||||
|
||||
/// Long-run (unconditional) variance `ω / (1 − α − β)`.
|
||||
pub const fn unconditional_variance(&self) -> f64 {
|
||||
self.unconditional
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Garch11 {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
// Non-finite / non-positive prices are skipped: `ln(input / prev)` is
|
||||
// undefined, so the tick must not enter the variance recursion.
|
||||
if !input.is_finite() || input <= 0.0 {
|
||||
return self.last;
|
||||
}
|
||||
let Some(prev) = self.prev_price else {
|
||||
self.prev_price = Some(input);
|
||||
return None;
|
||||
};
|
||||
self.prev_price = Some(input);
|
||||
// `prev` came from `self.prev_price`, gated by the guard above, so it is
|
||||
// finite and positive — the log return is always well-defined.
|
||||
let r = (input / prev).ln();
|
||||
let r_sq = r * r;
|
||||
let var = match self.state {
|
||||
// Seed the recursion with the unconditional variance.
|
||||
None => self.unconditional,
|
||||
Some((prev_var, prev_r_sq)) => {
|
||||
self.omega + self.alpha * prev_r_sq + self.beta * prev_var
|
||||
}
|
||||
};
|
||||
self.state = Some((var, r_sq));
|
||||
// `var` is `omega (> 0) + non-negative terms`, so it is strictly
|
||||
// positive — the square root is always well-defined.
|
||||
let vol = var.sqrt();
|
||||
self.last = Some(vol);
|
||||
Some(vol)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_price = None;
|
||||
self.state = None;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The first log return needs a previous price; the estimate is seeded
|
||||
// with the unconditional variance and emitted on that first return.
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Garch11"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(matches!(
|
||||
Garch11::new(0.0, 0.1, 0.8),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Garch11::new(-1.0, 0.1, 0.8),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Garch11::new(0.001, -0.1, 0.8),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Garch11::new(0.001, 0.1, -0.8),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Garch11::new(0.001, 0.5, 0.5),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Garch11::new(f64::NAN, 0.1, 0.8),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Garch11::new(0.001, f64::INFINITY, 0.8),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let g = Garch11::new(0.001, 0.1, 0.85).unwrap();
|
||||
assert_eq!(g.params(), (0.001, 0.1, 0.85));
|
||||
assert_relative_eq!(g.unconditional_variance(), 0.001 / 0.05, epsilon = 1e-12);
|
||||
assert_eq!(g.warmup_period(), 2);
|
||||
assert_eq!(g.name(), "Garch11");
|
||||
assert!(!g.is_ready());
|
||||
assert_eq!(g.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_is_unconditional() {
|
||||
// The first log return emits the seed = sqrt(unconditional variance),
|
||||
// independent of the return value.
|
||||
let g = Garch11::new(0.002, 0.1, 0.85);
|
||||
let mut g = g.unwrap();
|
||||
assert_eq!(g.update(100.0), None);
|
||||
let out = g.update(110.0).unwrap();
|
||||
assert_relative_eq!(out, (0.002_f64 / 0.05).sqrt(), epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_value() {
|
||||
// σ²₁ = uncond; σ²₂ = ω + α·r1² + β·uncond.
|
||||
let (omega, alpha, beta) = (0.002, 0.1, 0.85);
|
||||
let mut g = Garch11::new(omega, alpha, beta).unwrap();
|
||||
let out = g.batch(&[100.0, 110.0, 99.0]);
|
||||
let uncond = omega / (1.0 - alpha - beta);
|
||||
let r1 = (110.0_f64 / 100.0).ln();
|
||||
assert_relative_eq!(out[1].unwrap(), uncond.sqrt(), epsilon = 1e-12);
|
||||
let var2 = omega + alpha * r1 * r1 + beta * uncond;
|
||||
assert_relative_eq!(out[2].unwrap(), var2.sqrt(), epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_series_converges_to_long_run() {
|
||||
// With zero returns the alpha term vanishes; the variance mean-reverts
|
||||
// to the fixed point ω / (1 − β), NOT to zero (the key GARCH/EWMA
|
||||
// distinction).
|
||||
let (omega, beta) = (0.002, 0.85);
|
||||
let mut g = Garch11::new(omega, 0.10, beta).unwrap();
|
||||
let out = g.batch(&[100.0; 400]);
|
||||
let fixed_point = (omega / (1.0 - beta)).sqrt();
|
||||
assert_relative_eq!(out.last().unwrap().unwrap(), fixed_point, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_strictly_positive() {
|
||||
let mut g = Garch11::new(0.000_002, 0.1, 0.88).unwrap();
|
||||
let prices: Vec<f64> = (1..=200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
|
||||
.collect();
|
||||
for v in g.batch(&prices).into_iter().flatten() {
|
||||
assert!(
|
||||
v > 0.0,
|
||||
"GARCH volatility must be strictly positive, got {v}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
|
||||
let out = g.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
let last = *out.last().unwrap();
|
||||
assert!(last.is_some());
|
||||
assert_eq!(g.update(f64::NAN), last);
|
||||
assert_eq!(g.update(f64::INFINITY), last);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_non_positive_prices() {
|
||||
let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
|
||||
let warmup = g.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
let baseline = warmup.last().copied().flatten().expect("warmed up");
|
||||
assert_eq!(g.update(-5.0), Some(baseline));
|
||||
assert_eq!(g.update(0.0), Some(baseline));
|
||||
// State untouched: a clone advanced by the same real tick agrees.
|
||||
let mut control = g.clone();
|
||||
let after = g.update(21.0).expect("ready");
|
||||
assert_eq!(control.update(21.0).expect("ready"), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_non_positive_before_first_price() {
|
||||
let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
|
||||
assert_eq!(g.update(0.0), None);
|
||||
assert_eq!(g.update(f64::NAN), None);
|
||||
assert_eq!(g.update(100.0), None);
|
||||
assert!(g.update(110.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
|
||||
g.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(g.is_ready());
|
||||
g.reset();
|
||||
assert!(!g.is_ready());
|
||||
assert_eq!(g.value(), None);
|
||||
assert_eq!(g.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = Garch11::new(0.000_002, 0.1, 0.88).unwrap().batch(&prices);
|
||||
let mut b = Garch11::new(0.000_002, 0.1, 0.88).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Median Channel — a robust median ± MAD envelope.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::rolling_quantile::quantile_sorted;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Median Channel output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct MedianChannelOutput {
|
||||
/// Upper band: `median + multiplier · MAD`.
|
||||
pub upper: f64,
|
||||
/// Middle line: the rolling median.
|
||||
pub middle: f64,
|
||||
/// Lower band: `median − multiplier · MAD`.
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
/// Median Channel: a robust analogue of Bollinger Bands built from the rolling
|
||||
/// median and the median absolute deviation (MAD).
|
||||
///
|
||||
/// ```text
|
||||
/// middle = median(close, period)
|
||||
/// MAD = median( | close_i − middle | )
|
||||
/// upper = middle + multiplier · MAD
|
||||
/// lower = middle − multiplier · MAD
|
||||
/// ```
|
||||
///
|
||||
/// Where [`BollingerBands`](crate::BollingerBands) centre on the mean and scale
|
||||
/// by the standard deviation — both of which a single spike can drag
|
||||
/// arbitrarily far — the Median Channel uses two order statistics. The
|
||||
/// breakdown point of the median and MAD is 50%: up to half the window can be
|
||||
/// contaminated before the centre or width is materially distorted. That makes
|
||||
/// the channel well suited to noisy, gap-prone, or fat-tailed series where
|
||||
/// Bollinger Bands flare on every outlier. Both quantiles use the type-7
|
||||
/// interpolation shared with [`RollingQuantile`](crate::RollingQuantile).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, MedianChannel};
|
||||
///
|
||||
/// let mut indicator = MedianChannel::new(20, 2.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(100.0 + f64::from(i % 5));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MedianChannel {
|
||||
period: usize,
|
||||
multiplier: f64,
|
||||
window: VecDeque<f64>,
|
||||
scratch: Vec<f64>,
|
||||
deviations: Vec<f64>,
|
||||
}
|
||||
|
||||
impl MedianChannel {
|
||||
/// Construct a new Median Channel.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`, or
|
||||
/// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
|
||||
/// positive and finite.
|
||||
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if !multiplier.is_finite() || multiplier <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
multiplier,
|
||||
window: VecDeque::with_capacity(period),
|
||||
scratch: Vec::with_capacity(period),
|
||||
deviations: Vec::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured multiplier.
|
||||
pub const fn multiplier(&self) -> f64 {
|
||||
self.multiplier
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for MedianChannel {
|
||||
type Input = f64;
|
||||
type Output = MedianChannelOutput;
|
||||
|
||||
fn update(&mut self, value: f64) -> Option<MedianChannelOutput> {
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(value);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
self.scratch.clear();
|
||||
self.scratch.extend(self.window.iter().copied());
|
||||
self.scratch.sort_by(f64::total_cmp);
|
||||
let median = quantile_sorted(&self.scratch, 0.5);
|
||||
|
||||
self.deviations.clear();
|
||||
for &v in &self.window {
|
||||
self.deviations.push((v - median).abs());
|
||||
}
|
||||
self.deviations.sort_by(f64::total_cmp);
|
||||
let mad = quantile_sorted(&self.deviations, 0.5);
|
||||
let offset = self.multiplier * mad;
|
||||
|
||||
Some(MedianChannelOutput {
|
||||
upper: median + offset,
|
||||
middle: median,
|
||||
lower: median - offset,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.scratch.clear();
|
||||
self.deviations.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"MedianChannel"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(MedianChannel::new(0, 2.0), Err(Error::PeriodZero)));
|
||||
assert!(MedianChannel::new(1, 2.0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_positive_multiplier() {
|
||||
assert!(matches!(
|
||||
MedianChannel::new(20, 0.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
MedianChannel::new(20, -1.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
MedianChannel::new(20, f64::NAN),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mc = MedianChannel::new(20, 2.0).unwrap();
|
||||
assert_eq!(mc.period(), 20);
|
||||
assert_relative_eq!(mc.multiplier(), 2.0, epsilon = 1e-12);
|
||||
assert_eq!(mc.warmup_period(), 20);
|
||||
assert_eq!(mc.name(), "MedianChannel");
|
||||
assert!(!mc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warms_up_then_emits() {
|
||||
let mut mc = MedianChannel::new(5, 2.0).unwrap();
|
||||
for v in [1.0, 2.0, 3.0, 4.0] {
|
||||
assert!(mc.update(v).is_none());
|
||||
}
|
||||
assert!(mc.update(5.0).is_some());
|
||||
assert!(mc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_channel() {
|
||||
// [1,2,3,4,5]: median 3; |dev| sorted [0,1,1,2,2] -> MAD 1.
|
||||
// upper = 3 + 2*1 = 5; lower = 3 - 2*1 = 1.
|
||||
let mut mc = MedianChannel::new(5, 2.0).unwrap();
|
||||
let out = mc.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
let last = out[4].unwrap();
|
||||
assert_relative_eq!(last.middle, 3.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.upper, 5.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.lower, 1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn robust_to_outlier() {
|
||||
// Replacing the last value with a huge spike leaves the median centre
|
||||
// unchanged (still the middle order statistic).
|
||||
let mut mc = MedianChannel::new(5, 2.0).unwrap();
|
||||
let out = mc.batch(&[1.0, 2.0, 3.0, 4.0, 1_000.0]);
|
||||
assert_relative_eq!(out[4].unwrap().middle, 3.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_window_evicts_oldest() {
|
||||
// Ten values through a period-5 window: only the last five survive,
|
||||
// reproducing the `known_channel` window.
|
||||
let mut mc = MedianChannel::new(5, 2.0).unwrap();
|
||||
let out = mc.batch(&[10.0, 10.0, 10.0, 10.0, 10.0, 1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
let last = out[9].unwrap();
|
||||
assert_relative_eq!(last.middle, 3.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.upper, 5.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.lower, 1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut mc = MedianChannel::new(5, 2.0).unwrap();
|
||||
for v in [1.0, 2.0, 3.0, 4.0, 5.0] {
|
||||
mc.update(v);
|
||||
}
|
||||
assert!(mc.is_ready());
|
||||
mc.reset();
|
||||
assert!(!mc.is_ready());
|
||||
assert!(mc.update(1.0).is_none());
|
||||
}
|
||||
}
|
||||
@@ -48,9 +48,11 @@ mod bat;
|
||||
mod belt_hold;
|
||||
mod beta;
|
||||
mod beta_neutral_spread;
|
||||
mod bipower_variation;
|
||||
mod body_size_pct;
|
||||
mod bollinger;
|
||||
mod bollinger_bandwidth;
|
||||
mod bomar_bands;
|
||||
mod breadth_thrust;
|
||||
mod breakaway;
|
||||
mod bullish_percent_index;
|
||||
@@ -118,6 +120,7 @@ mod empirical_mode_decomposition;
|
||||
mod engulfing;
|
||||
mod evening_doji_star;
|
||||
mod evwma;
|
||||
mod ewma_volatility;
|
||||
mod expectancy;
|
||||
mod falling_three_methods;
|
||||
mod fama;
|
||||
@@ -143,8 +146,10 @@ mod funding_rate_mean;
|
||||
mod funding_rate_zscore;
|
||||
mod gain_loss_ratio;
|
||||
mod gap_side_by_side_white;
|
||||
mod garch11;
|
||||
mod garman_klass;
|
||||
mod gartley;
|
||||
mod gator_oscillator;
|
||||
mod generalized_dema;
|
||||
mod geometric_ma;
|
||||
mod golden_pocket;
|
||||
@@ -187,6 +192,7 @@ mod jump_indicator;
|
||||
mod kagi_bars;
|
||||
mod kalman_hedge_ratio;
|
||||
mod kama;
|
||||
mod kase_permission_stochastic;
|
||||
mod kelly_criterion;
|
||||
mod keltner;
|
||||
mod kicking;
|
||||
@@ -212,6 +218,7 @@ mod ma_envelope;
|
||||
mod macd;
|
||||
mod macd_ext;
|
||||
mod macd_fix;
|
||||
mod macd_histogram;
|
||||
mod mama;
|
||||
mod market_facilitation_index;
|
||||
mod marubozu;
|
||||
@@ -223,6 +230,7 @@ mod mcclellan_oscillator;
|
||||
mod mcclellan_summation_index;
|
||||
mod mcginley_dynamic;
|
||||
mod median_absolute_deviation;
|
||||
mod median_channel;
|
||||
mod median_ma;
|
||||
mod median_price;
|
||||
mod mfi;
|
||||
@@ -266,11 +274,17 @@ 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 projection_bands;
|
||||
mod projection_oscillator;
|
||||
mod psar;
|
||||
mod pvi;
|
||||
mod qqe;
|
||||
mod qstick;
|
||||
mod quartile_bands;
|
||||
mod quoted_spread;
|
||||
mod r_squared;
|
||||
mod realized_spread;
|
||||
@@ -368,6 +382,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;
|
||||
@@ -376,9 +391,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;
|
||||
@@ -395,6 +412,9 @@ mod variance;
|
||||
mod variance_ratio;
|
||||
mod vertical_horizontal_filter;
|
||||
mod vidya;
|
||||
mod volatility_cone;
|
||||
mod volatility_of_volatility;
|
||||
mod volatility_ratio;
|
||||
mod volty_stop;
|
||||
mod volume_by_time_profile;
|
||||
mod volume_oscillator;
|
||||
@@ -406,6 +426,7 @@ mod vwap;
|
||||
mod vwap_stddev_bands;
|
||||
mod vwma;
|
||||
mod vzo;
|
||||
mod wave_pm;
|
||||
mod wave_trend;
|
||||
mod wedge;
|
||||
mod weighted_close;
|
||||
@@ -461,9 +482,11 @@ pub use bat::Bat;
|
||||
pub use belt_hold::BeltHold;
|
||||
pub use beta::Beta;
|
||||
pub use beta_neutral_spread::BetaNeutralSpread;
|
||||
pub use bipower_variation::BipowerVariation;
|
||||
pub use body_size_pct::BodySizePct;
|
||||
pub use bollinger::{BollingerBands, BollingerOutput};
|
||||
pub use bollinger_bandwidth::BollingerBandwidth;
|
||||
pub use bomar_bands::{BomarBands, BomarBandsOutput};
|
||||
pub use breadth_thrust::BreadthThrust;
|
||||
pub use breakaway::Breakaway;
|
||||
pub use bullish_percent_index::BullishPercentIndex;
|
||||
@@ -531,6 +554,7 @@ pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
|
||||
pub use engulfing::Engulfing;
|
||||
pub use evening_doji_star::EveningDojiStar;
|
||||
pub use evwma::Evwma;
|
||||
pub use ewma_volatility::EwmaVolatility;
|
||||
pub use expectancy::Expectancy;
|
||||
pub use falling_three_methods::FallingThreeMethods;
|
||||
pub use fama::Fama;
|
||||
@@ -556,8 +580,10 @@ pub use funding_rate_mean::FundingRateMean;
|
||||
pub use funding_rate_zscore::FundingRateZScore;
|
||||
pub use gain_loss_ratio::GainLossRatio;
|
||||
pub use gap_side_by_side_white::GapSideBySideWhite;
|
||||
pub use garch11::Garch11;
|
||||
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};
|
||||
@@ -600,6 +626,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;
|
||||
@@ -625,6 +652,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;
|
||||
@@ -636,6 +664,7 @@ pub use mcclellan_oscillator::McClellanOscillator;
|
||||
pub use mcclellan_summation_index::McClellanSummationIndex;
|
||||
pub use mcginley_dynamic::McGinleyDynamic;
|
||||
pub use median_absolute_deviation::MedianAbsoluteDeviation;
|
||||
pub use median_channel::{MedianChannel, MedianChannelOutput};
|
||||
pub use median_ma::MedianMa;
|
||||
pub use median_price::MedianPrice;
|
||||
pub use mfi::Mfi;
|
||||
@@ -679,11 +708,17 @@ 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 projection_bands::{ProjectionBands, ProjectionBandsOutput};
|
||||
pub use projection_oscillator::ProjectionOscillator;
|
||||
pub use psar::Psar;
|
||||
pub use pvi::Pvi;
|
||||
pub use qqe::{Qqe, QqeOutput};
|
||||
pub use qstick::Qstick;
|
||||
pub use quartile_bands::{QuartileBands, QuartileBandsOutput};
|
||||
pub use quoted_spread::QuotedSpread;
|
||||
pub use r_squared::RSquared;
|
||||
pub use realized_spread::RealizedSpread;
|
||||
@@ -781,6 +816,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;
|
||||
@@ -789,9 +825,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;
|
||||
@@ -808,6 +846,9 @@ pub use variance::Variance;
|
||||
pub use variance_ratio::VarianceRatio;
|
||||
pub use vertical_horizontal_filter::VerticalHorizontalFilter;
|
||||
pub use vidya::Vidya;
|
||||
pub use volatility_cone::{VolatilityCone, VolatilityConeOutput};
|
||||
pub use volatility_of_volatility::VolatilityOfVolatility;
|
||||
pub use volatility_ratio::VolatilityRatio;
|
||||
pub use volty_stop::VoltyStop;
|
||||
pub use volume_by_time_profile::{VolumeByTimeProfile, VolumeByTimeProfileOutput};
|
||||
pub use volume_oscillator::VolumeOscillator;
|
||||
@@ -819,6 +860,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;
|
||||
@@ -936,6 +978,13 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"MinusDi",
|
||||
"Dx",
|
||||
"TrendLabel",
|
||||
"TtmTrend",
|
||||
"TrendStrengthIndex",
|
||||
"Qstick",
|
||||
"PolarizedFractalEfficiency",
|
||||
"WavePm",
|
||||
"GatorOscillator",
|
||||
"KasePermissionStochastic",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -952,6 +1001,9 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"ZeroLagMacd",
|
||||
"ElderImpulse",
|
||||
"Stc",
|
||||
"TsfOscillator",
|
||||
"MacdHistogram",
|
||||
"PpoHistogram",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -976,6 +1028,12 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"YangZhangVolatility",
|
||||
"JumpIndicator",
|
||||
"RegimeLabel",
|
||||
"EwmaVolatility",
|
||||
"Garch11",
|
||||
"VolatilityOfVolatility",
|
||||
"BipowerVariation",
|
||||
"VolatilityRatio",
|
||||
"VolatilityCone",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -992,6 +1050,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"TtmSqueeze",
|
||||
"FractalChaosBands",
|
||||
"VwapStdDevBands",
|
||||
"QuartileBands",
|
||||
"BomarBands",
|
||||
"MedianChannel",
|
||||
"ProjectionBands",
|
||||
"ProjectionOscillator",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1393,6 +1456,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, 413, "FAMILIES total drifted from indicator count");
|
||||
assert_eq!(total, 434, "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,253 @@
|
||||
//! Projection Bands (Mel Widner) — a high/low linear-regression projection
|
||||
//! envelope.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Projection Bands output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ProjectionBandsOutput {
|
||||
/// Upper band: the maximum forward-projected high in the window.
|
||||
pub upper: f64,
|
||||
/// Middle line: the midpoint of the upper and lower bands.
|
||||
pub middle: f64,
|
||||
/// Lower band: the minimum forward-projected low in the window.
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
/// Projection Bands: forward-projected high/low envelope.
|
||||
///
|
||||
/// Mel Widner ("Projection Bands and the Projection Oscillator", *Technical
|
||||
/// Analysis of Stocks & Commodities*, May 1995) fits a separate linear
|
||||
/// regression to the highs and to the lows over the last `period` bars, then
|
||||
/// slides every bar's high and low forward to the current bar along its own
|
||||
/// slope. The upper band is the maximum of the projected highs, the lower band
|
||||
/// the minimum of the projected lows:
|
||||
///
|
||||
/// ```text
|
||||
/// slope_h = OLS slope of (x, high) over the window
|
||||
/// slope_l = OLS slope of (x, low) over the window
|
||||
/// // bar i (0 = oldest, period-1 = newest) is (period-1-i) bars in the past
|
||||
/// upper = max over i of [ high_i + slope_h · (period-1-i) ]
|
||||
/// lower = min over i of [ low_i + slope_l · (period-1-i) ]
|
||||
/// middle = (upper + lower) / 2
|
||||
/// ```
|
||||
///
|
||||
/// Unlike [`LinRegChannel`](crate::LinRegChannel) and
|
||||
/// [`StandardErrorBands`](crate::StandardErrorBands) — which wrap a single
|
||||
/// close-regression endpoint by a dispersion statistic — Projection Bands are
|
||||
/// built from the *extremes*: the envelope adapts to the trend's slope yet
|
||||
/// always contains every projected high and low, so by construction price never
|
||||
/// pierces the bands within the window. A flat slope reduces the bands to the
|
||||
/// rolling highest-high / lowest-low (a Donchian channel); a steep slope tilts
|
||||
/// the whole envelope with the trend.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ProjectionBands};
|
||||
///
|
||||
/// let mut indicator = ProjectionBands::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..30 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectionBands {
|
||||
period: usize,
|
||||
highs: VecDeque<f64>,
|
||||
lows: VecDeque<f64>,
|
||||
sum_x: f64,
|
||||
sum_xx: f64,
|
||||
}
|
||||
|
||||
impl ProjectionBands {
|
||||
/// Construct new Projection Bands.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` (a regression slope
|
||||
/// needs at least two points).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "projection bands need period >= 2",
|
||||
});
|
||||
}
|
||||
let n = period as f64;
|
||||
Ok(Self {
|
||||
period,
|
||||
highs: VecDeque::with_capacity(period),
|
||||
lows: VecDeque::with_capacity(period),
|
||||
sum_x: n * (n - 1.0) / 2.0,
|
||||
sum_xx: (n - 1.0) * n * (2.0 * n - 1.0) / 6.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// OLS slope of `(0..period, values)` over the live window.
|
||||
fn slope(&self, values: &VecDeque<f64>) -> f64 {
|
||||
let n = self.period as f64;
|
||||
let mut sum_y = 0.0;
|
||||
let mut sum_xy = 0.0;
|
||||
for (i, &y) in values.iter().enumerate() {
|
||||
sum_y += y;
|
||||
sum_xy += (i as f64) * y;
|
||||
}
|
||||
let denom = n * self.sum_xx - self.sum_x * self.sum_x;
|
||||
(n * sum_xy - self.sum_x * sum_y) / denom
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ProjectionBands {
|
||||
type Input = Candle;
|
||||
type Output = ProjectionBandsOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<ProjectionBandsOutput> {
|
||||
if self.highs.len() == self.period {
|
||||
self.highs.pop_front();
|
||||
self.lows.pop_front();
|
||||
}
|
||||
self.highs.push_back(candle.high);
|
||||
self.lows.push_back(candle.low);
|
||||
if self.highs.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
|
||||
let slope_h = self.slope(&self.highs);
|
||||
let slope_l = self.slope(&self.lows);
|
||||
let last = (self.period - 1) as f64;
|
||||
|
||||
let mut upper = f64::NEG_INFINITY;
|
||||
let mut lower = f64::INFINITY;
|
||||
for (i, (&high, &low)) in self.highs.iter().zip(self.lows.iter()).enumerate() {
|
||||
let forward = last - (i as f64);
|
||||
let projected_high = high + slope_h * forward;
|
||||
let projected_low = low + slope_l * forward;
|
||||
if projected_high > upper {
|
||||
upper = projected_high;
|
||||
}
|
||||
if projected_low < lower {
|
||||
lower = projected_low;
|
||||
}
|
||||
}
|
||||
|
||||
Some(ProjectionBandsOutput {
|
||||
upper,
|
||||
middle: f64::midpoint(upper, lower),
|
||||
lower,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.highs.clear();
|
||||
self.lows.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.highs.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ProjectionBands"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(low, high, low, close, 10.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(matches!(
|
||||
ProjectionBands::new(0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
ProjectionBands::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(ProjectionBands::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let pb = ProjectionBands::new(14).unwrap();
|
||||
assert_eq!(pb.period(), 14);
|
||||
assert_eq!(pb.warmup_period(), 14);
|
||||
assert_eq!(pb.name(), "ProjectionBands");
|
||||
assert!(!pb.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warms_up_then_emits() {
|
||||
let mut pb = ProjectionBands::new(3).unwrap();
|
||||
assert!(pb.update(candle(10.0, 8.0, 9.0, 0)).is_none());
|
||||
assert!(pb.update(candle(12.0, 9.0, 11.0, 1)).is_none());
|
||||
assert!(pb.update(candle(11.0, 10.0, 11.0, 2)).is_some());
|
||||
assert!(pb.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_projection() {
|
||||
// highs 10,12,11 -> slope_h = 0.5; projected = 11, 12.5, 11 -> upper 12.5
|
||||
// lows 8, 9,10 -> slope_l = 1.0; projected = 10, 10, 10 -> lower 10
|
||||
let mut pb = ProjectionBands::new(3).unwrap();
|
||||
pb.update(candle(10.0, 8.0, 9.0, 0));
|
||||
pb.update(candle(12.0, 9.0, 11.0, 1));
|
||||
let out = pb.update(candle(11.0, 10.0, 11.0, 2)).unwrap();
|
||||
assert_relative_eq!(out.upper, 12.5, epsilon = 1e-9);
|
||||
assert_relative_eq!(out.lower, 10.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out.middle, 11.25, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_trend_pins_bands_to_current_extremes() {
|
||||
// High_i and Low_i both rise by exactly 1 per bar: every projected high
|
||||
// collapses onto the current high, every projected low onto the current
|
||||
// low.
|
||||
let mut pb = ProjectionBands::new(5).unwrap();
|
||||
let mut last = None;
|
||||
for i in 0..10 {
|
||||
let high = 100.0 + f64::from(i);
|
||||
let low = 95.0 + f64::from(i);
|
||||
last = pb.update(candle(high, low, high, i64::from(i)));
|
||||
}
|
||||
let out = last.unwrap();
|
||||
assert_relative_eq!(out.upper, 109.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out.lower, 104.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out.middle, 106.5, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut pb = ProjectionBands::new(3).unwrap();
|
||||
pb.update(candle(10.0, 8.0, 9.0, 0));
|
||||
pb.update(candle(12.0, 9.0, 11.0, 1));
|
||||
pb.update(candle(11.0, 10.0, 11.0, 2));
|
||||
assert!(pb.is_ready());
|
||||
pb.reset();
|
||||
assert!(!pb.is_ready());
|
||||
assert!(pb.update(candle(10.0, 8.0, 9.0, 3)).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Projection Oscillator (Mel Widner) — the close's position inside the
|
||||
//! [`ProjectionBands`](crate::ProjectionBands).
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::projection_bands::ProjectionBands;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Projection Oscillator: where the close sits inside the projection bands,
|
||||
/// scaled to `0..100`.
|
||||
///
|
||||
/// The companion to [`ProjectionBands`](crate::ProjectionBands) from Mel
|
||||
/// Widner's May 1995 *Stocks & Commodities* article. It maps the close onto the
|
||||
/// `[lower, upper]` projection envelope:
|
||||
///
|
||||
/// ```text
|
||||
/// PO = 100 · (close − lower) / (upper − lower)
|
||||
/// ```
|
||||
///
|
||||
/// `PO = 0` means the close is sitting on the lower band, `PO = 100` on the
|
||||
/// upper band, and `PO = 50` at the midline. Because the bands by construction
|
||||
/// bracket every projected high and low, the close almost always falls inside
|
||||
/// them and the oscillator stays in `0..100` — readings near the extremes flag
|
||||
/// an overbought/oversold position *relative to the trend-tilted channel*
|
||||
/// rather than to a horizontal level. When the bands collapse (a zero-range
|
||||
/// window, `upper == lower`) the position is undefined and the oscillator
|
||||
/// returns the neutral `50.0`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ProjectionOscillator};
|
||||
///
|
||||
/// let mut indicator = ProjectionOscillator::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..30 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectionOscillator {
|
||||
bands: ProjectionBands,
|
||||
}
|
||||
|
||||
impl ProjectionOscillator {
|
||||
/// Construct a new Projection Oscillator.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`](crate::Error::InvalidPeriod) if
|
||||
/// `period < 2`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
bands: ProjectionBands::new(period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.bands.period()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ProjectionOscillator {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let bands = self.bands.update(candle)?;
|
||||
let width = bands.upper - bands.lower;
|
||||
if width == 0.0 {
|
||||
return Some(50.0);
|
||||
}
|
||||
Some(100.0 * (candle.close - bands.lower) / width)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.bands.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.bands.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.bands.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ProjectionOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::Error;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(low, high, low, close, 10.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(matches!(
|
||||
ProjectionOscillator::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(ProjectionOscillator::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let po = ProjectionOscillator::new(14).unwrap();
|
||||
assert_eq!(po.period(), 14);
|
||||
assert_eq!(po.warmup_period(), 14);
|
||||
assert_eq!(po.name(), "ProjectionOscillator");
|
||||
assert!(!po.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warms_up_then_emits() {
|
||||
let mut po = ProjectionOscillator::new(3).unwrap();
|
||||
assert!(po.update(candle(10.0, 8.0, 9.0, 0)).is_none());
|
||||
assert!(po.update(candle(12.0, 9.0, 11.0, 1)).is_none());
|
||||
assert!(po.update(candle(11.0, 10.0, 11.0, 2)).is_some());
|
||||
assert!(po.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_position() {
|
||||
// Same window as ProjectionBands::known_projection: upper 12.5, lower 10.
|
||||
// close 11 -> 100 * (11 - 10) / (12.5 - 10) = 40.
|
||||
let mut po = ProjectionOscillator::new(3).unwrap();
|
||||
po.update(candle(10.0, 8.0, 9.0, 0));
|
||||
po.update(candle(12.0, 9.0, 11.0, 1));
|
||||
let out = po.update(candle(11.0, 10.0, 11.0, 2)).unwrap();
|
||||
assert_relative_eq!(out, 40.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_bands_return_neutral() {
|
||||
// Zero-range, perfectly trending candles: upper == lower every bar.
|
||||
let mut po = ProjectionOscillator::new(3).unwrap();
|
||||
let mut last = None;
|
||||
for i in 0..6 {
|
||||
let v = 100.0 + f64::from(i);
|
||||
last = po.update(candle(v, v, v, i64::from(i)));
|
||||
}
|
||||
assert_relative_eq!(last.unwrap(), 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut po = ProjectionOscillator::new(3).unwrap();
|
||||
po.update(candle(10.0, 8.0, 9.0, 0));
|
||||
po.update(candle(12.0, 9.0, 11.0, 1));
|
||||
po.update(candle(11.0, 10.0, 11.0, 2));
|
||||
assert!(po.is_ready());
|
||||
po.reset();
|
||||
assert!(!po.is_ready());
|
||||
assert!(po.update(candle(10.0, 8.0, 9.0, 3)).is_none());
|
||||
}
|
||||
}
|
||||
@@ -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,194 @@
|
||||
//! Quartile Bands — rolling 25th / 50th / 75th percentile envelope.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::rolling_quantile::quantile_sorted;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Quartile Bands output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct QuartileBandsOutput {
|
||||
/// Upper band: the rolling third quartile (75th percentile, `Q3`).
|
||||
pub upper: f64,
|
||||
/// Middle line: the rolling median (50th percentile, `Q2`).
|
||||
pub middle: f64,
|
||||
/// Lower band: the rolling first quartile (25th percentile, `Q1`).
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
/// Quartile Bands: a distribution-based envelope drawn at the rolling quartiles.
|
||||
///
|
||||
/// ```text
|
||||
/// lower = Q1 = 25th percentile of the last `period` values
|
||||
/// middle = Q2 = 50th percentile (median)
|
||||
/// upper = Q3 = 75th percentile
|
||||
/// ```
|
||||
///
|
||||
/// Quantiles use the type-7 (`NumPy`/`R-7`) linear interpolation shared with
|
||||
/// [`RollingQuantile`](crate::RollingQuantile). Where Bollinger Bands assume an
|
||||
/// approximately normal distribution and size the envelope by the mean and
|
||||
/// standard deviation, Quartile Bands are fully **non-parametric**: the band
|
||||
/// edges are order statistics, so a single outlier shifts at most one rank
|
||||
/// rather than inflating the whole width, and the inter-quartile span between
|
||||
/// the bands is exactly the [`RollingIqr`](crate::RollingIqr). The middle line
|
||||
/// is the robust median rather than the mean, so it is unmoved by spikes.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, QuartileBands};
|
||||
///
|
||||
/// let mut indicator = QuartileBands::new(20).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 QuartileBands {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
scratch: Vec<f64>,
|
||||
}
|
||||
|
||||
impl QuartileBands {
|
||||
/// Construct new Quartile Bands.
|
||||
///
|
||||
/// # 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),
|
||||
scratch: Vec::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for QuartileBands {
|
||||
type Input = f64;
|
||||
type Output = QuartileBandsOutput;
|
||||
|
||||
fn update(&mut self, value: f64) -> Option<QuartileBandsOutput> {
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(value);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
self.scratch.clear();
|
||||
self.scratch.extend(self.window.iter().copied());
|
||||
self.scratch.sort_by(f64::total_cmp);
|
||||
Some(QuartileBandsOutput {
|
||||
upper: quantile_sorted(&self.scratch, 0.75),
|
||||
middle: quantile_sorted(&self.scratch, 0.5),
|
||||
lower: quantile_sorted(&self.scratch, 0.25),
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.scratch.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"QuartileBands"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(QuartileBands::new(0), Err(Error::PeriodZero)));
|
||||
assert!(QuartileBands::new(1).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let qb = QuartileBands::new(20).unwrap();
|
||||
assert_eq!(qb.period(), 20);
|
||||
assert_eq!(qb.warmup_period(), 20);
|
||||
assert_eq!(qb.name(), "QuartileBands");
|
||||
assert!(!qb.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warms_up_then_emits() {
|
||||
let mut qb = QuartileBands::new(4).unwrap();
|
||||
assert!(qb.update(10.0).is_none());
|
||||
assert!(qb.update(20.0).is_none());
|
||||
assert!(qb.update(30.0).is_none());
|
||||
assert!(qb.update(40.0).is_some());
|
||||
assert!(qb.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_quartiles() {
|
||||
// sorted [10,20,30,40]:
|
||||
// Q1 h=(4-1)*0.25=0.75 -> 10 + 0.75*10 = 17.5
|
||||
// Q2 h=1.5 -> 20 + 0.5*10 = 25.0
|
||||
// Q3 h=2.25 -> 30 + 0.25*10 = 32.5
|
||||
let mut qb = QuartileBands::new(4).unwrap();
|
||||
let out = qb.batch(&[40.0, 30.0, 20.0, 10.0]);
|
||||
let last = out[3].unwrap();
|
||||
assert_relative_eq!(last.lower, 17.5, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.middle, 25.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.upper, 32.5, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn median_robust_to_outlier() {
|
||||
// A single spike shifts the mean a lot but the median by at most one rank.
|
||||
let mut qb = QuartileBands::new(5).unwrap();
|
||||
let out = qb.batch(&[1.0, 2.0, 3.0, 4.0, 1000.0]);
|
||||
assert_relative_eq!(out[4].unwrap().middle, 3.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_window_evicts_oldest() {
|
||||
// Eight values through a period-4 window: only the last four survive,
|
||||
// reproducing the `known_quartiles` window.
|
||||
let mut qb = QuartileBands::new(4).unwrap();
|
||||
let out = qb.batch(&[1.0, 2.0, 3.0, 4.0, 40.0, 30.0, 20.0, 10.0]);
|
||||
let last = out[7].unwrap();
|
||||
assert_relative_eq!(last.lower, 17.5, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.middle, 25.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.upper, 32.5, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut qb = QuartileBands::new(4).unwrap();
|
||||
for v in [10.0, 20.0, 30.0, 40.0] {
|
||||
qb.update(v);
|
||||
}
|
||||
assert!(qb.is_ready());
|
||||
qb.reset();
|
||||
assert!(!qb.is_ready());
|
||||
assert!(qb.update(10.0).is_none());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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,381 @@
|
||||
//! Volatility Cone — current realized volatility within its historical envelope.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`VolatilityCone`]: the current realized volatility together with
|
||||
/// the envelope (the "cone") it sits inside over the lookback window.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct VolatilityConeOutput {
|
||||
/// Latest realized volatility (sample stddev of log returns over `window`).
|
||||
pub current: f64,
|
||||
/// Lowest realized volatility seen over the `lookback` window.
|
||||
pub min: f64,
|
||||
/// Median realized volatility over the `lookback` window.
|
||||
pub median: f64,
|
||||
/// Highest realized volatility seen over the `lookback` window.
|
||||
pub max: f64,
|
||||
/// Percentile rank of `current` within the lookback distribution, in
|
||||
/// `[0, 100]` — the share of stored volatilities `<= current`, times 100.
|
||||
pub percentile: f64,
|
||||
}
|
||||
|
||||
/// Sample standard deviation from a running `(sum, sum_of_squares, count)`.
|
||||
fn sample_stddev(sum: f64, sum_sq: f64, count: usize) -> f64 {
|
||||
let n = count as f64;
|
||||
let mean = sum / n;
|
||||
let variance = ((sum_sq - n * mean * mean) / (n - 1.0)).max(0.0);
|
||||
variance.sqrt()
|
||||
}
|
||||
|
||||
/// Volatility Cone — the current realized volatility positioned within the
|
||||
/// historical range ("cone") of realized volatilities over a lookback window.
|
||||
///
|
||||
/// ```text
|
||||
/// r_t = ln(close_t / close_{t−1})
|
||||
/// vol_t = stddev_sample(r over window) (rolling realized volatility)
|
||||
/// cone = { min, median, max, percentile } of vol over the last `lookback`
|
||||
/// ```
|
||||
///
|
||||
/// A volatility cone (Burghardt & Lane 1990) shows whether current volatility is
|
||||
/// high or low *relative to its own history*, rather than as an absolute number.
|
||||
/// This streaming form tracks one horizon: it maintains the rolling realized
|
||||
/// volatility of log returns over `window`, then reports the latest reading
|
||||
/// (`current`) alongside the `min`, `median`, `max` and percentile rank of that
|
||||
/// volatility series over the trailing `lookback`. `current` always lies within
|
||||
/// `[min, max]` because it is itself the newest member of the lookback set.
|
||||
///
|
||||
/// Only the candle's **close** is used (the log-return series); the high and low
|
||||
/// are ignored. The volatility is per-period (sample stddev of log returns, not
|
||||
/// annualised) — multiply by `√trading_periods` for an annual figure. Each
|
||||
/// `update` is O(`lookback log lookback`) from sorting the envelope.
|
||||
///
|
||||
/// Non-positive closes are ignored (the log return would be undefined): the tick
|
||||
/// is dropped, state is left untouched, and the last value is returned.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, VolatilityCone};
|
||||
///
|
||||
/// let mut indicator = VolatilityCone::new(20, 60).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// let c = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
|
||||
/// let candle = Candle::new(c, c + 1.0, c - 1.0, c, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VolatilityCone {
|
||||
window: usize,
|
||||
lookback: usize,
|
||||
prev_close: Option<f64>,
|
||||
/// Rolling window of log returns for the inner realized-volatility series.
|
||||
returns: VecDeque<f64>,
|
||||
ret_sum: f64,
|
||||
ret_sum_sq: f64,
|
||||
/// Rolling window of realized-volatility readings (the cone envelope).
|
||||
vols: VecDeque<f64>,
|
||||
last: Option<VolatilityConeOutput>,
|
||||
}
|
||||
|
||||
impl VolatilityCone {
|
||||
/// Construct a new volatility-cone indicator.
|
||||
///
|
||||
/// `window` is the realized-volatility estimation window; `lookback` is the
|
||||
/// number of volatility readings forming the historical cone.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if either argument is `0`, or
|
||||
/// [`Error::InvalidPeriod`] if `window < 2` (a sample stddev needs two
|
||||
/// returns) or `lookback < 2` (an envelope needs at least two readings).
|
||||
pub fn new(window: usize, lookback: usize) -> Result<Self> {
|
||||
if window == 0 || lookback == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if window < 2 || lookback < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "volatility cone window and lookback must both be >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
window,
|
||||
lookback,
|
||||
prev_close: None,
|
||||
returns: VecDeque::with_capacity(window),
|
||||
ret_sum: 0.0,
|
||||
ret_sum_sq: 0.0,
|
||||
vols: VecDeque::with_capacity(lookback),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(window, lookback)`.
|
||||
pub const fn windows(&self) -> (usize, usize) {
|
||||
(self.window, self.lookback)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<VolatilityConeOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for VolatilityCone {
|
||||
type Input = Candle;
|
||||
type Output = VolatilityConeOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<VolatilityConeOutput> {
|
||||
let price = candle.close;
|
||||
// A log return is undefined for a non-positive close; skip the tick.
|
||||
if price <= 0.0 {
|
||||
return self.last;
|
||||
}
|
||||
let Some(prev) = self.prev_close else {
|
||||
self.prev_close = Some(price);
|
||||
return None;
|
||||
};
|
||||
self.prev_close = Some(price);
|
||||
// `prev` came from `self.prev_close`, gated by the guard above, so it is
|
||||
// positive — the log return is always well-defined.
|
||||
let r = (price / prev).ln();
|
||||
|
||||
// Stage one: rolling sample volatility of log returns.
|
||||
if self.returns.len() == self.window {
|
||||
let old = self.returns.pop_front().expect("returns window non-empty");
|
||||
self.ret_sum -= old;
|
||||
self.ret_sum_sq -= old * old;
|
||||
}
|
||||
self.returns.push_back(r);
|
||||
self.ret_sum += r;
|
||||
self.ret_sum_sq += r * r;
|
||||
if self.returns.len() < self.window {
|
||||
return None;
|
||||
}
|
||||
let current = sample_stddev(self.ret_sum, self.ret_sum_sq, self.window);
|
||||
|
||||
// Stage two: maintain the lookback envelope of volatility readings.
|
||||
if self.vols.len() == self.lookback {
|
||||
self.vols.pop_front();
|
||||
}
|
||||
self.vols.push_back(current);
|
||||
if self.vols.len() < self.lookback {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut sorted: Vec<f64> = self.vols.iter().copied().collect();
|
||||
sorted.sort_by(f64::total_cmp);
|
||||
let min = sorted[0];
|
||||
let max = sorted[self.lookback - 1];
|
||||
let mid = self.lookback / 2;
|
||||
let median = if self.lookback % 2 == 1 {
|
||||
sorted[mid]
|
||||
} else {
|
||||
f64::midpoint(sorted[mid - 1], sorted[mid])
|
||||
};
|
||||
let count_le = self.vols.iter().filter(|&&v| v <= current).count();
|
||||
let percentile = count_le as f64 / self.lookback as f64 * 100.0;
|
||||
|
||||
let out = VolatilityConeOutput {
|
||||
current,
|
||||
min,
|
||||
median,
|
||||
max,
|
||||
percentile,
|
||||
};
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.returns.clear();
|
||||
self.ret_sum = 0.0;
|
||||
self.ret_sum_sq = 0.0;
|
||||
self.vols.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// One previous close for the first return, `window` returns for the
|
||||
// first volatility, then `lookback` volatilities for the envelope.
|
||||
self.window + self.lookback
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"VolatilityCone"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
/// Candle whose close drives the indicator (open = high = low = close here).
|
||||
fn close_candle(close: f64) -> Candle {
|
||||
Candle::new_unchecked(close, close, close, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_window() {
|
||||
assert!(matches!(VolatilityCone::new(0, 10), Err(Error::PeriodZero)));
|
||||
assert!(matches!(VolatilityCone::new(10, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_window_one() {
|
||||
assert!(matches!(
|
||||
VolatilityCone::new(1, 10),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
VolatilityCone::new(10, 1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let vc = VolatilityCone::new(20, 60).unwrap();
|
||||
assert_eq!(vc.windows(), (20, 60));
|
||||
assert_eq!(vc.warmup_period(), 80);
|
||||
assert_eq!(vc.name(), "VolatilityCone");
|
||||
assert!(!vc.is_ready());
|
||||
assert_eq!(vc.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut vc = VolatilityCone::new(2, 2).unwrap();
|
||||
let prices = [100.0, 110.0, 121.0, 100.0, 105.0, 99.0];
|
||||
let candles: Vec<Candle> = prices.iter().map(|p| close_candle(*p)).collect();
|
||||
let out = vc.batch(&candles);
|
||||
let warmup = vc.warmup_period(); // 4
|
||||
assert_eq!(warmup, 4);
|
||||
for v in out.iter().take(warmup - 1) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[warmup - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_value() {
|
||||
// window = 2 -> vol = |r_t − r_{t−1}| / √2; lookback = 2.
|
||||
// prices: r1 = r2 = ln(1.1), r3 = ln(100/121).
|
||||
let mut vc = VolatilityCone::new(2, 2).unwrap();
|
||||
let candles: Vec<Candle> = [100.0, 110.0, 121.0, 100.0]
|
||||
.iter()
|
||||
.map(|p| close_candle(*p))
|
||||
.collect();
|
||||
let out = vc.batch(&candles);
|
||||
let r2 = (121.0_f64 / 110.0).ln();
|
||||
let r3 = (100.0_f64 / 121.0).ln();
|
||||
let vol2 = (r2 - r3).abs() / 2.0_f64.sqrt();
|
||||
let o = out[3].unwrap();
|
||||
assert_relative_eq!(o.current, vol2, epsilon = 1e-9);
|
||||
assert_relative_eq!(o.min, 0.0, epsilon = 1e-9); // vol1 = 0 (r1 == r2)
|
||||
assert_relative_eq!(o.max, vol2, epsilon = 1e-9);
|
||||
assert_relative_eq!(o.median, vol2 / 2.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(o.percentile, 100.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_lookback_median_is_middle() {
|
||||
// lookback = 3 picks the middle of the sorted envelope.
|
||||
let mut vc = VolatilityCone::new(2, 3).unwrap();
|
||||
let candles: Vec<Candle> = [100.0, 101.0, 103.0, 100.0, 104.0, 99.0, 106.0]
|
||||
.iter()
|
||||
.map(|p| close_candle(*p))
|
||||
.collect();
|
||||
let out = vc.batch(&candles);
|
||||
let o = out.last().unwrap().unwrap();
|
||||
assert!(o.min <= o.median && o.median <= o.max);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_brackets_current() {
|
||||
let mut vc = VolatilityCone::new(10, 30).unwrap();
|
||||
let candles: Vec<Candle> = (0..200)
|
||||
.map(|i| close_candle(100.0 + (f64::from(i) * 0.3).sin() * 12.0))
|
||||
.collect();
|
||||
for o in vc.batch(&candles).into_iter().flatten() {
|
||||
assert!(o.min <= o.current && o.current <= o.max);
|
||||
assert!(o.min <= o.median && o.median <= o.max);
|
||||
assert!(o.percentile > 0.0 && o.percentile <= 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero_cone() {
|
||||
let mut vc = VolatilityCone::new(5, 5).unwrap();
|
||||
let candles: Vec<Candle> = (0..40).map(|_| close_candle(100.0)).collect();
|
||||
for o in vc.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(o.current, 0.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(o.min, 0.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(o.max, 0.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(o.median, 0.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(o.percentile, 100.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_non_positive_close() {
|
||||
let mut vc = VolatilityCone::new(2, 2).unwrap();
|
||||
let candles: Vec<Candle> = [100.0, 110.0, 121.0, 100.0]
|
||||
.iter()
|
||||
.map(|p| close_candle(*p))
|
||||
.collect();
|
||||
let warmup = vc.batch(&candles);
|
||||
let baseline = warmup.last().copied().flatten().expect("warmed up");
|
||||
// A non-positive close is skipped and the previous value is returned.
|
||||
assert_eq!(vc.update(close_candle(0.0)), Some(baseline));
|
||||
// State untouched: a clone advanced by the same real tick agrees.
|
||||
let mut control = vc.clone();
|
||||
let after = vc.update(close_candle(105.0)).expect("ready");
|
||||
assert_eq!(control.update(close_candle(105.0)).expect("ready"), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_non_positive_before_first_close() {
|
||||
let mut vc = VolatilityCone::new(2, 2).unwrap();
|
||||
assert_eq!(vc.update(close_candle(0.0)), None);
|
||||
assert_eq!(vc.update(close_candle(100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut vc = VolatilityCone::new(2, 2).unwrap();
|
||||
let candles: Vec<Candle> = [100.0, 110.0, 121.0, 100.0, 105.0]
|
||||
.iter()
|
||||
.map(|p| close_candle(*p))
|
||||
.collect();
|
||||
vc.batch(&candles);
|
||||
assert!(vc.is_ready());
|
||||
vc.reset();
|
||||
assert!(!vc.is_ready());
|
||||
assert_eq!(vc.value(), None);
|
||||
assert_eq!(vc.update(close_candle(100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..200)
|
||||
.map(|i| close_candle(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
|
||||
.collect();
|
||||
let batch = VolatilityCone::new(10, 30).unwrap().batch(&candles);
|
||||
let mut b = VolatilityCone::new(10, 30).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Volatility of Volatility — the dispersion of a rolling volatility series.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Sample standard deviation from a running `(sum, sum_of_squares, count)`.
|
||||
///
|
||||
/// Uses Bessel's correction (divisor `n − 1`) and clamps a tiny negative
|
||||
/// floating-point residual to zero before the square root.
|
||||
fn sample_stddev(sum: f64, sum_sq: f64, count: usize) -> f64 {
|
||||
let n = count as f64;
|
||||
let mean = sum / n;
|
||||
let variance = ((sum_sq - n * mean * mean) / (n - 1.0)).max(0.0);
|
||||
variance.sqrt()
|
||||
}
|
||||
|
||||
/// Volatility of Volatility — the standard deviation of a rolling realized-
|
||||
/// volatility series ("vol-of-vol").
|
||||
///
|
||||
/// ```text
|
||||
/// r_t = ln(price_t / price_{t−1})
|
||||
/// vol_t = stddev_sample(r over vol_window) (rolling realized volatility)
|
||||
/// VoV = stddev_sample(vol over vov_window) (dispersion of that series)
|
||||
/// ```
|
||||
///
|
||||
/// This is a two-stage estimator: the first stage measures the rolling sample
|
||||
/// volatility of log returns (the same quantity
|
||||
/// [`HistoricalVolatility`](crate::HistoricalVolatility) annualises), and the
|
||||
/// second stage measures how much *that* volatility itself moves. A high
|
||||
/// vol-of-vol means the volatility regime is unstable — turbulent periods
|
||||
/// alternate with calm ones — which is exactly the convexity that long-gamma and
|
||||
/// volatility-trading strategies care about. Both stages use the unbiased
|
||||
/// `n − 1` sample standard deviation. Each `update` is O(1).
|
||||
///
|
||||
/// Non-finite and non-positive prices are ignored (the log return would be
|
||||
/// undefined): the tick is dropped, state is left untouched, and the last value
|
||||
/// is returned.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, VolatilityOfVolatility};
|
||||
///
|
||||
/// let mut indicator = VolatilityOfVolatility::new(20, 20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VolatilityOfVolatility {
|
||||
vol_window: usize,
|
||||
vov_window: usize,
|
||||
prev_price: Option<f64>,
|
||||
/// Rolling window of log returns (stage one).
|
||||
returns: VecDeque<f64>,
|
||||
ret_sum: f64,
|
||||
ret_sum_sq: f64,
|
||||
/// Rolling window of realized-volatility readings (stage two).
|
||||
vols: VecDeque<f64>,
|
||||
vol_sum: f64,
|
||||
vol_sum_sq: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl VolatilityOfVolatility {
|
||||
/// Construct a new vol-of-vol indicator.
|
||||
///
|
||||
/// `vol_window` is the window for the inner realized-volatility series;
|
||||
/// `vov_window` is the window over which its dispersion is measured.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if either window is `0`, or
|
||||
/// [`Error::InvalidPeriod`] if either is `1` (a sample standard deviation
|
||||
/// needs at least two observations).
|
||||
pub fn new(vol_window: usize, vov_window: usize) -> Result<Self> {
|
||||
if vol_window == 0 || vov_window == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if vol_window < 2 || vov_window < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "vol-of-vol windows must both be >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
vol_window,
|
||||
vov_window,
|
||||
prev_price: None,
|
||||
returns: VecDeque::with_capacity(vol_window),
|
||||
ret_sum: 0.0,
|
||||
ret_sum_sq: 0.0,
|
||||
vols: VecDeque::with_capacity(vov_window),
|
||||
vol_sum: 0.0,
|
||||
vol_sum_sq: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(vol_window, vov_window)`.
|
||||
pub const fn windows(&self) -> (usize, usize) {
|
||||
(self.vol_window, self.vov_window)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for VolatilityOfVolatility {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
// Non-finite / non-positive prices are skipped: `ln(input / prev)` is
|
||||
// undefined, so the tick must not enter the return window.
|
||||
if !input.is_finite() || input <= 0.0 {
|
||||
return self.last;
|
||||
}
|
||||
let Some(prev) = self.prev_price else {
|
||||
self.prev_price = Some(input);
|
||||
return None;
|
||||
};
|
||||
self.prev_price = Some(input);
|
||||
// `prev` came from `self.prev_price`, gated by the guard above, so it is
|
||||
// finite and positive — the log return is always well-defined.
|
||||
let r = (input / prev).ln();
|
||||
|
||||
// Stage one: rolling sample volatility of log returns.
|
||||
if self.returns.len() == self.vol_window {
|
||||
let old = self.returns.pop_front().expect("returns window non-empty");
|
||||
self.ret_sum -= old;
|
||||
self.ret_sum_sq -= old * old;
|
||||
}
|
||||
self.returns.push_back(r);
|
||||
self.ret_sum += r;
|
||||
self.ret_sum_sq += r * r;
|
||||
if self.returns.len() < self.vol_window {
|
||||
return None;
|
||||
}
|
||||
let vol = sample_stddev(self.ret_sum, self.ret_sum_sq, self.vol_window);
|
||||
|
||||
// Stage two: rolling sample dispersion of the volatility series.
|
||||
if self.vols.len() == self.vov_window {
|
||||
let old = self.vols.pop_front().expect("vols window non-empty");
|
||||
self.vol_sum -= old;
|
||||
self.vol_sum_sq -= old * old;
|
||||
}
|
||||
self.vols.push_back(vol);
|
||||
self.vol_sum += vol;
|
||||
self.vol_sum_sq += vol * vol;
|
||||
if self.vols.len() < self.vov_window {
|
||||
return None;
|
||||
}
|
||||
let vov = sample_stddev(self.vol_sum, self.vol_sum_sq, self.vov_window);
|
||||
self.last = Some(vov);
|
||||
Some(vov)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_price = None;
|
||||
self.returns.clear();
|
||||
self.ret_sum = 0.0;
|
||||
self.ret_sum_sq = 0.0;
|
||||
self.vols.clear();
|
||||
self.vol_sum = 0.0;
|
||||
self.vol_sum_sq = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// One previous price for the first return, `vol_window` returns for the
|
||||
// first volatility, then `vov_window` volatilities for the dispersion.
|
||||
// The two windows overlap on the bar axis, so this is the sum.
|
||||
self.vol_window + self.vov_window
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"VolatilityOfVolatility"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use crate::HistoricalVolatility;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_window() {
|
||||
assert!(matches!(
|
||||
VolatilityOfVolatility::new(0, 10),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
VolatilityOfVolatility::new(10, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_window_one() {
|
||||
assert!(matches!(
|
||||
VolatilityOfVolatility::new(1, 10),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
VolatilityOfVolatility::new(10, 1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let vov = VolatilityOfVolatility::new(20, 10).unwrap();
|
||||
assert_eq!(vov.windows(), (20, 10));
|
||||
assert_eq!(vov.warmup_period(), 30);
|
||||
assert_eq!(vov.name(), "VolatilityOfVolatility");
|
||||
assert!(!vov.is_ready());
|
||||
assert_eq!(vov.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut vov = VolatilityOfVolatility::new(3, 3).unwrap();
|
||||
let prices: Vec<f64> = (1..=20)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 4.0)
|
||||
.collect();
|
||||
let out = vov.batch(&prices);
|
||||
let warmup = vov.warmup_period(); // 6
|
||||
for v in out.iter().take(warmup - 1) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[warmup - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_two_stage_reference() {
|
||||
// Stage one equals HistoricalVolatility(vol_window, 1) / 100 (sample
|
||||
// stddev of log returns); stage two is the sample stddev of that series.
|
||||
let (vol_window, vov_window) = (3, 3);
|
||||
let prices: Vec<f64> = [100.0, 102.0, 101.0, 104.0, 103.5, 106.0, 105.0, 108.0].to_vec();
|
||||
|
||||
let mut hv = HistoricalVolatility::new(vol_window, 1).unwrap();
|
||||
let vol_series: Vec<f64> = hv
|
||||
.batch(&prices)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|v| v / 100.0)
|
||||
.collect();
|
||||
// Sample stddev of the last `vov_window` volatilities.
|
||||
let tail = &vol_series[vol_series.len() - vov_window..];
|
||||
let sum: f64 = tail.iter().sum();
|
||||
let sum_sq: f64 = tail.iter().map(|v| v * v).sum();
|
||||
let expected = sample_stddev(sum, sum_sq, vov_window);
|
||||
|
||||
let mut vov = VolatilityOfVolatility::new(vol_window, vov_window).unwrap();
|
||||
let out = vov.batch(&prices);
|
||||
assert_relative_eq!(out.last().unwrap().unwrap(), expected, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
let mut vov = VolatilityOfVolatility::new(5, 5).unwrap();
|
||||
for v in vov.batch(&[100.0; 60]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_non_negative() {
|
||||
let mut vov = VolatilityOfVolatility::new(10, 10).unwrap();
|
||||
let prices: Vec<f64> = (1..=300)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
|
||||
.collect();
|
||||
for v in vov.batch(&prices).into_iter().flatten() {
|
||||
assert!(v >= 0.0, "vol-of-vol must be non-negative, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut vov = VolatilityOfVolatility::new(3, 3).unwrap();
|
||||
let out = vov.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
|
||||
let last = *out.last().unwrap();
|
||||
assert!(last.is_some());
|
||||
assert_eq!(vov.update(f64::NAN), last);
|
||||
assert_eq!(vov.update(f64::INFINITY), last);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_non_positive_prices() {
|
||||
let mut vov = VolatilityOfVolatility::new(3, 3).unwrap();
|
||||
let warmup = vov.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
|
||||
let baseline = warmup.last().copied().flatten().expect("warmed up");
|
||||
assert_eq!(vov.update(-5.0), Some(baseline));
|
||||
assert_eq!(vov.update(0.0), Some(baseline));
|
||||
// State untouched: a clone advanced by the same real tick agrees.
|
||||
let mut control = vov.clone();
|
||||
let after = vov.update(41.0).expect("ready");
|
||||
assert_eq!(control.update(41.0).expect("ready"), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut vov = VolatilityOfVolatility::new(3, 3).unwrap();
|
||||
vov.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(vov.is_ready());
|
||||
vov.reset();
|
||||
assert!(!vov.is_ready());
|
||||
assert_eq!(vov.value(), None);
|
||||
assert_eq!(vov.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = VolatilityOfVolatility::new(10, 10).unwrap().batch(&prices);
|
||||
let mut b = VolatilityOfVolatility::new(10, 10).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Schwager's Volatility Ratio — today's true range versus its typical level.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Schwager's Volatility Ratio — the current bar's true range divided by the
|
||||
/// exponential moving average of the *prior* true ranges.
|
||||
///
|
||||
/// ```text
|
||||
/// TR_t = true range of bar t
|
||||
/// VR_t = TR_t / EMA_n(TR through bar t−1)
|
||||
/// ```
|
||||
///
|
||||
/// Jack Schwager's volatility ratio measures how today's range compares to its
|
||||
/// recent typical level: a reading above `2.0` marks a **wide-ranging day** —
|
||||
/// today's true range is more than twice the smoothed average — which often
|
||||
/// precedes or accompanies a reversal. The denominator is the exponential
|
||||
/// moving average of true range *excluding the current bar*, seeded with the
|
||||
/// simple average of the first `period` true ranges, so a single large bar
|
||||
/// stands out instead of inflating its own benchmark.
|
||||
///
|
||||
/// True range is `max(high − low, |high − prev_close|, |low − prev_close|)`,
|
||||
/// identical to the [`Atr`](crate::Atr) building block, but here it is compared
|
||||
/// to a *standard* EMA (smoothing `2 / (period + 1)`) rather than Wilder
|
||||
/// smoothing, which keeps the ratio distinct from `TR / ATR`. Each `update` is
|
||||
/// O(1).
|
||||
///
|
||||
/// A flat market drives every true range — and the EMA — to `0`; the ratio is
|
||||
/// then `0.0` rather than an undefined `0 / 0`. `Candle::new` rejects non-finite
|
||||
/// fields, so no in-method finiteness guard is needed.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, VolatilityRatio};
|
||||
///
|
||||
/// let mut indicator = VolatilityRatio::new(14).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 - 1.0, base + 0.5, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VolatilityRatio {
|
||||
period: usize,
|
||||
alpha: f64,
|
||||
prev_close: Option<f64>,
|
||||
/// Sum and count of the first `period` true ranges, used to seed the EMA.
|
||||
seed_sum: f64,
|
||||
seed_count: usize,
|
||||
/// EMA of true range through the previous bar; `None` until seeded.
|
||||
ema: Option<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl VolatilityRatio {
|
||||
/// Construct a new volatility-ratio indicator.
|
||||
///
|
||||
/// `period` is the number of true ranges that seed and smooth the
|
||||
/// denominator EMA.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
alpha: 2.0 / (period as f64 + 1.0),
|
||||
prev_close: None,
|
||||
seed_sum: 0.0,
|
||||
seed_count: 0,
|
||||
ema: None,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for VolatilityRatio {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
// The first bar has no previous close, so no true range can be formed.
|
||||
let Some(prev_close) = self.prev_close else {
|
||||
self.prev_close = Some(candle.close);
|
||||
return None;
|
||||
};
|
||||
let tr = candle.true_range(Some(prev_close));
|
||||
self.prev_close = Some(candle.close);
|
||||
|
||||
match self.ema {
|
||||
None => {
|
||||
// Seeding the EMA with the simple average of the first `period`
|
||||
// true ranges; emit nothing until it is established.
|
||||
self.seed_sum += tr;
|
||||
self.seed_count += 1;
|
||||
if self.seed_count == self.period {
|
||||
self.ema = Some(self.seed_sum / self.period as f64);
|
||||
}
|
||||
None
|
||||
}
|
||||
Some(prev_ema) => {
|
||||
// Denominator excludes the current bar (it is the EMA through the
|
||||
// previous bar). A flat benchmark yields 0.0, not 0/0.
|
||||
let vr = if prev_ema > 0.0 { tr / prev_ema } else { 0.0 };
|
||||
self.ema = Some(self.alpha * tr + (1.0 - self.alpha) * prev_ema);
|
||||
self.last = Some(vr);
|
||||
Some(vr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.seed_sum = 0.0;
|
||||
self.seed_count = 0;
|
||||
self.ema = None;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// Bar 1 sets the previous close; bars 2..=period+1 seed the EMA; the
|
||||
// first ratio is emitted on bar period + 2.
|
||||
self.period + 2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"VolatilityRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
/// Build a candle with the given high/low/close (open = low, fixed volume).
|
||||
fn candle(high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(low, high, low, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(VolatilityRatio::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let vr = VolatilityRatio::new(14).unwrap();
|
||||
assert_eq!(vr.period(), 14);
|
||||
assert_eq!(vr.warmup_period(), 16);
|
||||
assert_eq!(vr.name(), "VolatilityRatio");
|
||||
assert!(!vr.is_ready());
|
||||
assert_eq!(vr.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut vr = VolatilityRatio::new(3).unwrap();
|
||||
// Build enough constant-range candles to reach warmup.
|
||||
let candles: Vec<Candle> = (0..10)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
candle(base + 1.0, base - 1.0, base)
|
||||
})
|
||||
.collect();
|
||||
let out = vr.batch(&candles);
|
||||
// warmup_period == period + 2 == 5: the first emission is at index 4.
|
||||
let warmup = vr.warmup_period();
|
||||
assert_eq!(warmup, 5);
|
||||
for v in out.iter().take(warmup - 1) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[warmup - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_ranging_day_exceeds_two() {
|
||||
// Steady true range of 2.0 seeds the EMA, then one bar with a far wider
|
||||
// range pushes the ratio above 2.0.
|
||||
let mut vr = VolatilityRatio::new(3).unwrap();
|
||||
let mut candles: Vec<Candle> = (0..6)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
candle(base + 1.0, base - 1.0, base) // TR = 2.0 each
|
||||
})
|
||||
.collect();
|
||||
// A wide bar: range 10 around the last close (~105).
|
||||
candles.push(candle(110.0, 100.0, 105.0));
|
||||
let out = vr.batch(&candles);
|
||||
let last = out.last().unwrap().unwrap();
|
||||
assert!(last > 2.0, "wide-ranging day should exceed 2.0, got {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steady_range_ratio_is_one() {
|
||||
// Constant true range -> EMA equals it -> ratio is exactly 1.0.
|
||||
let mut vr = VolatilityRatio::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..12)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
candle(base + 1.0, base - 1.0, base) // TR = 2.0 each
|
||||
})
|
||||
.collect();
|
||||
let out = vr.batch(&candles);
|
||||
assert_relative_eq!(out.last().unwrap().unwrap(), 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_yields_zero() {
|
||||
// Zero-range candles: TR = 0, EMA = 0, ratio guarded to 0.0.
|
||||
let mut vr = VolatilityRatio::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..10).map(|_| candle(100.0, 100.0, 100.0)).collect();
|
||||
let out = vr.batch(&candles);
|
||||
for v in out.into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_non_negative() {
|
||||
let mut vr = VolatilityRatio::new(14).unwrap();
|
||||
let candles: Vec<Candle> = (0..200)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0;
|
||||
candle(base + 2.0, base - 2.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
for v in vr.batch(&candles).into_iter().flatten() {
|
||||
assert!(v >= 0.0, "volatility ratio must be non-negative, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut vr = VolatilityRatio::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..10)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
candle(base + 1.0, base - 1.0, base)
|
||||
})
|
||||
.collect();
|
||||
vr.batch(&candles);
|
||||
assert!(vr.is_ready());
|
||||
vr.reset();
|
||||
assert!(!vr.is_ready());
|
||||
assert_eq!(vr.value(), None);
|
||||
assert_eq!(vr.update(candle(101.0, 99.0, 100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
|
||||
candle(base + 2.0, base - 1.5, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let batch = VolatilityRatio::new(14).unwrap().batch(&candles);
|
||||
let mut b = VolatilityRatio::new(14).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -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<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -63,28 +63,29 @@ pub use indicators::{
|
||||
AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, AutoFib,
|
||||
AutoFibOutput, Autocorrelation, AverageDailyRange, AverageDrawdown, AvgPrice,
|
||||
AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BeltHold, Beta,
|
||||
BetaNeutralSpread, BodySizePct, BollingerBands, BollingerBandwidth, BollingerOutput,
|
||||
BreadthThrust, Breakaway, BullishPercentIndex, Butterfly, CalendarSpread, CalmarRatio,
|
||||
Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow,
|
||||
ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
|
||||
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, CloseVsOpen,
|
||||
ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput,
|
||||
ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock, Counterattack, Crab,
|
||||
CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle, CyberneticCycle, Cypher,
|
||||
DayOfWeekProfile, DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex,
|
||||
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, FisherRsi, FisherTransform, FlagPennant, Footprint,
|
||||
FootprintOutput, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis,
|
||||
FundingRate, FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite,
|
||||
GarmanKlassVolatility, Gartley, GeneralizedDema, GeometricMa, GoldenPocket, GoldenPocketOutput,
|
||||
BetaNeutralSpread, BipowerVariation, BodySizePct, BollingerBands, BollingerBandwidth,
|
||||
BollingerOutput, BomarBands, BomarBandsOutput, BreadthThrust, Breakaway, BullishPercentIndex,
|
||||
Butterfly, CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity,
|
||||
Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop,
|
||||
ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots,
|
||||
ClassicPivotsOutput, CloseVsOpen, ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration,
|
||||
CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock,
|
||||
Counterattack, Crab, CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle,
|
||||
CyberneticCycle, Cypher, DayOfWeekProfile, DayOfWeekProfileOutput, Decycler,
|
||||
DecyclerOscillator, Dema, DemandIndex, 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,
|
||||
EwmaVolatility, Expectancy, FallingThreeMethods, Fama, FibArcs, FibArcsOutput, FibChannel,
|
||||
FibChannelOutput, FibConfluence, FibConfluenceOutput, FibExtension, FibExtensionOutput, FibFan,
|
||||
FibFanOutput, FibProjection, FibProjectionOutput, FibRetracement, FibRetracementOutput,
|
||||
FibTimeZones, FibTimeZonesOutput, FibonacciPivots, FibonacciPivotsOutput, FisherRsi,
|
||||
FisherTransform, FlagPennant, Footprint, FootprintOutput, ForceIndex, FractalChaosBands,
|
||||
FractalChaosBandsOutput, Frama, FundingBasis, FundingRate, FundingRateMean, FundingRateZScore,
|
||||
GainLossRatio, GapSideBySideWhite, Garch11, 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,
|
||||
@@ -93,22 +94,25 @@ pub use indicators::{
|
||||
InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
|
||||
IntradayMomentumIndex, IntradayVolatilityProfile, IntradayVolatilityProfileOutput,
|
||||
InverseFisherTransform, InvertedHammer, Jma, JumpIndicator, KagiBars, KalmanHedgeRatio,
|
||||
KalmanHedgeRatioOutput, Kama, KellyCriterion, Keltner, KeltnerOutput, Kicking, KickingByLength,
|
||||
Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation,
|
||||
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, 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, Qqe, QqeOutput, QuotedSpread, RSquared, RealizedSpread,
|
||||
MacdFix, MacdHistogram, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex,
|
||||
Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown, McClellanOscillator,
|
||||
McClellanSummationIndex, McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel,
|
||||
MedianChannelOutput, 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, ProjectionBands, ProjectionBandsOutput, ProjectionOscillator, Psar, Pvi, Qqe,
|
||||
QqeOutput, Qstick, QuartileBands, QuartileBandsOutput, QuotedSpread, RSquared, RealizedSpread,
|
||||
RealizedVolatility, RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB,
|
||||
RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi,
|
||||
Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollMeasure, RollingCorrelation,
|
||||
@@ -127,16 +131,18 @@ pub use indicators::{
|
||||
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,
|
||||
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, VolatilityCone, VolatilityConeOutput,
|
||||
VolatilityOfVolatility, VolatilityRatio, 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 **413 indicators** across
|
||||
- A per-indicator deep dive for every one of the **434 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.6",
|
||||
"version": "0.6.1",
|
||||
"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.6",
|
||||
"wickra-darwin-x64": "0.5.6",
|
||||
"wickra-linux-arm64-gnu": "0.5.6",
|
||||
"wickra-linux-x64-gnu": "0.5.6",
|
||||
"wickra-win32-arm64-msvc": "0.5.6",
|
||||
"wickra-win32-x64-msvc": "0.5.6"
|
||||
"wickra-darwin-arm64": "0.6.1",
|
||||
"wickra-darwin-x64": "0.6.1",
|
||||
"wickra-linux-arm64-gnu": "0.6.1",
|
||||
"wickra-linux-x64-gnu": "0.6.1",
|
||||
"wickra-win32-arm64-msvc": "0.6.1",
|
||||
"wickra-win32-x64-msvc": "0.6.1"
|
||||
}
|
||||
},
|
||||
"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, 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, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, Ppo, 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, 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, BipowerVariation, BollingerBands, BomarBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DerivativeOscillator, DetrendedStdDev, DisparityIndex, DoubleBollinger, Dpo, DrawdownDuration, DynamicMomentumIndex, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, EwmaVolatility, Expectancy, Fama, FisherRsi, FisherTransform, Frama, GainLossRatio, Garch11, 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, MedianChannel, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Qqe, QuartileBands, 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, VolatilityOfVolatility, 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.
|
||||
@@ -73,6 +73,9 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
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);
|
||||
@@ -81,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);
|
||||
@@ -109,6 +115,10 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
drive(|| HurstExponent::new(16, 4).unwrap(), &data);
|
||||
drive(|| LogReturn::new(1).unwrap(), &data);
|
||||
drive(|| RealizedVolatility::new(20).unwrap(), &data);
|
||||
drive(|| EwmaVolatility::new(0.94).unwrap(), &data);
|
||||
drive(|| Garch11::new(0.000_002, 0.1, 0.88).unwrap(), &data);
|
||||
drive(|| BipowerVariation::new(20).unwrap(), &data);
|
||||
drive(|| VolatilityOfVolatility::new(20, 20).unwrap(), &data);
|
||||
drive(|| RollingQuantile::new(20, 0.5).unwrap(), &data);
|
||||
drive(|| RollingIqr::new(14).unwrap(), &data);
|
||||
drive(|| RollingPercentileRank::new(14).unwrap(), &data);
|
||||
@@ -262,6 +272,27 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
}
|
||||
|
||||
// --- Family 05: scalar-input band/channel indicators (multi-output) ---
|
||||
{
|
||||
let mut medianchannel = MedianChannel::new(5, 2.0).unwrap();
|
||||
for &x in &data {
|
||||
let _ = medianchannel.update(x);
|
||||
}
|
||||
let _ = MedianChannel::new(5, 2.0).unwrap().batch(&data);
|
||||
}
|
||||
{
|
||||
let mut bomarbands = BomarBands::new(4, 0.85).unwrap();
|
||||
for &x in &data {
|
||||
let _ = bomarbands.update(x);
|
||||
}
|
||||
let _ = BomarBands::new(4, 0.85).unwrap().batch(&data);
|
||||
}
|
||||
{
|
||||
let mut quartilebands = QuartileBands::new(4).unwrap();
|
||||
for &x in &data {
|
||||
let _ = quartilebands.update(x);
|
||||
}
|
||||
let _ = QuartileBands::new(4).unwrap().batch(&data);
|
||||
}
|
||||
{
|
||||
let mut env = MaEnvelope::new(20, 0.025).unwrap();
|
||||
for &x in &data {
|
||||
|
||||
@@ -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, ElderRay, 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, IntradayMomentumIndex, 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, 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, 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, ProjectionBands, ProjectionOscillator, 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, VolatilityCone, VolatilityRatio, 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
|
||||
@@ -62,6 +62,7 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
}
|
||||
|
||||
// --- Volatility & ATR family ---
|
||||
drive(|| VolatilityRatio::new(14).unwrap(), &candles);
|
||||
drive(|| Atr::new(14).unwrap(), &candles);
|
||||
drive(|| Natr::new(14).unwrap(), &candles);
|
||||
drive(TrueRange::new, &candles);
|
||||
@@ -72,6 +73,8 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
drive(|| YangZhangVolatility::new(20, 252).unwrap(), &candles);
|
||||
|
||||
// --- Bands & Channels ---
|
||||
drive(|| ProjectionOscillator::new(14).unwrap(), &candles);
|
||||
drive(|| ProjectionBands::new(3).unwrap(), &candles);
|
||||
drive(|| Keltner::new(20, 10, 2.0).unwrap(), &candles);
|
||||
drive(|| Donchian::new(20).unwrap(), &candles);
|
||||
|
||||
@@ -87,6 +90,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);
|
||||
@@ -242,6 +249,7 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
}
|
||||
|
||||
// --- Family 05: candle-input band/channel indicators (multi-output) ---
|
||||
drive(|| VolatilityCone::new(20, 60).unwrap(), &candles);
|
||||
{
|
||||
let mut ab = AccelerationBands::new(20, 0.001).unwrap();
|
||||
for c in &candles {
|
||||
|
||||
Reference in New Issue
Block a user