Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81406e7a1b | |||
| c78b84e186 | |||
| fc6f3d80c2 | |||
| 2991ba411d | |||
| 83e34c6f71 | |||
| 67feec598a | |||
| 3dfbc415c5 | |||
| 6b8c6a0e7f | |||
| db186b18d3 | |||
| 654da5722f | |||
| aacb9280f1 |
@@ -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)."
|
||||
|
||||
+58
-1
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.3] - 2026-06-07
|
||||
- **Volume-Weighted MACD** — Volume-Weighted MACD: MACD computed on VWMA instead of EMA, with signal line and histogram (`VWMACD`).
|
||||
- **Better Volume** — Better Volume (VSA): classifies volume against bar spread to surface effort/result imbalance (`BETTERVOL`).
|
||||
- **Intraday Intensity Index** — Intraday Intensity Index: volume weighted by close position within the bar range (`INTRADAYINT`).
|
||||
- **Trade Volume Index** — Trade Volume Index: accumulates volume by tick direction past a min-tick threshold (distinct from TSV) (`TRADEVOLIDX`).
|
||||
- **Twiggs Money Flow** — Twiggs Money Flow: volume-weighted accumulation using true range and Wilder smoothing (distinct from CMF) (`TWIGGSMF`).
|
||||
- **Williams Accumulation/Distribution** — Williams Accumulation/Distribution: cumulative price-direction accumulator (distinct from Chaikin A/D) (`WILLIAMSAD`).
|
||||
- **Volume RSI** — Volume RSI: Wilder-style RSI computed on signed volume flow (`VOLUMERSI`).
|
||||
|
||||
## [0.6.2] - 2026-06-07
|
||||
- **Modified MA Stop** — Modified MA Stop — SMMA-ratcheted trailing stop with directional flip (`MODIFIED_MA_STOP`).
|
||||
- **Time-Based Stop** — Time-Based Stop — bar-count timer that fires after a fixed holding period (`TIME_BASED_STOP`).
|
||||
- **NRTR** — NRTR (Nick Rypock Trailing Reverse) — percentage trailing-reverse stop (`NRTR`).
|
||||
- **ATR Ratchet** — ATR Ratchet — Kaufman per-bar tightening volatility trailing stop (`ATR_RATCHET`).
|
||||
- **Elder SafeZone** — Elder SafeZone Stop — average noise-penetration trailing stop with directional flip (`ELDER_SAFE_ZONE`).
|
||||
- **Kase DevStop** — Kase DevStop volatility trailing stop using standard-deviation of two-bar true range (`KASE_DEV_STOP`).
|
||||
|
||||
## [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`).
|
||||
@@ -1273,7 +1325,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.8...HEAD
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.6.3...HEAD
|
||||
[0.6.3]: https://github.com/wickra-lib/wickra/compare/v0.6.2...v0.6.3
|
||||
[0.6.2]: https://github.com/wickra-lib/wickra/compare/v0.6.1...v0.6.2
|
||||
[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
|
||||
|
||||
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.8"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1876,9 +1953,21 @@ dependencies = [
|
||||
"wickra-data",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wickra-bench"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"kand",
|
||||
"ta",
|
||||
"wickra",
|
||||
"wickra-data",
|
||||
"yata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.5.8"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
@@ -1888,7 +1977,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.5.8"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"csv",
|
||||
@@ -1905,7 +1994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-examples"
|
||||
version = "0.0.0"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -1915,7 +2004,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-node"
|
||||
version = "0.5.8"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -1925,7 +2014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.5.8"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -1934,7 +2023,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.5.8"
|
||||
version = "0.6.3"
|
||||
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.8"
|
||||
version = "0.6.3"
|
||||
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.8" }
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.6.3" }
|
||||
|
||||
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=423" 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=447" 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 423 indicators; start at the
|
||||
every one of the 447 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**: 447 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
|
||||
|
||||
423 streaming-first indicators across twenty-four families. Every one passes the
|
||||
447 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).
|
||||
@@ -147,10 +199,10 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
|
||||
| Momentum Oscillators | RSI (Wilder), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia, ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100), Disparity Index, Fisher RSI, RSX, Dynamic Momentum Index, Stochastic CCI, RMI, Derivative Oscillator, Elder Ray, Intraday Momentum Index, QQE |
|
||||
| Trend & Directional | MACD, MACD Fixed (MACDFIX), MACD Extended (MACDEXT), ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter, Plus DM, Minus DM, Plus DI, Minus DI, DX, TTM Trend, Trend Strength Index, Qstick, Polarized Fractal Efficiency, Wave PM, Gator Oscillator, Kase Permission Stochastic |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC, TSF Oscillator, MACD Histogram, PPO Histogram |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility |
|
||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
|
||||
| Trailing Stops | Parabolic SAR, Parabolic SAR Extended (SAREXT), SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
|
||||
| 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 |
|
||||
| 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, Kase DevStop, Elder SafeZone, ATR Ratchet, NRTR, Time-Based Stop, Modified MA 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, Volume RSI, Williams Accumulation/Distribution, Twiggs Money Flow, Trade Volume Index, Intraday Intensity Index, Better Volume, Volume-Weighted MACD |
|
||||
| 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 |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
||||
@@ -245,9 +297,10 @@ A Python live-trading example using the public `websockets` package lives at
|
||||
```
|
||||
wickra/
|
||||
├── crates/
|
||||
│ ├── wickra-core/ core engine + all 423 indicators
|
||||
│ ├── wickra-core/ core engine + all 447 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,10 @@ 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),
|
||||
@@ -350,6 +354,15 @@ const candleScalar = {
|
||||
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) },
|
||||
TimeBasedStop: { make: () => new wickra.TimeBasedStop(5), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
VolumeRsi: { make: () => new wickra.VolumeRsi(14), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
Wad: { make: () => new wickra.Wad(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TwiggsMoneyFlow: { make: () => new wickra.TwiggsMoneyFlow(21), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
TradeVolumeIndex: { make: () => new wickra.TradeVolumeIndex(0.25), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
IntradayIntensity: { make: () => new wickra.IntradayIntensity(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
BetterVolume: { make: () => new wickra.BetterVolume(14), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(candleScalar)) {
|
||||
@@ -436,6 +449,17 @@ const multi = {
|
||||
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) },
|
||||
KaseDevStop: { make: () => new wickra.KaseDevStop(3, 1.0), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
ElderSafeZone: { make: () => new wickra.ElderSafeZone(14, 2.0), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
AtrRatchet: { make: () => new wickra.AtrRatchet(14, 4.0, 0.1), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
Nrtr: { make: () => new wickra.Nrtr(2.0), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
ModifiedMaStop: { make: () => new wickra.ModifiedMaStop(14), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
VolumeWeightedMacd: { make: () => new wickra.VolumeWeightedMacd(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(multi)) {
|
||||
|
||||
Vendored
+296
@@ -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`. */
|
||||
@@ -138,6 +149,26 @@ export interface DonchianStopValue {
|
||||
stopLong: number
|
||||
stopShort: number
|
||||
}
|
||||
export interface KaseDevStopValue {
|
||||
value: number
|
||||
direction: number
|
||||
}
|
||||
export interface ElderSafeZoneValue {
|
||||
value: number
|
||||
direction: number
|
||||
}
|
||||
export interface AtrRatchetValue {
|
||||
value: number
|
||||
direction: number
|
||||
}
|
||||
export interface NrtrValue {
|
||||
value: number
|
||||
direction: number
|
||||
}
|
||||
export interface ModifiedMaStopValue {
|
||||
value: number
|
||||
direction: number
|
||||
}
|
||||
/** Vortex Indicator pair: `VI+` and `VI-`. */
|
||||
export interface VortexValue {
|
||||
plus: number
|
||||
@@ -188,6 +219,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
|
||||
@@ -438,6 +489,11 @@ export interface FibTimeZonesValue {
|
||||
onZone: number
|
||||
barsToNext: number
|
||||
}
|
||||
export interface VolumeWeightedMacdValue {
|
||||
macd: number
|
||||
signal: number
|
||||
histogram: number
|
||||
}
|
||||
export type SmaNode = SMA
|
||||
export declare class SMA {
|
||||
constructor(period: number)
|
||||
@@ -987,6 +1043,51 @@ export declare class TsfOscillator {
|
||||
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)
|
||||
@@ -1574,6 +1675,33 @@ export declare class KasePermissionStochastic {
|
||||
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 TimeBasedStopNode = TimeBasedStop
|
||||
export declare class TimeBasedStop {
|
||||
constructor(maxBars: 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)
|
||||
@@ -2234,6 +2362,71 @@ export declare class RenkoTrailingStop {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type KaseDevStopNode = KaseDevStop
|
||||
export declare class KaseDevStop {
|
||||
constructor(period: number, dev: number)
|
||||
update(high: number, low: number, close: number): KaseDevStopValue | null
|
||||
/**
|
||||
* Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
|
||||
* Warmup positions are `NaN`.
|
||||
*/
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type ElderSafeZoneNode = ElderSafeZone
|
||||
export declare class ElderSafeZone {
|
||||
constructor(period: number, coeff: number)
|
||||
update(high: number, low: number, close: number): ElderSafeZoneValue | null
|
||||
/**
|
||||
* Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
|
||||
* Warmup positions are `NaN`.
|
||||
*/
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type AtrRatchetNode = AtrRatchet
|
||||
export declare class AtrRatchet {
|
||||
constructor(atrPeriod: number, startMult: number, increment: number)
|
||||
update(high: number, low: number, close: number): AtrRatchetValue | null
|
||||
/**
|
||||
* Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
|
||||
* Warmup positions are `NaN`.
|
||||
*/
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type NrtrNode = Nrtr
|
||||
export declare class Nrtr {
|
||||
constructor(pct: number)
|
||||
update(high: number, low: number, close: number): NrtrValue | null
|
||||
/**
|
||||
* Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
|
||||
* Warmup positions are `NaN`.
|
||||
*/
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type ModifiedMaStopNode = ModifiedMaStop
|
||||
export declare class ModifiedMaStop {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, close: number): ModifiedMaStopValue | null
|
||||
/**
|
||||
* Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
|
||||
* Warmup positions are `NaN`.
|
||||
*/
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TypicalPriceNode = TypicalPrice
|
||||
export declare class TypicalPrice {
|
||||
constructor()
|
||||
@@ -2562,6 +2755,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)
|
||||
@@ -4416,3 +4645,70 @@ export declare class FibTimeZones {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type VolumeRsiNode = VolumeRsi
|
||||
export declare class VolumeRsi {
|
||||
constructor(period: number)
|
||||
update(close: number, volume: number): number | null
|
||||
batch(close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type WadNode = Wad
|
||||
export declare class Wad {
|
||||
constructor()
|
||||
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 TwiggsMoneyFlowNode = TwiggsMoneyFlow
|
||||
export declare class TwiggsMoneyFlow {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, close: number, volume: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TradeVolumeIndexNode = TradeVolumeIndex
|
||||
export declare class TradeVolumeIndex {
|
||||
constructor(minTick: number)
|
||||
update(close: number, volume: number): number | null
|
||||
batch(close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type IntradayIntensityNode = IntradayIntensity
|
||||
export declare class IntradayIntensity {
|
||||
constructor()
|
||||
update(high: number, low: number, close: number, volume: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type BetterVolumeNode = BetterVolume
|
||||
export declare class BetterVolume {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, close: number, volume: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type VolumeWeightedMacdNode = VolumeWeightedMacd
|
||||
export declare class VolumeWeightedMacd {
|
||||
constructor(fast: number, slow: number, signal: number)
|
||||
update(close: number, volume: number): VolumeWeightedMacdValue | null
|
||||
/**
|
||||
* Returns `[macd0, signal0, histogram0, macd1, ...]`, length `3 * n`.
|
||||
* Warmup positions are `NaN`.
|
||||
*/
|
||||
batch(close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
|
||||
+25
-1
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-arm64",
|
||||
"version": "0.5.8",
|
||||
"version": "0.6.3",
|
||||
"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.8",
|
||||
"version": "0.6.3",
|
||||
"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.8",
|
||||
"version": "0.6.3",
|
||||
"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.8",
|
||||
"version": "0.6.3",
|
||||
"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.8",
|
||||
"version": "0.6.3",
|
||||
"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.8",
|
||||
"version": "0.6.3",
|
||||
"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.8",
|
||||
"version": "0.6.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra",
|
||||
"version": "0.5.8",
|
||||
"version": "0.6.3",
|
||||
"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.8",
|
||||
"wickra-darwin-x64": "0.5.8",
|
||||
"wickra-linux-arm64-gnu": "0.5.8",
|
||||
"wickra-linux-x64-gnu": "0.5.8",
|
||||
"wickra-win32-arm64-msvc": "0.5.8",
|
||||
"wickra-win32-x64-msvc": "0.5.8"
|
||||
"wickra-darwin-arm64": "0.6.3",
|
||||
"wickra-darwin-x64": "0.6.3",
|
||||
"wickra-linux-arm64-gnu": "0.6.3",
|
||||
"wickra-linux-x64-gnu": "0.6.3",
|
||||
"wickra-win32-arm64-msvc": "0.6.3",
|
||||
"wickra-win32-x64-msvc": "0.6.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/cli": {
|
||||
@@ -41,8 +41,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-arm64": {
|
||||
"version": "0.5.8",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.8.tgz",
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.6.3.tgz",
|
||||
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -57,8 +57,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-x64": {
|
||||
"version": "0.5.8",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.8.tgz",
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.6.3.tgz",
|
||||
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -73,8 +73,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-arm64-gnu": {
|
||||
"version": "0.5.8",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.8.tgz",
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.6.3.tgz",
|
||||
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -89,8 +89,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-x64-gnu": {
|
||||
"version": "0.5.8",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.8.tgz",
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.6.3.tgz",
|
||||
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -105,8 +105,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-arm64-msvc": {
|
||||
"version": "0.5.8",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.8.tgz",
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.6.3.tgz",
|
||||
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -121,8 +121,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-x64-msvc": {
|
||||
"version": "0.5.8",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.8.tgz",
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.6.3.tgz",
|
||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.5.8",
|
||||
"version": "0.6.3",
|
||||
"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.8",
|
||||
"wickra-linux-arm64-gnu": "0.5.8",
|
||||
"wickra-darwin-x64": "0.5.8",
|
||||
"wickra-darwin-arm64": "0.5.8",
|
||||
"wickra-win32-x64-msvc": "0.5.8",
|
||||
"wickra-win32-arm64-msvc": "0.5.8"
|
||||
"wickra-linux-x64-gnu": "0.6.3",
|
||||
"wickra-linux-arm64-gnu": "0.6.3",
|
||||
"wickra-darwin-x64": "0.6.3",
|
||||
"wickra-darwin-arm64": "0.6.3",
|
||||
"wickra-win32-x64-msvc": "0.6.3",
|
||||
"wickra-win32-arm64-msvc": "0.6.3"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "napi build --platform --release",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.8"
|
||||
version = "0.6.3"
|
||||
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,14 @@ from __future__ import annotations
|
||||
|
||||
from ._wickra import (
|
||||
__version__,
|
||||
TimeBasedStop,
|
||||
ProjectionOscillator,
|
||||
VolatilityCone,
|
||||
VolatilityRatio,
|
||||
BipowerVariation,
|
||||
VolatilityOfVolatility,
|
||||
Garch11,
|
||||
EwmaVolatility,
|
||||
PpoHistogram,
|
||||
MacdHistogram,
|
||||
TsfOscillator,
|
||||
@@ -161,6 +169,11 @@ from ._wickra import (
|
||||
HistoricalVolatility,
|
||||
BollingerBandwidth,
|
||||
PercentB,
|
||||
# Trailing Stops
|
||||
ModifiedMaStop,
|
||||
Nrtr,
|
||||
AtrRatchet,
|
||||
ElderSafeZone,
|
||||
SuperTrend,
|
||||
ChandelierExit,
|
||||
ChandeKrollStop,
|
||||
@@ -172,6 +185,7 @@ from ._wickra import (
|
||||
PercentageTrailingStop,
|
||||
StepTrailingStop,
|
||||
RenkoTrailingStop,
|
||||
KaseDevStop,
|
||||
TrueRange,
|
||||
ChaikinVolatility,
|
||||
RVIVolatility,
|
||||
@@ -180,6 +194,13 @@ from ._wickra import (
|
||||
RogersSatchellVolatility,
|
||||
YangZhangVolatility,
|
||||
# Volume
|
||||
VolumeWeightedMacd,
|
||||
BetterVolume,
|
||||
IntradayIntensity,
|
||||
TradeVolumeIndex,
|
||||
TwiggsMoneyFlow,
|
||||
Wad,
|
||||
VolumeRsi,
|
||||
OBV,
|
||||
VWAP,
|
||||
RollingVWAP,
|
||||
@@ -257,6 +278,10 @@ from ._wickra import (
|
||||
MAMA,
|
||||
FAMA,
|
||||
# Bands & Channels
|
||||
ProjectionBands,
|
||||
MedianChannel,
|
||||
BomarBands,
|
||||
QuartileBands,
|
||||
MaEnvelope,
|
||||
AccelerationBands,
|
||||
StarcBands,
|
||||
@@ -476,6 +501,14 @@ from ._wickra import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"TimeBasedStop",
|
||||
"ProjectionOscillator",
|
||||
"VolatilityCone",
|
||||
"VolatilityRatio",
|
||||
"BipowerVariation",
|
||||
"VolatilityOfVolatility",
|
||||
"Garch11",
|
||||
"EwmaVolatility",
|
||||
"PpoHistogram",
|
||||
"MacdHistogram",
|
||||
"TsfOscillator",
|
||||
@@ -613,6 +646,11 @@ __all__ = [
|
||||
"HistoricalVolatility",
|
||||
"BollingerBandwidth",
|
||||
"PercentB",
|
||||
# Trailing Stops
|
||||
"ModifiedMaStop",
|
||||
"Nrtr",
|
||||
"AtrRatchet",
|
||||
"ElderSafeZone",
|
||||
"SuperTrend",
|
||||
"ChandelierExit",
|
||||
"ChandeKrollStop",
|
||||
@@ -624,6 +662,7 @@ __all__ = [
|
||||
"PercentageTrailingStop",
|
||||
"StepTrailingStop",
|
||||
"RenkoTrailingStop",
|
||||
"KaseDevStop",
|
||||
"TrueRange",
|
||||
"ChaikinVolatility",
|
||||
"RVIVolatility",
|
||||
@@ -632,6 +671,13 @@ __all__ = [
|
||||
"RogersSatchellVolatility",
|
||||
"YangZhangVolatility",
|
||||
# Volume
|
||||
"VolumeWeightedMacd",
|
||||
"BetterVolume",
|
||||
"IntradayIntensity",
|
||||
"TradeVolumeIndex",
|
||||
"TwiggsMoneyFlow",
|
||||
"Wad",
|
||||
"VolumeRsi",
|
||||
"OBV",
|
||||
"VWAP",
|
||||
"RollingVWAP",
|
||||
@@ -709,6 +755,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,10 @@ 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,)),
|
||||
@@ -169,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),
|
||||
@@ -361,6 +368,33 @@ def test_relative_strength_streaming_matches_batch():
|
||||
# 6-tuple candle; the batch helper takes only the columns it needs.
|
||||
|
||||
CANDLE_SCALAR = {
|
||||
"BetterVolume": (
|
||||
lambda: ta.BetterVolume(14),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
|
||||
),
|
||||
"IntradayIntensity": (
|
||||
lambda: ta.IntradayIntensity(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
|
||||
),
|
||||
"TradeVolumeIndex": (
|
||||
lambda: ta.TradeVolumeIndex(0.25),
|
||||
lambda ind, h, l, c, v: ind.batch(c, v),
|
||||
),
|
||||
"TwiggsMoneyFlow": (
|
||||
lambda: ta.TwiggsMoneyFlow(21),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
|
||||
),
|
||||
"Wad": (
|
||||
lambda: ta.Wad(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"VolumeRsi": (
|
||||
lambda: ta.VolumeRsi(14),
|
||||
lambda ind, h, l, c, v: ind.batch(c, v),
|
||||
),
|
||||
"TimeBasedStop": (lambda: ta.TimeBasedStop(5), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
"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
|
||||
@@ -899,6 +933,46 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
|
||||
# --- Candle-input, multi-output indicators --------------------------------
|
||||
|
||||
MULTI = {
|
||||
"VolumeWeightedMacd": (
|
||||
lambda: ta.VolumeWeightedMacd(12, 26, 9),
|
||||
lambda ind, h, l, c, v: ind.batch(c, v),
|
||||
3,
|
||||
),
|
||||
"ModifiedMaStop": (
|
||||
lambda: ta.ModifiedMaStop(14),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
2,
|
||||
),
|
||||
"Nrtr": (
|
||||
lambda: ta.Nrtr(2.0),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
2,
|
||||
),
|
||||
"AtrRatchet": (
|
||||
lambda: ta.AtrRatchet(14, 4.0, 0.1),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
2,
|
||||
),
|
||||
"ElderSafeZone": (
|
||||
lambda: ta.ElderSafeZone(14, 2.0),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
2,
|
||||
),
|
||||
"KaseDevStop": (
|
||||
lambda: ta.KaseDevStop(3, 1.0),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
2,
|
||||
),
|
||||
"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),
|
||||
@@ -1535,7 +1609,7 @@ def test_kvo_constant_series_is_zero():
|
||||
assert v == pytest.approx(0.0, abs=1e-12)
|
||||
|
||||
|
||||
def test_williams_ad_reference():
|
||||
def test_wad_reference():
|
||||
# bar 0 seeds prev_close = 10.
|
||||
# bar 1: prev=10, today high=13, low=8, close=12 (up day).
|
||||
# TR_l = min(10, 8) = 8 -> delta = 12 - 8 = 4. AD = 4.
|
||||
@@ -2890,6 +2964,135 @@ def test_ppo_histogram_reference():
|
||||
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)
|
||||
|
||||
|
||||
def test_kase_devstop_reference():
|
||||
t = ta.KaseDevStop(3, 1.0)
|
||||
assert t.update((100.0, 101.0, 99.0, 100.0, 1.0, 0)) is None
|
||||
assert t.update((101.0, 102.0, 100.0, 101.0, 1.0, 1)) is None
|
||||
assert t.update((102.0, 103.0, 101.0, 102.0, 1.0, 2)) is None
|
||||
assert t.update((102.5, 104.0, 102.0, 103.0, 1.0, 3)) == pytest.approx((101.0, 1.0))
|
||||
|
||||
|
||||
def _stop_candles(n):
|
||||
# Gently rising, valid OHLC: high >= open/close, low <= open/close.
|
||||
return [(100.0 + i, 101.5 + i, 98.5 + i, 100.5 + i, 1.0, i) for i in range(n)]
|
||||
|
||||
|
||||
def test_elder_safezone_reference():
|
||||
t = ta.ElderSafeZone(14, 2.0)
|
||||
candles = _stop_candles(15)
|
||||
for c in candles[:14]:
|
||||
assert t.update(c) is None
|
||||
assert t.update(candles[14]) == pytest.approx((112.5, 1.0))
|
||||
|
||||
|
||||
def test_atr_ratchet_reference():
|
||||
t = ta.AtrRatchet(14, 4.0, 0.1)
|
||||
candles = _stop_candles(14)
|
||||
for c in candles[:13]:
|
||||
assert t.update(c) is None
|
||||
assert t.update(candles[13]) == pytest.approx((101.5, 1.0))
|
||||
|
||||
|
||||
def test_nrtr_reference():
|
||||
t = ta.Nrtr(2.0)
|
||||
assert t.update((100.0, 100.0, 100.0, 100.0, 1.0, 0)) == pytest.approx((98.0, 1.0))
|
||||
|
||||
|
||||
def test_time_based_stop_reference():
|
||||
t = ta.TimeBasedStop(5)
|
||||
assert t.update((100.0, 101.0, 99.0, 100.0, 1.0, 0)) == pytest.approx(0.2)
|
||||
|
||||
|
||||
def test_modified_ma_stop_reference():
|
||||
t = ta.ModifiedMaStop(14)
|
||||
candles = _stop_candles(14)
|
||||
for c in candles[:13]:
|
||||
assert t.update(c) is None
|
||||
assert t.update(candles[13]) == pytest.approx((107.0, 1.0))
|
||||
|
||||
|
||||
def test_volume_rsi_reference():
|
||||
t = ta.VolumeRsi(14)
|
||||
|
||||
|
||||
def test_twiggs_money_flow_reference():
|
||||
t = ta.TwiggsMoneyFlow(21)
|
||||
|
||||
|
||||
def test_trade_volume_index_reference():
|
||||
t = ta.TradeVolumeIndex(0.25)
|
||||
|
||||
|
||||
def test_intraday_intensity_reference():
|
||||
t = ta.IntradayIntensity()
|
||||
|
||||
|
||||
def test_better_volume_reference():
|
||||
t = ta.BetterVolume(14)
|
||||
|
||||
|
||||
def test_volume_weighted_macd_reference():
|
||||
t = ta.VolumeWeightedMacd(12, 26, 9)
|
||||
|
||||
# --- Lifecycle ------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,279 @@
|
||||
//! ATR Ratchet (Kaufman) — a trailing stop that creeps toward price each bar.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::atr::Atr;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`AtrRatchet`]: the active stop level and the trend direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AtrRatchetOutput {
|
||||
/// The ratchet stop level — below price when long, above price when short.
|
||||
pub value: f64,
|
||||
/// Trend direction: `+1.0` long, `-1.0` short.
|
||||
pub direction: f64,
|
||||
}
|
||||
|
||||
/// ATR Ratchet — Perry Kaufman's time-based volatility stop that tightens by a
|
||||
/// fixed fraction of ATR **every bar**, whether or not price moves.
|
||||
///
|
||||
/// ```text
|
||||
/// on entry (long): stop = close − start_mult · ATR
|
||||
/// each later bar: stop = stop + increment · ATR (ratchets toward price)
|
||||
/// flip to short when close < stop, reseeding stop = close + start_mult · ATR
|
||||
/// ```
|
||||
///
|
||||
/// Most trailing stops only move when price makes a new extreme. Kaufman's ratchet
|
||||
/// instead advances the stop a little each bar — `increment · ATR` — so a trade
|
||||
/// that stalls is squeezed out over time even in a flat market. The initial
|
||||
/// distance (`start_mult · ATR`) gives the position room to breathe; the per-bar
|
||||
/// `increment` controls how aggressively the leash shortens. When price closes
|
||||
/// through the stop the system reverses and reseeds at the full initial distance.
|
||||
///
|
||||
/// The first stop lands once ATR is ready (`atr_period` inputs). Each `update` is
|
||||
/// O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AtrRatchet};
|
||||
///
|
||||
/// let mut indicator = AtrRatchet::new(14, 4.0, 0.1).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AtrRatchet {
|
||||
atr: Atr,
|
||||
atr_period: usize,
|
||||
start_mult: f64,
|
||||
increment: f64,
|
||||
direction: f64,
|
||||
stop: f64,
|
||||
last: Option<AtrRatchetOutput>,
|
||||
}
|
||||
|
||||
impl AtrRatchet {
|
||||
/// Construct an ATR Ratchet stop.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `atr_period == 0` and
|
||||
/// [`Error::NonPositiveMultiplier`] if `start_mult` or `increment` is not
|
||||
/// finite and positive.
|
||||
pub fn new(atr_period: usize, start_mult: f64, increment: f64) -> Result<Self> {
|
||||
if !start_mult.is_finite()
|
||||
|| start_mult <= 0.0
|
||||
|| !increment.is_finite()
|
||||
|| increment <= 0.0
|
||||
{
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
atr: Atr::new(atr_period)?,
|
||||
atr_period,
|
||||
start_mult,
|
||||
increment,
|
||||
direction: 0.0,
|
||||
stop: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(atr_period, start_mult, increment)`.
|
||||
pub const fn params(&self) -> (usize, f64, f64) {
|
||||
(self.atr_period, self.start_mult, self.increment)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<AtrRatchetOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AtrRatchet {
|
||||
type Input = Candle;
|
||||
type Output = AtrRatchetOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<AtrRatchetOutput> {
|
||||
let atr = self.atr.update(candle)?;
|
||||
let close = candle.close;
|
||||
|
||||
if self.direction == 0.0 {
|
||||
self.direction = 1.0;
|
||||
self.stop = close - self.start_mult * atr;
|
||||
} else if self.direction > 0.0 {
|
||||
self.stop += self.increment * atr;
|
||||
if close < self.stop {
|
||||
self.direction = -1.0;
|
||||
self.stop = close + self.start_mult * atr;
|
||||
}
|
||||
} else {
|
||||
self.stop -= self.increment * atr;
|
||||
if close > self.stop {
|
||||
self.direction = 1.0;
|
||||
self.stop = close - self.start_mult * atr;
|
||||
}
|
||||
}
|
||||
|
||||
let out = AtrRatchetOutput {
|
||||
value: self.stop,
|
||||
direction: self.direction,
|
||||
};
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.atr.reset();
|
||||
self.direction = 0.0;
|
||||
self.stop = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.atr_period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AtrRatchet"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(matches!(
|
||||
AtrRatchet::new(0, 4.0, 0.1),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
AtrRatchet::new(14, 0.0, 0.1),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
AtrRatchet::new(14, 4.0, 0.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
AtrRatchet::new(14, 4.0, f64::NAN),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let r = AtrRatchet::new(14, 4.0, 0.1).unwrap();
|
||||
assert_eq!(r.params(), (14, 4.0, 0.1));
|
||||
assert_eq!(r.warmup_period(), 14);
|
||||
assert_eq!(r.name(), "AtrRatchet");
|
||||
assert!(!r.is_ready());
|
||||
assert_eq!(r.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut r = AtrRatchet::new(5, 4.0, 0.1).unwrap();
|
||||
let candles: Vec<Candle> = (0..12)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base)
|
||||
})
|
||||
.collect();
|
||||
let out = r.batch(&candles);
|
||||
for v in out.iter().take(4) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_keeps_stop_below_price() {
|
||||
let mut r = AtrRatchet::new(5, 4.0, 0.05).unwrap();
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let base = 100.0 + 2.0 * f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
for (o, candle) in r.batch(&candles).into_iter().zip(candles.iter()) {
|
||||
if let Some(o) = o {
|
||||
assert_eq!(o.direction, 1.0);
|
||||
assert!(o.value < candle.close);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stall_eventually_triggers_flip() {
|
||||
// A long trend then a long flat stretch: the ratchet creeps up each bar
|
||||
// and eventually overtakes the flat close, flipping to short.
|
||||
let mut r = AtrRatchet::new(5, 2.0, 0.5).unwrap();
|
||||
let mut candles: Vec<Candle> = (0..20)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
// Flat stretch at the last price.
|
||||
candles.extend((0..40).map(|_| c(120.6, 118.6, 119.5)));
|
||||
let dirs: Vec<f64> = r
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|o| o.direction)
|
||||
.collect();
|
||||
assert!(
|
||||
dirs.iter().any(|&d| d < 0.0),
|
||||
"the ratchet should eventually flip short"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut r = AtrRatchet::new(5, 4.0, 0.1).unwrap();
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
r.batch(&candles);
|
||||
assert!(r.is_ready());
|
||||
r.reset();
|
||||
assert!(!r.is_ready());
|
||||
assert_eq!(r.value(), None);
|
||||
assert_eq!(r.update(candles[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;
|
||||
c(base + 2.0, base - 1.5, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let batch = AtrRatchet::new(14, 4.0, 0.1).unwrap().batch(&candles);
|
||||
let mut b = AtrRatchet::new(14, 4.0, 0.1).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Better Volume (VSA) — a streaming effort-versus-result oscillator.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Better Volume — a Volume-Spread-Analysis (VSA) "effort versus result"
|
||||
/// oscillator: how much volume (effort) a bar spent relative to the price range
|
||||
/// (result) it achieved, both normalised against their own recent averages.
|
||||
///
|
||||
/// ```text
|
||||
/// range_t = high_t − low_t
|
||||
/// rel_vol = volume_t / SMA(volume, period)
|
||||
/// rel_range = range_t / SMA(range, period)
|
||||
/// BetterVol = rel_vol − rel_range
|
||||
/// ```
|
||||
///
|
||||
/// Volume-Spread Analysis (Wyckoff, popularised by Tom Williams) reads markets
|
||||
/// through the relationship between **effort** (volume) and **result** (the bar's
|
||||
/// spread). A bar with heavy volume but a narrow range — `rel_vol` high while
|
||||
/// `rel_range` low, so the oscillator is **positive** — is *churn*: large effort
|
||||
/// produced little movement, the hallmark of absorption (supply meeting demand at
|
||||
/// a top, or vice versa at a bottom). A bar that travels far on light volume —
|
||||
/// negative oscillator — shows *ease of movement*, a trend meeting no resistance.
|
||||
///
|
||||
/// Both legs are normalised by their `period` simple moving averages (including
|
||||
/// the current bar), so the output is centred near `0` and self-scales to the
|
||||
/// instrument. A degenerate average of `0` makes its leg `0` rather than dividing
|
||||
/// by zero. The first value lands after `period` inputs. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, BetterVolume};
|
||||
///
|
||||
/// let mut indicator = BetterVolume::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BetterVolume {
|
||||
period: usize,
|
||||
volumes: VecDeque<f64>,
|
||||
ranges: VecDeque<f64>,
|
||||
vol_sum: f64,
|
||||
range_sum: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl BetterVolume {
|
||||
/// Construct a new Better Volume oscillator with the given averaging `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
volumes: VecDeque::with_capacity(period),
|
||||
ranges: VecDeque::with_capacity(period),
|
||||
vol_sum: 0.0,
|
||||
range_sum: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured averaging 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 BetterVolume {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let range = candle.high - candle.low;
|
||||
if self.volumes.len() == self.period {
|
||||
self.vol_sum -= self.volumes.pop_front().expect("non-empty");
|
||||
self.range_sum -= self.ranges.pop_front().expect("non-empty");
|
||||
}
|
||||
self.volumes.push_back(candle.volume);
|
||||
self.ranges.push_back(range);
|
||||
self.vol_sum += candle.volume;
|
||||
self.range_sum += range;
|
||||
if self.volumes.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let sma_vol = self.vol_sum / n;
|
||||
let sma_range = self.range_sum / n;
|
||||
let rel_vol = if sma_vol > 0.0 {
|
||||
candle.volume / sma_vol
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let rel_range = if sma_range > 0.0 {
|
||||
range / sma_range
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let out = rel_vol - rel_range;
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.volumes.clear();
|
||||
self.ranges.clear();
|
||||
self.vol_sum = 0.0;
|
||||
self.range_sum = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"BetterVolume"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, volume: f64) -> Candle {
|
||||
Candle::new_unchecked(low, high, low, high, volume, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(BetterVolume::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let bv = BetterVolume::new(20).unwrap();
|
||||
assert_eq!(bv.period(), 20);
|
||||
assert_eq!(bv.warmup_period(), 20);
|
||||
assert_eq!(bv.name(), "BetterVolume");
|
||||
assert!(!bv.is_ready());
|
||||
assert_eq!(bv.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut bv = BetterVolume::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
|
||||
let out = bv.batch(&candles);
|
||||
for v in out.iter().take(2) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steady_bars_are_neutral() {
|
||||
// Identical volume and range every bar -> rel_vol = rel_range = 1 -> 0.
|
||||
let mut bv = BetterVolume::new(4).unwrap();
|
||||
let candles: Vec<Candle> = (0..10).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
|
||||
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn churn_bar_is_positive() {
|
||||
// Three normal bars, then a high-volume narrow-range bar -> positive.
|
||||
let mut bv = BetterVolume::new(4).unwrap();
|
||||
let mut candles: Vec<Candle> = (0..3).map(|_| candle(105.0, 100.0, 1_000.0)).collect();
|
||||
candles.push(candle(100.5, 100.0, 5_000.0)); // huge volume, tiny range
|
||||
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(last > 0.0, "churn bar should be positive, got {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ease_of_movement_bar_is_negative() {
|
||||
// Three normal bars, then a wide-range light-volume bar -> negative.
|
||||
let mut bv = BetterVolume::new(4).unwrap();
|
||||
let mut candles: Vec<Candle> = (0..3).map(|_| candle(101.0, 100.0, 5_000.0)).collect();
|
||||
candles.push(candle(115.0, 100.0, 500.0)); // wide range, tiny volume
|
||||
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last < 0.0,
|
||||
"ease-of-movement bar should be negative, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_everything_is_zero() {
|
||||
// Zero volume and zero range -> both legs guarded to 0.
|
||||
let mut bv = BetterVolume::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|_| candle(100.0, 100.0, 0.0)).collect();
|
||||
for v in bv.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut bv = BetterVolume::new(3).unwrap();
|
||||
bv.batch(
|
||||
&(0..6)
|
||||
.map(|_| candle(102.0, 100.0, 1_000.0))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(bv.is_ready());
|
||||
bv.reset();
|
||||
assert!(!bv.is_ready());
|
||||
assert_eq!(bv.value(), None);
|
||||
assert_eq!(bv.update(candle(102.0, 100.0, 1_000.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,
|
||||
1_000.0 + (f64::from(i) * 0.5).cos() * 400.0,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let batch = BetterVolume::new(20).unwrap().batch(&candles);
|
||||
let mut b = BetterVolume::new(20).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
//! Elder `SafeZone` Stop — a trailing stop set by the average noise penetration.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`ElderSafeZone`]: the active stop level and the trend direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ElderSafeZoneOutput {
|
||||
/// The `SafeZone` stop level — below price when long, above price when short.
|
||||
pub value: f64,
|
||||
/// Trend direction: `+1.0` long, `-1.0` short.
|
||||
pub direction: f64,
|
||||
}
|
||||
|
||||
/// Elder `SafeZone` Stop — Alexander Elder's stop placed a multiple of the
|
||||
/// **average market noise** away from price.
|
||||
///
|
||||
/// ```text
|
||||
/// long market noise = average downside penetration = mean( prev_low − low | low < prev_low )
|
||||
/// short market noise = average upside penetration = mean( high − prev_high | high > prev_high )
|
||||
/// long stop = ratchet_up( low_t − coeff · avg_down_penetration )
|
||||
/// short stop = ratchet_down( high_t + coeff · avg_up_penetration )
|
||||
/// ```
|
||||
///
|
||||
/// Elder defines *noise* in an uptrend as the part of each bar that pokes below
|
||||
/// the previous bar's low (a "downside penetration"). Averaging those
|
||||
/// penetrations over a lookback and placing the stop `coeff` multiples below the
|
||||
/// current low keeps the stop just outside normal pullbacks while still exiting on
|
||||
/// a genuine reversal. The stop trails in the trend's favour and flips when price
|
||||
/// closes through it. The average uses only the bars that actually penetrated
|
||||
/// (Elder's definition), so a noiseless trend gives a tight stop at the bar's
|
||||
/// extreme.
|
||||
///
|
||||
/// The first bar seeds the prior candle; the next `period` bars accumulate the
|
||||
/// penetration statistics, so the first stop lands after `period + 1` inputs.
|
||||
/// Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ElderSafeZone};
|
||||
///
|
||||
/// let mut indicator = ElderSafeZone::new(14, 2.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ElderSafeZone {
|
||||
period: usize,
|
||||
coeff: f64,
|
||||
prev: Option<Candle>,
|
||||
down_pen: VecDeque<f64>,
|
||||
up_pen: VecDeque<f64>,
|
||||
down_sum: f64,
|
||||
up_sum: f64,
|
||||
down_count: usize,
|
||||
up_count: usize,
|
||||
direction: f64,
|
||||
stop: f64,
|
||||
last: Option<ElderSafeZoneOutput>,
|
||||
}
|
||||
|
||||
impl ElderSafeZone {
|
||||
/// Construct an Elder `SafeZone` stop with the given averaging `period` and
|
||||
/// noise `coeff`icient.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
||||
/// [`Error::NonPositiveMultiplier`] if `coeff` is not finite and positive.
|
||||
pub fn new(period: usize, coeff: f64) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if !coeff.is_finite() || coeff <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
coeff,
|
||||
prev: None,
|
||||
down_pen: VecDeque::with_capacity(period),
|
||||
up_pen: VecDeque::with_capacity(period),
|
||||
down_sum: 0.0,
|
||||
up_sum: 0.0,
|
||||
down_count: 0,
|
||||
up_count: 0,
|
||||
direction: 0.0,
|
||||
stop: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, coeff)`.
|
||||
pub const fn params(&self) -> (usize, f64) {
|
||||
(self.period, self.coeff)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<ElderSafeZoneOutput> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn push(window: &mut VecDeque<f64>, sum: &mut f64, count: &mut usize, period: usize, pen: f64) {
|
||||
if window.len() == period {
|
||||
let old = window.pop_front().expect("non-empty");
|
||||
*sum -= old;
|
||||
if old > 0.0 {
|
||||
*count -= 1;
|
||||
}
|
||||
}
|
||||
window.push_back(pen);
|
||||
*sum += pen;
|
||||
if pen > 0.0 {
|
||||
*count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn avg(sum: f64, count: usize) -> f64 {
|
||||
if count == 0 {
|
||||
0.0
|
||||
} else {
|
||||
sum / count as f64
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ElderSafeZone {
|
||||
type Input = Candle;
|
||||
type Output = ElderSafeZoneOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<ElderSafeZoneOutput> {
|
||||
let Some(prev) = self.prev else {
|
||||
self.prev = Some(candle);
|
||||
return None;
|
||||
};
|
||||
let dp = (prev.low - candle.low).max(0.0);
|
||||
let up = (candle.high - prev.high).max(0.0);
|
||||
self.prev = Some(candle);
|
||||
|
||||
Self::push(
|
||||
&mut self.down_pen,
|
||||
&mut self.down_sum,
|
||||
&mut self.down_count,
|
||||
self.period,
|
||||
dp,
|
||||
);
|
||||
Self::push(
|
||||
&mut self.up_pen,
|
||||
&mut self.up_sum,
|
||||
&mut self.up_count,
|
||||
self.period,
|
||||
up,
|
||||
);
|
||||
if self.down_pen.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
|
||||
let avg_down = Self::avg(self.down_sum, self.down_count);
|
||||
let avg_up = Self::avg(self.up_sum, self.up_count);
|
||||
|
||||
if self.direction == 0.0 {
|
||||
self.direction = 1.0;
|
||||
self.stop = candle.low - self.coeff * avg_down;
|
||||
} else if self.direction > 0.0 {
|
||||
let raw = candle.low - self.coeff * avg_down;
|
||||
self.stop = self.stop.max(raw);
|
||||
if candle.close < self.stop {
|
||||
self.direction = -1.0;
|
||||
self.stop = candle.high + self.coeff * avg_up;
|
||||
}
|
||||
} else {
|
||||
let raw = candle.high + self.coeff * avg_up;
|
||||
self.stop = self.stop.min(raw);
|
||||
if candle.close > self.stop {
|
||||
self.direction = 1.0;
|
||||
self.stop = candle.low - self.coeff * avg_down;
|
||||
}
|
||||
}
|
||||
|
||||
let out = ElderSafeZoneOutput {
|
||||
value: self.stop,
|
||||
direction: self.direction,
|
||||
};
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.down_pen.clear();
|
||||
self.up_pen.clear();
|
||||
self.down_sum = 0.0;
|
||||
self.up_sum = 0.0;
|
||||
self.down_count = 0;
|
||||
self.up_count = 0;
|
||||
self.direction = 0.0;
|
||||
self.stop = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ElderSafeZone"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(matches!(ElderSafeZone::new(0, 2.0), Err(Error::PeriodZero)));
|
||||
assert!(matches!(
|
||||
ElderSafeZone::new(14, 0.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
ElderSafeZone::new(14, -1.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let e = ElderSafeZone::new(14, 2.0).unwrap();
|
||||
assert_eq!(e.params(), (14, 2.0));
|
||||
assert_eq!(e.warmup_period(), 15);
|
||||
assert_eq!(e.name(), "ElderSafeZone");
|
||||
assert!(!e.is_ready());
|
||||
assert_eq!(e.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut e = ElderSafeZone::new(3, 2.0).unwrap();
|
||||
let candles: Vec<Candle> = (0..8)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base)
|
||||
})
|
||||
.collect();
|
||||
let out = e.batch(&candles);
|
||||
let warmup = e.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 uptrend_keeps_stop_below_price() {
|
||||
let mut e = ElderSafeZone::new(5, 2.0).unwrap();
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let base = 100.0 + 2.0 * f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
for (o, candle) in e.batch(&candles).into_iter().zip(candles.iter()) {
|
||||
if let Some(o) = o {
|
||||
assert_eq!(o.direction, 1.0);
|
||||
assert!(o.value <= candle.close);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noiseless_trend_stop_sits_at_low() {
|
||||
// Every bar makes a higher low -> no downside penetration -> avg 0 ->
|
||||
// the stop sits exactly at the bar's low.
|
||||
let mut e = ElderSafeZone::new(3, 2.0).unwrap();
|
||||
let candles: Vec<Candle> = (0..10)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let out = e.batch(&candles);
|
||||
let last_candle = candles.last().unwrap();
|
||||
let last = out.last().unwrap().unwrap();
|
||||
assert!((last.value - last_candle.low).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flips_on_reversal() {
|
||||
let mut candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
candles.extend((0..40).map(|i| {
|
||||
let base = 140.0 - f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base - 0.5)
|
||||
}));
|
||||
let mut e = ElderSafeZone::new(5, 2.0).unwrap();
|
||||
let dirs: Vec<f64> = e
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|o| o.direction)
|
||||
.collect();
|
||||
assert!(dirs.iter().any(|&d| d > 0.0));
|
||||
assert!(dirs.iter().any(|&d| d < 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut e = ElderSafeZone::new(5, 2.0).unwrap();
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
e.batch(&candles);
|
||||
assert!(e.is_ready());
|
||||
e.reset();
|
||||
assert!(!e.is_ready());
|
||||
assert_eq!(e.value(), None);
|
||||
assert_eq!(e.update(candles[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;
|
||||
c(base + 2.0, base - 1.5, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let batch = ElderSafeZone::new(14, 2.0).unwrap().batch(&candles);
|
||||
let mut b = ElderSafeZone::new(14, 2.0).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -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,185 @@
|
||||
//! Intraday Intensity Index (Bostian) — a cumulative volume-weighted close-location line.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Intraday Intensity Index — David Bostian's cumulative line that weights each
|
||||
/// bar's volume by where the close lands inside the bar's range.
|
||||
///
|
||||
/// ```text
|
||||
/// II_t = volume * (2*close − high − low) / (high − low) (0 if high == low)
|
||||
/// III_t = III_{t−1} + II_t
|
||||
/// ```
|
||||
///
|
||||
/// The fraction `(2*close − high − low) / (high − low)` is `+1` when the bar
|
||||
/// closes on its high, `−1` when it closes on its low, and `0` at the midpoint.
|
||||
/// Scaling it by volume and accumulating produces a running measure of how
|
||||
/// aggressively the close is being pushed toward the extremes — Bostian's proxy
|
||||
/// for institutional accumulation (rising line) or distribution (falling line).
|
||||
///
|
||||
/// This is the **cumulative** Intraday Intensity (the original index), not the
|
||||
/// normalized "Intraday Intensity %" — the latter divides a windowed sum of `II`
|
||||
/// by a windowed sum of volume and is mathematically identical to
|
||||
/// [`Cmf`](crate::Cmf), so it is not duplicated here. The level of this line is
|
||||
/// arbitrary; only its slope and divergences against price matter. A doji whose
|
||||
/// `high == low` contributes nothing. Each `update` is O(1) and the first bar
|
||||
/// already emits a value.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, IntradayIntensity};
|
||||
///
|
||||
/// let mut indicator = IntradayIntensity::new();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..20 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.9, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct IntradayIntensity {
|
||||
iii: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl IntradayIntensity {
|
||||
/// Construct a new Intraday Intensity Index. The line is parameter-free.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for IntradayIntensity {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let range = candle.high - candle.low;
|
||||
let ii = if range > 0.0 {
|
||||
candle.volume * (2.0 * candle.close - candle.high - candle.low) / range
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.iii += ii;
|
||||
self.last = Some(self.iii);
|
||||
Some(self.iii)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.iii = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"IntradayIntensity"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64, volume: f64) -> Candle {
|
||||
Candle::new_unchecked(low, high, low, close, volume, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let iii = IntradayIntensity::new();
|
||||
assert_eq!(iii.warmup_period(), 1);
|
||||
assert_eq!(iii.name(), "IntradayIntensity");
|
||||
assert!(!iii.is_ready());
|
||||
assert_eq!(iii.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_emits() {
|
||||
// close at the high: (2*101 - 102 - 100)/(2) = 0/... wait, high=102 low=100 close=101 -> 0.
|
||||
let mut iii = IntradayIntensity::new();
|
||||
// close on the high -> +1 * volume.
|
||||
let v = iii.update(candle(102.0, 100.0, 102.0, 500.0)).unwrap();
|
||||
assert_relative_eq!(v, 500.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_on_high_adds_full_volume() {
|
||||
let mut iii = IntradayIntensity::new();
|
||||
let v = iii.update(candle(110.0, 100.0, 110.0, 1_000.0)).unwrap();
|
||||
assert_relative_eq!(v, 1_000.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_on_low_subtracts_full_volume() {
|
||||
let mut iii = IntradayIntensity::new();
|
||||
let v = iii.update(candle(110.0, 100.0, 100.0, 1_000.0)).unwrap();
|
||||
assert_relative_eq!(v, -1_000.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_at_midpoint_adds_nothing() {
|
||||
let mut iii = IntradayIntensity::new();
|
||||
let v = iii.update(candle(110.0, 100.0, 105.0, 1_000.0)).unwrap();
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_adds_nothing() {
|
||||
let mut iii = IntradayIntensity::new();
|
||||
let v = iii.update(candle(100.0, 100.0, 100.0, 1_000.0)).unwrap();
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accumulates_across_bars() {
|
||||
let mut iii = IntradayIntensity::new();
|
||||
iii.update(candle(110.0, 100.0, 110.0, 1_000.0)); // +1000
|
||||
let v = iii.update(candle(110.0, 100.0, 100.0, 400.0)).unwrap(); // -400 -> 600
|
||||
assert_relative_eq!(v, 600.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut iii = IntradayIntensity::new();
|
||||
iii.batch(&[
|
||||
candle(110.0, 100.0, 108.0, 1.0),
|
||||
candle(110.0, 100.0, 102.0, 1.0),
|
||||
]);
|
||||
assert!(iii.is_ready());
|
||||
iii.reset();
|
||||
assert!(!iii.is_ready());
|
||||
assert_eq!(iii.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.3).sin() * 6.0;
|
||||
candle(base + 2.0, base - 2.0, base + 0.7, 1_000.0 + f64::from(i))
|
||||
})
|
||||
.collect();
|
||||
let batch = IntradayIntensity::new().batch(&candles);
|
||||
let mut b = IntradayIntensity::new();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
//! Kase `DevStop` — a volatility trailing stop on the standard deviation of the
|
||||
//! two-bar true range.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`KaseDevStop`]: the active trailing-stop level and the trend
|
||||
/// direction it protects.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct KaseDevStopOutput {
|
||||
/// The `DevStop` level — below price in an uptrend, above price in a downtrend.
|
||||
pub value: f64,
|
||||
/// Trend direction: `+1.0` long (stop below price), `-1.0` short.
|
||||
pub direction: 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;
|
||||
(((sum_sq - n * mean * mean) / (n - 1.0)).max(0.0)).sqrt()
|
||||
}
|
||||
|
||||
/// Kase `DevStop` — Cynthia Kase's volatility stop, built on the **standard
|
||||
/// deviation of the two-bar true range** rather than a single-bar ATR.
|
||||
///
|
||||
/// ```text
|
||||
/// DTR_t = max(high_t, high_{t−1}) − min(low_t, low_{t−1}) (two-bar range)
|
||||
/// band = mean(DTR, period) + dev · stddev(DTR, period)
|
||||
/// long stop = ratchet_up( highest_high_since_flip − band )
|
||||
/// short stop = ratchet_down( lowest_low_since_flip + band )
|
||||
/// ```
|
||||
///
|
||||
/// Kase observed that range expansion is better captured by a two-bar range than
|
||||
/// a one-bar one, and that subtracting a *standard-deviation* band (not a fixed
|
||||
/// ATR multiple) adapts the stop to changing volatility. The stop trails the
|
||||
/// extreme reached since the last reversal — ratcheting only in the trend's favour
|
||||
/// — and flips sides when price closes through it. `dev` selects which `DevStop`
|
||||
/// line to follow (`1`, `2` or `3` standard deviations are Kase's warning lines).
|
||||
///
|
||||
/// The first bar seeds the prior candle; the next `period` two-bar ranges seed the
|
||||
/// mean and standard deviation, so the first stop lands after `period + 1` inputs.
|
||||
/// Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, KaseDevStop};
|
||||
///
|
||||
/// let mut indicator = KaseDevStop::new(30, 1.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KaseDevStop {
|
||||
period: usize,
|
||||
dev: f64,
|
||||
prev: Option<Candle>,
|
||||
window: VecDeque<f64>,
|
||||
sum: f64,
|
||||
sum_sq: f64,
|
||||
direction: f64,
|
||||
extreme: f64,
|
||||
stop: f64,
|
||||
last: Option<KaseDevStopOutput>,
|
||||
}
|
||||
|
||||
impl KaseDevStop {
|
||||
/// Construct a Kase `DevStop` with the given lookback `period` and
|
||||
/// standard-deviation multiplier `dev`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` (a standard deviation
|
||||
/// needs at least two samples) and [`Error::NonPositiveMultiplier`] if `dev`
|
||||
/// is not finite and positive.
|
||||
pub fn new(period: usize, dev: f64) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "Kase DevStop period must be >= 2",
|
||||
});
|
||||
}
|
||||
if !dev.is_finite() || dev <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
dev,
|
||||
prev: None,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum: 0.0,
|
||||
sum_sq: 0.0,
|
||||
direction: 0.0,
|
||||
extreme: 0.0,
|
||||
stop: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, dev)`.
|
||||
pub const fn params(&self) -> (usize, f64) {
|
||||
(self.period, self.dev)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<KaseDevStopOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for KaseDevStop {
|
||||
type Input = Candle;
|
||||
type Output = KaseDevStopOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<KaseDevStopOutput> {
|
||||
let Some(prev) = self.prev else {
|
||||
self.prev = Some(candle);
|
||||
return None;
|
||||
};
|
||||
let dtr = candle.high.max(prev.high) - candle.low.min(prev.low);
|
||||
self.prev = Some(candle);
|
||||
|
||||
if self.window.len() == self.period {
|
||||
let old = self.window.pop_front().expect("non-empty");
|
||||
self.sum -= old;
|
||||
self.sum_sq -= old * old;
|
||||
}
|
||||
self.window.push_back(dtr);
|
||||
self.sum += dtr;
|
||||
self.sum_sq += dtr * dtr;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let mean = self.sum / self.period as f64;
|
||||
let band = mean + self.dev * sample_stddev(self.sum, self.sum_sq, self.period);
|
||||
|
||||
if self.direction == 0.0 {
|
||||
// Seed the trend as long off the first fully-warmed bar.
|
||||
self.direction = 1.0;
|
||||
self.extreme = candle.high;
|
||||
self.stop = candle.high - band;
|
||||
} else if self.direction > 0.0 {
|
||||
self.extreme = self.extreme.max(candle.high);
|
||||
let raw = self.extreme - band;
|
||||
self.stop = self.stop.max(raw);
|
||||
if candle.close < self.stop {
|
||||
self.direction = -1.0;
|
||||
self.extreme = candle.low;
|
||||
self.stop = candle.low + band;
|
||||
}
|
||||
} else {
|
||||
self.extreme = self.extreme.min(candle.low);
|
||||
let raw = self.extreme + band;
|
||||
self.stop = self.stop.min(raw);
|
||||
if candle.close > self.stop {
|
||||
self.direction = 1.0;
|
||||
self.extreme = candle.high;
|
||||
self.stop = candle.high - band;
|
||||
}
|
||||
}
|
||||
|
||||
let out = KaseDevStopOutput {
|
||||
value: self.stop,
|
||||
direction: self.direction,
|
||||
};
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.window.clear();
|
||||
self.sum = 0.0;
|
||||
self.sum_sq = 0.0;
|
||||
self.direction = 0.0;
|
||||
self.extreme = 0.0;
|
||||
self.stop = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"KaseDevStop"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(matches!(
|
||||
KaseDevStop::new(1, 1.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
KaseDevStop::new(30, 0.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
KaseDevStop::new(30, -1.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let k = KaseDevStop::new(30, 1.0).unwrap();
|
||||
assert_eq!(k.params(), (30, 1.0));
|
||||
assert_eq!(k.warmup_period(), 31);
|
||||
assert_eq!(k.name(), "KaseDevStop");
|
||||
assert!(!k.is_ready());
|
||||
assert_eq!(k.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut k = KaseDevStop::new(3, 1.0).unwrap();
|
||||
let candles: Vec<Candle> = (0..8)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base)
|
||||
})
|
||||
.collect();
|
||||
let out = k.batch(&candles);
|
||||
let warmup = k.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 uptrend_keeps_stop_below_price() {
|
||||
let mut k = KaseDevStop::new(5, 1.0).unwrap();
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let base = 100.0 + 2.0 * f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
for (o, candle) in k.batch(&candles).into_iter().zip(candles.iter()) {
|
||||
if let Some(o) = o {
|
||||
assert_eq!(o.direction, 1.0, "pure uptrend stays long");
|
||||
assert!(o.value < candle.close, "stop below price");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_ratchets_up_in_uptrend() {
|
||||
let mut k = KaseDevStop::new(5, 1.0).unwrap();
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let base = 100.0 + 2.0 * f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let mut prev = f64::NEG_INFINITY;
|
||||
for o in k.batch(&candles).into_iter().flatten() {
|
||||
assert!(o.value >= prev, "long stop must not fall");
|
||||
prev = o.value;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flips_on_reversal() {
|
||||
let mut candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
candles.extend((0..40).map(|i| {
|
||||
let base = 140.0 - f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base - 0.5)
|
||||
}));
|
||||
let mut k = KaseDevStop::new(5, 1.0).unwrap();
|
||||
let dirs: Vec<f64> = k
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|o| o.direction)
|
||||
.collect();
|
||||
assert!(dirs.iter().any(|&d| d > 0.0));
|
||||
assert!(dirs.iter().any(|&d| d < 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut k = KaseDevStop::new(5, 1.0).unwrap();
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
k.batch(&candles);
|
||||
assert!(k.is_ready());
|
||||
k.reset();
|
||||
assert!(!k.is_ready());
|
||||
assert_eq!(k.value(), None);
|
||||
assert_eq!(k.update(candles[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;
|
||||
c(base + 2.0, base - 1.5, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let batch = KaseDevStop::new(20, 2.0).unwrap().batch(&candles);
|
||||
let mut b = KaseDevStop::new(20, 2.0).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ mod aroon;
|
||||
mod aroon_oscillator;
|
||||
mod atr;
|
||||
mod atr_bands;
|
||||
mod atr_ratchet;
|
||||
mod atr_trailing_stop;
|
||||
mod auto_fib;
|
||||
mod autocorrelation;
|
||||
@@ -48,9 +49,12 @@ mod bat;
|
||||
mod belt_hold;
|
||||
mod beta;
|
||||
mod beta_neutral_spread;
|
||||
mod better_volume;
|
||||
mod bipower_variation;
|
||||
mod body_size_pct;
|
||||
mod bollinger;
|
||||
mod bollinger_bandwidth;
|
||||
mod bomar_bands;
|
||||
mod breadth_thrust;
|
||||
mod breakaway;
|
||||
mod bullish_percent_index;
|
||||
@@ -113,11 +117,13 @@ mod ehlers_stochastic;
|
||||
mod ehma;
|
||||
mod elder_impulse;
|
||||
mod elder_ray;
|
||||
mod elder_safezone;
|
||||
mod ema;
|
||||
mod empirical_mode_decomposition;
|
||||
mod engulfing;
|
||||
mod evening_doji_star;
|
||||
mod evwma;
|
||||
mod ewma_volatility;
|
||||
mod expectancy;
|
||||
mod falling_three_methods;
|
||||
mod fama;
|
||||
@@ -143,6 +149,7 @@ 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;
|
||||
@@ -179,6 +186,7 @@ mod inertia;
|
||||
mod information_ratio;
|
||||
mod initial_balance;
|
||||
mod instantaneous_trendline;
|
||||
mod intraday_intensity;
|
||||
mod intraday_momentum_index;
|
||||
mod intraday_volatility_profile;
|
||||
mod inverse_fisher_transform;
|
||||
@@ -188,6 +196,7 @@ mod jump_indicator;
|
||||
mod kagi_bars;
|
||||
mod kalman_hedge_ratio;
|
||||
mod kama;
|
||||
mod kase_devstop;
|
||||
mod kase_permission_stochastic;
|
||||
mod kelly_criterion;
|
||||
mod keltner;
|
||||
@@ -226,6 +235,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;
|
||||
@@ -234,11 +244,13 @@ mod mid_point;
|
||||
mod mid_price;
|
||||
mod minus_di;
|
||||
mod minus_dm;
|
||||
mod modified_ma_stop;
|
||||
mod mom;
|
||||
mod morning_doji_star;
|
||||
mod morning_evening_star;
|
||||
mod natr;
|
||||
mod new_highs_new_lows;
|
||||
mod nrtr;
|
||||
mod nvi;
|
||||
mod ob_imbalance_full;
|
||||
mod ob_imbalance_top1;
|
||||
@@ -273,10 +285,13 @@ 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;
|
||||
@@ -370,9 +385,11 @@ mod three_stars_in_south;
|
||||
mod thrusting;
|
||||
mod tick_index;
|
||||
mod tii;
|
||||
mod time_based_stop;
|
||||
mod time_of_day_return_profile;
|
||||
mod tpo_profile;
|
||||
mod trade_imbalance;
|
||||
mod trade_volume_index;
|
||||
mod trend_label;
|
||||
mod trend_strength_index;
|
||||
mod treynor_ratio;
|
||||
@@ -390,6 +407,7 @@ mod ttm_squeeze;
|
||||
mod ttm_trend;
|
||||
mod turn_of_month;
|
||||
mod tweezer;
|
||||
mod twiggs_money_flow;
|
||||
mod two_crows;
|
||||
mod typical_price;
|
||||
mod ulcer_index;
|
||||
@@ -404,10 +422,15 @@ 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;
|
||||
mod volume_profile;
|
||||
mod volume_rsi;
|
||||
mod volume_weighted_macd;
|
||||
mod vortex;
|
||||
mod vpin;
|
||||
mod vpt;
|
||||
@@ -415,6 +438,7 @@ mod vwap;
|
||||
mod vwap_stddev_bands;
|
||||
mod vwma;
|
||||
mod vzo;
|
||||
mod wad;
|
||||
mod wave_pm;
|
||||
mod wave_trend;
|
||||
mod wedge;
|
||||
@@ -458,6 +482,7 @@ pub use aroon::{Aroon, AroonOutput};
|
||||
pub use aroon_oscillator::AroonOscillator;
|
||||
pub use atr::Atr;
|
||||
pub use atr_bands::{AtrBands, AtrBandsOutput};
|
||||
pub use atr_ratchet::{AtrRatchet, AtrRatchetOutput};
|
||||
pub use atr_trailing_stop::AtrTrailingStop;
|
||||
pub use auto_fib::{AutoFib, AutoFibOutput};
|
||||
pub use autocorrelation::Autocorrelation;
|
||||
@@ -471,9 +496,12 @@ pub use bat::Bat;
|
||||
pub use belt_hold::BeltHold;
|
||||
pub use beta::Beta;
|
||||
pub use beta_neutral_spread::BetaNeutralSpread;
|
||||
pub use better_volume::BetterVolume;
|
||||
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;
|
||||
@@ -536,11 +564,13 @@ pub use ehlers_stochastic::EhlersStochastic;
|
||||
pub use ehma::Ehma;
|
||||
pub use elder_impulse::ElderImpulse;
|
||||
pub use elder_ray::{ElderRay, ElderRayOutput};
|
||||
pub use elder_safezone::{ElderSafeZone, ElderSafeZoneOutput};
|
||||
pub use ema::Ema;
|
||||
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;
|
||||
@@ -566,6 +596,7 @@ 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};
|
||||
@@ -602,6 +633,7 @@ pub use inertia::Inertia;
|
||||
pub use information_ratio::InformationRatio;
|
||||
pub use initial_balance::{InitialBalance, InitialBalanceOutput};
|
||||
pub use instantaneous_trendline::InstantaneousTrendline;
|
||||
pub use intraday_intensity::IntradayIntensity;
|
||||
pub use intraday_momentum_index::IntradayMomentumIndex;
|
||||
pub use intraday_volatility_profile::{IntradayVolatilityProfile, IntradayVolatilityProfileOutput};
|
||||
pub use inverse_fisher_transform::InverseFisherTransform;
|
||||
@@ -611,6 +643,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_devstop::{KaseDevStop, KaseDevStopOutput};
|
||||
pub use kase_permission_stochastic::{KasePermissionStochastic, KasePermissionStochasticOutput};
|
||||
pub use kelly_criterion::KellyCriterion;
|
||||
pub use keltner::{Keltner, KeltnerOutput};
|
||||
@@ -649,6 +682,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;
|
||||
@@ -657,11 +691,13 @@ pub use mid_point::MidPoint;
|
||||
pub use mid_price::MidPrice;
|
||||
pub use minus_di::MinusDi;
|
||||
pub use minus_dm::MinusDm;
|
||||
pub use modified_ma_stop::{ModifiedMaStop, ModifiedMaStopOutput};
|
||||
pub use mom::Mom;
|
||||
pub use morning_doji_star::MorningDojiStar;
|
||||
pub use morning_evening_star::MorningEveningStar;
|
||||
pub use natr::Natr;
|
||||
pub use new_highs_new_lows::NewHighsNewLows;
|
||||
pub use nrtr::{Nrtr, NrtrOutput};
|
||||
pub use nvi::Nvi;
|
||||
pub use ob_imbalance_full::OrderBookImbalanceFull;
|
||||
pub use ob_imbalance_top1::OrderBookImbalanceTop1;
|
||||
@@ -696,10 +732,13 @@ 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;
|
||||
@@ -793,9 +832,11 @@ pub use three_stars_in_south::ThreeStarsInSouth;
|
||||
pub use thrusting::Thrusting;
|
||||
pub use tick_index::TickIndex;
|
||||
pub use tii::Tii;
|
||||
pub use time_based_stop::TimeBasedStop;
|
||||
pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput};
|
||||
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
|
||||
pub use trade_imbalance::TradeImbalance;
|
||||
pub use trade_volume_index::TradeVolumeIndex;
|
||||
pub use trend_label::TrendLabel;
|
||||
pub use trend_strength_index::TrendStrengthIndex;
|
||||
pub use treynor_ratio::TreynorRatio;
|
||||
@@ -813,6 +854,7 @@ pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput};
|
||||
pub use ttm_trend::TtmTrend;
|
||||
pub use turn_of_month::TurnOfMonth;
|
||||
pub use tweezer::Tweezer;
|
||||
pub use twiggs_money_flow::TwiggsMoneyFlow;
|
||||
pub use two_crows::TwoCrows;
|
||||
pub use typical_price::TypicalPrice;
|
||||
pub use ulcer_index::UlcerIndex;
|
||||
@@ -827,10 +869,15 @@ 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;
|
||||
pub use volume_profile::{VolumeProfile, VolumeProfileOutput};
|
||||
pub use volume_rsi::VolumeRsi;
|
||||
pub use volume_weighted_macd::{VolumeWeightedMacd, VolumeWeightedMacdOutput};
|
||||
pub use vortex::{Vortex, VortexOutput};
|
||||
pub use vpin::Vpin;
|
||||
pub use vpt::VolumePriceTrend;
|
||||
@@ -838,6 +885,7 @@ pub use vwap::{RollingVwap, Vwap};
|
||||
pub use vwap_stddev_bands::{VwapStdDevBands, VwapStdDevBandsOutput};
|
||||
pub use vwma::Vwma;
|
||||
pub use vzo::Vzo;
|
||||
pub use wad::Wad;
|
||||
pub use wave_pm::WavePm;
|
||||
pub use wave_trend::{WaveTrend, WaveTrendOutput};
|
||||
pub use wedge::Wedge;
|
||||
@@ -1006,6 +1054,12 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"YangZhangVolatility",
|
||||
"JumpIndicator",
|
||||
"RegimeLabel",
|
||||
"EwmaVolatility",
|
||||
"Garch11",
|
||||
"VolatilityOfVolatility",
|
||||
"BipowerVariation",
|
||||
"VolatilityRatio",
|
||||
"VolatilityCone",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1022,6 +1076,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"TtmSqueeze",
|
||||
"FractalChaosBands",
|
||||
"VwapStdDevBands",
|
||||
"QuartileBands",
|
||||
"BomarBands",
|
||||
"MedianChannel",
|
||||
"ProjectionBands",
|
||||
"ProjectionOscillator",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1040,6 +1099,12 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"StepTrailingStop",
|
||||
"RenkoTrailingStop",
|
||||
"SarExt",
|
||||
"KaseDevStop",
|
||||
"ElderSafeZone",
|
||||
"AtrRatchet",
|
||||
"Nrtr",
|
||||
"TimeBasedStop",
|
||||
"ModifiedMaStop",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1064,6 +1129,13 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"Tsv",
|
||||
"Vzo",
|
||||
"MarketFacilitationIndex",
|
||||
"VolumeRsi",
|
||||
"Wad",
|
||||
"TwiggsMoneyFlow",
|
||||
"TradeVolumeIndex",
|
||||
"IntradayIntensity",
|
||||
"BetterVolume",
|
||||
"VolumeWeightedMacd",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1423,6 +1495,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, 423, "FAMILIES total drifted from indicator count");
|
||||
assert_eq!(total, 447, "FAMILIES total drifted from indicator count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Modified-MA Stop — a trailing stop riding the Modified Moving Average (SMMA).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::smma::Smma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`ModifiedMaStop`]: the active stop level and the trend direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ModifiedMaStopOutput {
|
||||
/// The stop level (a directionally-ratcheted Modified Moving Average).
|
||||
pub value: f64,
|
||||
/// Trend direction: `+1.0` long (stop below price), `-1.0` short.
|
||||
pub direction: f64,
|
||||
}
|
||||
|
||||
/// Modified-MA Stop — a trailing stop whose line is the **Modified Moving
|
||||
/// Average** (SMMA / Wilder's RMA) of price, allowed to move only in the trend's
|
||||
/// favour.
|
||||
///
|
||||
/// ```text
|
||||
/// ma = SMMA(close, period) (Modified Moving Average)
|
||||
/// long: stop = max(prev_stop, ma); flip short when close < stop
|
||||
/// short: stop = min(prev_stop, ma); flip long when close > stop
|
||||
/// ```
|
||||
///
|
||||
/// The Modified Moving Average (also called the smoothed or running moving
|
||||
/// average) is the slow, low-lag average Wilder used throughout his systems. Using
|
||||
/// it directly as a trailing line — but **ratcheting** so the long stop never
|
||||
/// falls and the short stop never rises — turns the smooth average into a stop
|
||||
/// that hugs price in a trend and flips when price decisively crosses it. Because
|
||||
/// the SMMA lags, the stop gives trends room while still exiting clean reversals.
|
||||
///
|
||||
/// The first stop lands once the SMMA is ready (`period` inputs). Each `update` is
|
||||
/// O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ModifiedMaStop};
|
||||
///
|
||||
/// let mut indicator = ModifiedMaStop::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModifiedMaStop {
|
||||
smma: Smma,
|
||||
period: usize,
|
||||
direction: f64,
|
||||
stop: f64,
|
||||
last: Option<ModifiedMaStopOutput>,
|
||||
}
|
||||
|
||||
impl ModifiedMaStop {
|
||||
/// Construct a Modified-MA stop with the given SMMA `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
smma: Smma::new(period)?,
|
||||
period,
|
||||
direction: 0.0,
|
||||
stop: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured SMMA period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<ModifiedMaStopOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ModifiedMaStop {
|
||||
type Input = Candle;
|
||||
type Output = ModifiedMaStopOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<ModifiedMaStopOutput> {
|
||||
let ma = self.smma.update(candle.close)?;
|
||||
let close = candle.close;
|
||||
|
||||
if self.direction == 0.0 {
|
||||
self.direction = if close >= ma { 1.0 } else { -1.0 };
|
||||
self.stop = ma;
|
||||
} else if self.direction > 0.0 {
|
||||
self.stop = self.stop.max(ma);
|
||||
if close < self.stop {
|
||||
self.direction = -1.0;
|
||||
self.stop = ma;
|
||||
}
|
||||
} else {
|
||||
self.stop = self.stop.min(ma);
|
||||
if close > self.stop {
|
||||
self.direction = 1.0;
|
||||
self.stop = ma;
|
||||
}
|
||||
}
|
||||
|
||||
let out = ModifiedMaStopOutput {
|
||||
value: self.stop,
|
||||
direction: self.direction,
|
||||
};
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.smma.reset();
|
||||
self.direction = 0.0;
|
||||
self.stop = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ModifiedMaStop"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(close: f64) -> Candle {
|
||||
Candle::new_unchecked(close, close + 1.0, close - 1.0, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(ModifiedMaStop::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let m = ModifiedMaStop::new(14).unwrap();
|
||||
assert_eq!(m.period(), 14);
|
||||
assert_eq!(m.warmup_period(), 14);
|
||||
assert_eq!(m.name(), "ModifiedMaStop");
|
||||
assert!(!m.is_ready());
|
||||
assert_eq!(m.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut m = ModifiedMaStop::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..12).map(|i| c(100.0 + f64::from(i))).collect();
|
||||
let out = m.batch(&candles);
|
||||
for v in out.iter().take(4) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_keeps_stop_below_price() {
|
||||
let mut m = ModifiedMaStop::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..60).map(|i| c(100.0 + 2.0 * f64::from(i))).collect();
|
||||
for (o, candle) in m.batch(&candles).into_iter().zip(candles.iter()) {
|
||||
if let Some(o) = o {
|
||||
assert_eq!(o.direction, 1.0);
|
||||
assert!(o.value < candle.close);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_stop_ratchets_up() {
|
||||
let mut m = ModifiedMaStop::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..60).map(|i| c(100.0 + 2.0 * f64::from(i))).collect();
|
||||
let mut prev = f64::NEG_INFINITY;
|
||||
for o in m.batch(&candles).into_iter().flatten() {
|
||||
assert_eq!(o.direction, 1.0, "pure uptrend stays long");
|
||||
assert!(o.value >= prev, "long stop must not fall");
|
||||
prev = o.value;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flips_on_reversal() {
|
||||
let mut candles: Vec<Candle> = (0..40).map(|i| c(100.0 + f64::from(i))).collect();
|
||||
candles.extend((0..40).map(|i| c(140.0 - f64::from(i))));
|
||||
let mut m = ModifiedMaStop::new(5).unwrap();
|
||||
let dirs: Vec<f64> = m
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|o| o.direction)
|
||||
.collect();
|
||||
assert!(dirs.iter().any(|&d| d > 0.0));
|
||||
assert!(dirs.iter().any(|&d| d < 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut m = ModifiedMaStop::new(5).unwrap();
|
||||
m.batch(&(0..40).map(|i| c(100.0 + f64::from(i))).collect::<Vec<_>>());
|
||||
assert!(m.is_ready());
|
||||
m.reset();
|
||||
assert!(!m.is_ready());
|
||||
assert_eq!(m.value(), None);
|
||||
assert_eq!(m.update(c(100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
|
||||
.collect();
|
||||
let batch = ModifiedMaStop::new(14).unwrap().batch(&candles);
|
||||
let mut b = ModifiedMaStop::new(14).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! NRTR — Nick Rypock Trailing Reverse, a percentage trailing-reverse stop.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`Nrtr`]: the trailing-reverse line and the trend direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct NrtrOutput {
|
||||
/// The NRTR line — below price in an uptrend, above price in a downtrend.
|
||||
pub value: f64,
|
||||
/// Trend direction: `+1.0` up (line below price), `-1.0` down.
|
||||
pub direction: f64,
|
||||
}
|
||||
|
||||
/// NRTR (Nick Rypock Trailing Reverse) — a **percentage** trailing-reverse stop
|
||||
/// that follows the trend extreme and flips when price retraces by a fixed
|
||||
/// percentage.
|
||||
///
|
||||
/// ```text
|
||||
/// uptrend: high_water = max(high_water, close)
|
||||
/// line = high_water · (1 − pct/100)
|
||||
/// flip down when close < line (reseed low_water = close)
|
||||
/// downtrend: low_water = min(low_water, close)
|
||||
/// line = low_water · (1 + pct/100)
|
||||
/// flip up when close > line (reseed high_water = close)
|
||||
/// ```
|
||||
///
|
||||
/// Unlike volatility stops (ATR, σ-of-range), NRTR uses a pure **percentage**
|
||||
/// retracement: the line trails the highest close reached in the up-leg at a
|
||||
/// fixed `pct` below it, and a close that gives back that percentage reverses the
|
||||
/// trend, handing the line to the opposite extreme. This makes it scale-free and
|
||||
/// trivially tunable — one number sets how much retracement you tolerate. It
|
||||
/// differs from a fixed percentage *stop-loss* in that it **reverses** (tracks
|
||||
/// both directions) rather than just exiting.
|
||||
///
|
||||
/// The first bar seeds the up-trend and emits a line immediately. Each `update` is
|
||||
/// O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Nrtr};
|
||||
///
|
||||
/// let mut indicator = Nrtr::new(2.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let close = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(close, close + 0.5, close - 0.5, close, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Nrtr {
|
||||
pct: f64,
|
||||
direction: f64,
|
||||
water: f64,
|
||||
last: Option<NrtrOutput>,
|
||||
}
|
||||
|
||||
impl Nrtr {
|
||||
/// Construct an NRTR with the given trailing percentage (e.g. `2.0` for 2%).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidParameter`] if `pct` is not finite or is outside
|
||||
/// `(0, 100)`.
|
||||
pub fn new(pct: f64) -> Result<Self> {
|
||||
if !pct.is_finite() || pct <= 0.0 || pct >= 100.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "NRTR percentage must be in (0, 100)",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
pct,
|
||||
direction: 0.0,
|
||||
water: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured trailing percentage.
|
||||
pub const fn pct(&self) -> f64 {
|
||||
self.pct
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<NrtrOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Nrtr {
|
||||
type Input = Candle;
|
||||
type Output = NrtrOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<NrtrOutput> {
|
||||
let close = candle.close;
|
||||
let down = self.pct / 100.0;
|
||||
let up = self.pct / 100.0;
|
||||
|
||||
if self.direction == 0.0 {
|
||||
self.direction = 1.0;
|
||||
self.water = close;
|
||||
} else if self.direction > 0.0 {
|
||||
self.water = self.water.max(close);
|
||||
let line = self.water * (1.0 - down);
|
||||
if close < line {
|
||||
self.direction = -1.0;
|
||||
self.water = close;
|
||||
}
|
||||
} else {
|
||||
self.water = self.water.min(close);
|
||||
let line = self.water * (1.0 + up);
|
||||
if close > line {
|
||||
self.direction = 1.0;
|
||||
self.water = close;
|
||||
}
|
||||
}
|
||||
|
||||
let line = if self.direction > 0.0 {
|
||||
self.water * (1.0 - down)
|
||||
} else {
|
||||
self.water * (1.0 + up)
|
||||
};
|
||||
let out = NrtrOutput {
|
||||
value: line,
|
||||
direction: self.direction,
|
||||
};
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.direction = 0.0;
|
||||
self.water = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Nrtr"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(close: f64) -> Candle {
|
||||
Candle::new_unchecked(close, close, close, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_pct() {
|
||||
assert!(matches!(
|
||||
Nrtr::new(0.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Nrtr::new(100.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Nrtr::new(f64::NAN),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(Nrtr::new(2.0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let n = Nrtr::new(2.0).unwrap();
|
||||
assert_eq!(n.pct(), 2.0);
|
||||
assert_eq!(n.warmup_period(), 1);
|
||||
assert_eq!(n.name(), "Nrtr");
|
||||
assert!(!n.is_ready());
|
||||
assert_eq!(n.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_emits_up_line() {
|
||||
let mut n = Nrtr::new(10.0).unwrap();
|
||||
let o = n.update(c(100.0)).unwrap();
|
||||
assert_eq!(o.direction, 1.0);
|
||||
// line = 100 * (1 - 0.10) = 90.
|
||||
assert!((o.value - 90.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_keeps_line_below_price() {
|
||||
let mut n = Nrtr::new(5.0).unwrap();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| c(100.0 + f64::from(i))).collect();
|
||||
for (o, candle) in n.batch(&candles).into_iter().zip(candles.iter()) {
|
||||
let o = o.unwrap();
|
||||
assert_eq!(o.direction, 1.0);
|
||||
assert!(o.value < candle.close);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverses_on_retracement() {
|
||||
let mut n = Nrtr::new(5.0).unwrap();
|
||||
// Rise to 120, then drop sharply -> a >5% retracement reverses the trend.
|
||||
let mut candles: Vec<Candle> = (0..20).map(|i| c(100.0 + f64::from(i))).collect();
|
||||
candles.extend((0..10).map(|i| c(119.0 - 3.0 * f64::from(i))));
|
||||
let dirs: Vec<f64> = n
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|o| o.direction)
|
||||
.collect();
|
||||
assert!(dirs.iter().any(|&d| d > 0.0));
|
||||
assert!(dirs.iter().any(|&d| d < 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downtrend_keeps_line_above_price() {
|
||||
let mut n = Nrtr::new(5.0).unwrap();
|
||||
// Establish a downtrend after an initial bar.
|
||||
let mut candles = vec![c(100.0)];
|
||||
candles.extend((0..30).map(|i| c(80.0 - f64::from(i))));
|
||||
let out = n.batch(&candles);
|
||||
let o = out.last().unwrap().unwrap();
|
||||
let candle = candles.last().unwrap();
|
||||
assert_eq!(o.direction, -1.0);
|
||||
assert!(o.value > candle.close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut n = Nrtr::new(2.0).unwrap();
|
||||
n.batch(&(0..20).map(|i| c(100.0 + f64::from(i))).collect::<Vec<_>>());
|
||||
assert!(n.is_ready());
|
||||
n.reset();
|
||||
assert!(!n.is_ready());
|
||||
assert_eq!(n.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 15.0))
|
||||
.collect();
|
||||
let batch = Nrtr::new(3.0).unwrap().batch(&candles);
|
||||
let mut b = Nrtr::new(3.0).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -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,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,192 @@
|
||||
//! Time-Based Stop — a holding-period timer that fires after a fixed bar count.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Time-Based Stop — exits a position purely on **elapsed bars**, independent of
|
||||
/// price.
|
||||
///
|
||||
/// ```text
|
||||
/// bars_held increments by 1 each bar (since the last reset)
|
||||
/// progress = min(bars_held / max_bars, 1.0) in [0, 1]
|
||||
/// stop fires when progress == 1.0 (bars_held >= max_bars)
|
||||
/// ```
|
||||
///
|
||||
/// Some setups should not be given unlimited time to work: a mean-reversion entry
|
||||
/// that has not reverted within `max_bars`, or an event trade whose catalyst has
|
||||
/// passed, is best closed regardless of price. This indicator is a pure timer —
|
||||
/// it ignores the candle's prices entirely and reports the fraction of the
|
||||
/// holding window that has elapsed, reaching `1.0` (the stop) after `max_bars`
|
||||
/// bars. **Call [`reset`](Indicator::reset) on each new entry** so the timer
|
||||
/// restarts from the position open.
|
||||
///
|
||||
/// Each `update` is O(1) and the first bar already emits a value
|
||||
/// (`1 / max_bars`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, TimeBasedStop};
|
||||
///
|
||||
/// let mut indicator = TimeBasedStop::new(5).unwrap();
|
||||
/// let c = Candle::new(100.0, 101.0, 99.0, 100.0, 1.0, 0).unwrap();
|
||||
/// // Five bars reach the stop.
|
||||
/// let mut last = 0.0;
|
||||
/// for _ in 0..5 {
|
||||
/// last = indicator.update(c).unwrap();
|
||||
/// }
|
||||
/// assert_eq!(last, 1.0);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TimeBasedStop {
|
||||
max_bars: usize,
|
||||
bars_held: usize,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl TimeBasedStop {
|
||||
/// Construct a time-based stop that fires after `max_bars` bars.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `max_bars == 0`.
|
||||
pub fn new(max_bars: usize) -> Result<Self> {
|
||||
if max_bars == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
max_bars,
|
||||
bars_held: 0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured maximum holding period in bars.
|
||||
pub const fn max_bars(&self) -> usize {
|
||||
self.max_bars
|
||||
}
|
||||
|
||||
/// Number of bars held since the last reset.
|
||||
pub const fn bars_held(&self) -> usize {
|
||||
self.bars_held
|
||||
}
|
||||
|
||||
/// Whether the stop has fired (the holding period has fully elapsed).
|
||||
pub const fn triggered(&self) -> bool {
|
||||
self.bars_held >= self.max_bars
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TimeBasedStop {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, _candle: Candle) -> Option<f64> {
|
||||
self.bars_held += 1;
|
||||
let progress = (self.bars_held as f64 / self.max_bars as f64).min(1.0);
|
||||
self.last = Some(progress);
|
||||
Some(progress)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.bars_held = 0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TimeBasedStop"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c() -> Candle {
|
||||
Candle::new_unchecked(100.0, 101.0, 99.0, 100.0, 1.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_max_bars() {
|
||||
assert!(matches!(TimeBasedStop::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let t = TimeBasedStop::new(5).unwrap();
|
||||
assert_eq!(t.max_bars(), 5);
|
||||
assert_eq!(t.bars_held(), 0);
|
||||
assert!(!t.triggered());
|
||||
assert_eq!(t.warmup_period(), 1);
|
||||
assert_eq!(t.name(), "TimeBasedStop");
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_climbs_to_one() {
|
||||
let mut t = TimeBasedStop::new(4).unwrap();
|
||||
let out = t.batch(&[c(), c(), c(), c()]);
|
||||
assert_relative_eq!(out[0].unwrap(), 0.25, epsilon = 1e-12);
|
||||
assert_relative_eq!(out[1].unwrap(), 0.50, epsilon = 1e-12);
|
||||
assert_relative_eq!(out[2].unwrap(), 0.75, epsilon = 1e-12);
|
||||
assert_relative_eq!(out[3].unwrap(), 1.00, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triggers_after_max_bars() {
|
||||
let mut t = TimeBasedStop::new(3).unwrap();
|
||||
t.update(c());
|
||||
assert!(!t.triggered());
|
||||
t.update(c());
|
||||
assert!(!t.triggered());
|
||||
t.update(c());
|
||||
assert!(t.triggered());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_saturates_at_one() {
|
||||
// Beyond max_bars the progress stays clamped at 1.0.
|
||||
let mut t = TimeBasedStop::new(2).unwrap();
|
||||
let out = t.batch(&[c(), c(), c(), c()]);
|
||||
assert_relative_eq!(out[2].unwrap(), 1.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(out[3].unwrap(), 1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_restarts_timer() {
|
||||
let mut t = TimeBasedStop::new(3).unwrap();
|
||||
t.batch(&[c(), c(), c()]);
|
||||
assert!(t.triggered());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.bars_held(), 0);
|
||||
assert!(!t.triggered());
|
||||
assert_relative_eq!(t.update(c()).unwrap(), 1.0 / 3.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = [c(); 10];
|
||||
let batch = TimeBasedStop::new(4).unwrap().batch(&candles);
|
||||
let mut b = TimeBasedStop::new(4).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Trade Volume Index (TVI) — cumulative volume signed by a minimum-tick rule.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Trade Volume Index — a cumulative line that adds volume while price ticks up
|
||||
/// and subtracts it while price ticks down, where "up" and "down" are decided by
|
||||
/// a **minimum tick value** rather than any change.
|
||||
///
|
||||
/// ```text
|
||||
/// change = close − prev_close
|
||||
/// if change > min_tick: direction = +1
|
||||
/// if change < −min_tick: direction = −1
|
||||
/// else: direction unchanged (price is "churning")
|
||||
/// TVI_t = TVI_{t−1} + direction * volume
|
||||
/// ```
|
||||
///
|
||||
/// The minimum tick value (MTV) is a dead-band: only moves larger than `min_tick`
|
||||
/// flip the accumulation direction, so a price drifting within the spread keeps
|
||||
/// adding volume in the last established direction instead of whipsawing. This is
|
||||
/// the cumulative-volume analogue of [`Obv`](crate::Obv), but with a noise filter
|
||||
/// and applied to close-to-close moves. Like all cumulative lines, only its slope
|
||||
/// and divergences against price carry meaning — the absolute level is arbitrary.
|
||||
///
|
||||
/// The first candle seeds the reference close and emits nothing; thereafter each
|
||||
/// bar emits the running total. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, TradeVolumeIndex};
|
||||
///
|
||||
/// let mut indicator = TradeVolumeIndex::new(0.5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..20 {
|
||||
/// let close = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(close, close + 0.5, close - 0.5, close, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TradeVolumeIndex {
|
||||
min_tick: f64,
|
||||
prev_close: Option<f64>,
|
||||
direction: f64,
|
||||
tvi: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl TradeVolumeIndex {
|
||||
/// Construct a new Trade Volume Index with the given minimum tick value.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidParameter`] if `min_tick` is not finite or is
|
||||
/// negative. A `min_tick` of `0` is allowed and makes every non-zero move
|
||||
/// flip the direction.
|
||||
pub fn new(min_tick: f64) -> Result<Self> {
|
||||
if !min_tick.is_finite() || min_tick < 0.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "trade volume index min_tick must be finite and non-negative",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
min_tick,
|
||||
prev_close: None,
|
||||
direction: 0.0,
|
||||
tvi: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured minimum tick value.
|
||||
pub const fn min_tick(&self) -> f64 {
|
||||
self.min_tick
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TradeVolumeIndex {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let Some(prev_close) = self.prev_close else {
|
||||
self.prev_close = Some(candle.close);
|
||||
return None;
|
||||
};
|
||||
let change = candle.close - prev_close;
|
||||
if change > self.min_tick {
|
||||
self.direction = 1.0;
|
||||
} else if change < -self.min_tick {
|
||||
self.direction = -1.0;
|
||||
}
|
||||
// Otherwise the direction is held from the previous bar (or 0 before the
|
||||
// first decisive move), so a churning price keeps its last lean.
|
||||
self.tvi += self.direction * candle.volume;
|
||||
self.prev_close = Some(candle.close);
|
||||
self.last = Some(self.tvi);
|
||||
Some(self.tvi)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.direction = 0.0;
|
||||
self.tvi = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TradeVolumeIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(close: f64, volume: f64) -> Candle {
|
||||
Candle::new_unchecked(close, close, close, close, volume, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_min_tick() {
|
||||
assert!(matches!(
|
||||
TradeVolumeIndex::new(-1.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
TradeVolumeIndex::new(f64::NAN),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(TradeVolumeIndex::new(0.0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let tvi = TradeVolumeIndex::new(0.25).unwrap();
|
||||
assert_relative_eq!(tvi.min_tick(), 0.25, epsilon = 1e-12);
|
||||
assert_eq!(tvi.warmup_period(), 2);
|
||||
assert_eq!(tvi.name(), "TradeVolumeIndex");
|
||||
assert!(!tvi.is_ready());
|
||||
assert_eq!(tvi.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_seeds_without_output() {
|
||||
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
|
||||
assert_eq!(tvi.update(candle(100.0, 1_000.0)), None);
|
||||
assert!(tvi.update(candle(101.0, 1_000.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_accumulates_volume() {
|
||||
// Each step of +1 exceeds the 0.5 tick -> direction +1 -> add volume.
|
||||
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
|
||||
let candles = [
|
||||
candle(100.0, 1_000.0), // seed
|
||||
candle(101.0, 500.0), // +1 -> +500
|
||||
candle(102.0, 300.0), // +1 -> +300
|
||||
];
|
||||
let out = tvi.batch(&candles);
|
||||
assert_relative_eq!(out[1].unwrap(), 500.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out[2].unwrap(), 800.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_move_holds_last_direction() {
|
||||
// After an up-move, a sub-tick wobble keeps adding in the up direction.
|
||||
let mut tvi = TradeVolumeIndex::new(1.0).unwrap();
|
||||
let candles = [
|
||||
candle(100.0, 1_000.0), // seed
|
||||
candle(102.0, 400.0), // +2 > tick -> dir +1, +400
|
||||
candle(102.2, 100.0), // +0.2 < tick -> hold dir +1, +100
|
||||
];
|
||||
let out = tvi.batch(&candles);
|
||||
assert_relative_eq!(out[1].unwrap(), 400.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out[2].unwrap(), 500.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downtrend_distributes_volume() {
|
||||
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
|
||||
let candles = [
|
||||
candle(100.0, 1_000.0),
|
||||
candle(99.0, 200.0), // -1 -> -200
|
||||
candle(98.0, 300.0), // -1 -> -300
|
||||
];
|
||||
let out = tvi.batch(&candles);
|
||||
assert_relative_eq!(out[2].unwrap(), -500.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
|
||||
tvi.batch(&[candle(100.0, 1.0), candle(101.0, 1.0), candle(102.0, 1.0)]);
|
||||
assert!(tvi.is_ready());
|
||||
tvi.reset();
|
||||
assert!(!tvi.is_ready());
|
||||
assert_eq!(tvi.value(), None);
|
||||
assert_eq!(tvi.update(candle(100.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
candle(
|
||||
100.0 + (f64::from(i) * 0.3).sin() * 5.0,
|
||||
1_000.0 + f64::from(i),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let batch = TradeVolumeIndex::new(0.5).unwrap().batch(&candles);
|
||||
let mut b = TradeVolumeIndex::new(0.5).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
//! Twiggs Money Flow (TMF) — Colin Twiggs' Wilder-smoothed money-flow oscillator.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Twiggs Money Flow — a refinement of Chaikin Money Flow that uses **true range**
|
||||
/// boundaries and **Wilder (exponential) smoothing** instead of a simple sum.
|
||||
///
|
||||
/// ```text
|
||||
/// TRH = max(high, prev_close) (true high)
|
||||
/// TRL = min(low, prev_close) (true low)
|
||||
/// ad = volume * (2*close − TRH − TRL) / (TRH − TRL) (0 if TRH == TRL)
|
||||
/// TMF = WilderEMA(ad, period) / WilderEMA(volume, period)
|
||||
/// ```
|
||||
///
|
||||
/// Colin Twiggs' money flow fixes two issues with [`Cmf`](crate::Cmf): it replaces
|
||||
/// the bar's raw high/low with the *true* high/low (folding in the prior close so
|
||||
/// gaps count), and it smooths the accumulated money flow and the volume with a
|
||||
/// Wilder exponential average rather than a flat `period`-sum, so the oscillator
|
||||
/// reacts faster and never jumps when a large bar drops out of a window. The
|
||||
/// output is bounded in roughly `[−1, +1]`: positive means buying pressure
|
||||
/// (closes biased toward the true high), negative means selling pressure.
|
||||
///
|
||||
/// The first candle seeds the reference close; the next `period` bars seed both
|
||||
/// Wilder averages, so the first value lands after `period + 1` inputs. A stretch
|
||||
/// of zero volume makes the denominator average `0`, in which case the oscillator
|
||||
/// reports `0` rather than `0 / 0`. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, TwiggsMoneyFlow};
|
||||
///
|
||||
/// let mut indicator = TwiggsMoneyFlow::new(21).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// let base = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TwiggsMoneyFlow {
|
||||
period: usize,
|
||||
prev_close: Option<f64>,
|
||||
seed_ad: f64,
|
||||
seed_vol: f64,
|
||||
seed_count: usize,
|
||||
ad_ema: Option<f64>,
|
||||
vol_ema: Option<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl TwiggsMoneyFlow {
|
||||
/// Construct a new Twiggs Money Flow with the given smoothing `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
prev_close: None,
|
||||
seed_ad: 0.0,
|
||||
seed_vol: 0.0,
|
||||
seed_count: 0,
|
||||
ad_ema: None,
|
||||
vol_ema: None,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured smoothing period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn ratio(ad_ema: f64, vol_ema: f64) -> f64 {
|
||||
if vol_ema == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
ad_ema / vol_ema
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TwiggsMoneyFlow {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let Some(prev_close) = self.prev_close else {
|
||||
self.prev_close = Some(candle.close);
|
||||
return None;
|
||||
};
|
||||
let trh = candle.high.max(prev_close);
|
||||
let trl = candle.low.min(prev_close);
|
||||
let range = trh - trl;
|
||||
let ad = if range > 0.0 {
|
||||
candle.volume * (2.0 * candle.close - trh - trl) / range
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.prev_close = Some(candle.close);
|
||||
|
||||
if let (Some(ad_ema), Some(vol_ema)) = (self.ad_ema, self.vol_ema) {
|
||||
let n = self.period as f64;
|
||||
let new_ad = ad_ema + (ad - ad_ema) / n;
|
||||
let new_vol = vol_ema + (candle.volume - vol_ema) / n;
|
||||
self.ad_ema = Some(new_ad);
|
||||
self.vol_ema = Some(new_vol);
|
||||
let v = Self::ratio(new_ad, new_vol);
|
||||
self.last = Some(v);
|
||||
return Some(v);
|
||||
}
|
||||
|
||||
self.seed_ad += ad;
|
||||
self.seed_vol += candle.volume;
|
||||
self.seed_count += 1;
|
||||
if self.seed_count == self.period {
|
||||
let n = self.period as f64;
|
||||
let ad_ema = self.seed_ad / n;
|
||||
let vol_ema = self.seed_vol / n;
|
||||
self.ad_ema = Some(ad_ema);
|
||||
self.vol_ema = Some(vol_ema);
|
||||
let v = Self::ratio(ad_ema, vol_ema);
|
||||
self.last = Some(v);
|
||||
return Some(v);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.seed_ad = 0.0;
|
||||
self.seed_vol = 0.0;
|
||||
self.seed_count = 0;
|
||||
self.ad_ema = None;
|
||||
self.vol_ema = None;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TwiggsMoneyFlow"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64, volume: f64) -> Candle {
|
||||
Candle::new_unchecked(low, high, low, close, volume, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(TwiggsMoneyFlow::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_bars_drive_tmf_to_zero() {
|
||||
// A flat bar (high == low == close == prior close) gives a zero two-bar
|
||||
// range, so the accumulation term falls back to 0.0 and TMF settles at
|
||||
// zero. Exercises the `range == 0` guard.
|
||||
let mut tmf = TwiggsMoneyFlow::new(2).unwrap();
|
||||
let flat: Vec<Candle> = (0..6)
|
||||
.map(|_| candle(100.0, 100.0, 100.0, 1_000.0))
|
||||
.collect();
|
||||
let last = tmf.batch(&flat).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let tmf = TwiggsMoneyFlow::new(21).unwrap();
|
||||
assert_eq!(tmf.period(), 21);
|
||||
assert_eq!(tmf.warmup_period(), 22);
|
||||
assert_eq!(tmf.name(), "TwiggsMoneyFlow");
|
||||
assert!(!tmf.is_ready());
|
||||
assert_eq!(tmf.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..8)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
candle(base + 1.0, base - 1.0, base, 1_000.0)
|
||||
})
|
||||
.collect();
|
||||
let out = tmf.batch(&candles);
|
||||
// warmup_period == period + 1 == 4: first emission at index 3.
|
||||
for o in out.iter().take(3) {
|
||||
assert!(o.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closes_at_true_high_is_positive() {
|
||||
// Every bar closes at its high -> strong buying pressure -> TMF -> +1.
|
||||
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..12)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
// open=low=base-1, high=close=base+1 -> closes at the top.
|
||||
Candle::new_unchecked(base - 1.0, base + 1.0, base - 1.0, base + 1.0, 1_000.0, 0)
|
||||
})
|
||||
.collect();
|
||||
let last = tmf.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last > 0.9,
|
||||
"closing at the high should drive TMF near +1, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closes_at_true_low_is_negative() {
|
||||
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..12)
|
||||
.map(|i| {
|
||||
let base = 100.0 - f64::from(i);
|
||||
// closes at the low.
|
||||
Candle::new_unchecked(base + 1.0, base + 1.0, base - 1.0, base - 1.0, 1_000.0, 0)
|
||||
})
|
||||
.collect();
|
||||
let last = tmf.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last < -0.5,
|
||||
"closing at the low should drive TMF negative, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_volume_yields_zero() {
|
||||
let mut tmf = TwiggsMoneyFlow::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, 0.0)
|
||||
})
|
||||
.collect();
|
||||
for v in tmf.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut tmf = TwiggsMoneyFlow::new(21).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, 1_000.0)
|
||||
})
|
||||
.collect();
|
||||
for v in tmf.batch(&candles).into_iter().flatten() {
|
||||
assert!((-1.0..=1.0).contains(&v), "TMF out of range: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut tmf = TwiggsMoneyFlow::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, 1_000.0)
|
||||
})
|
||||
.collect();
|
||||
tmf.batch(&candles);
|
||||
assert!(tmf.is_ready());
|
||||
tmf.reset();
|
||||
assert!(!tmf.is_ready());
|
||||
assert_eq!(tmf.value(), None);
|
||||
assert_eq!(tmf.update(candle(101.0, 99.0, 100.0, 1_000.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, 1_000.0 + f64::from(i))
|
||||
})
|
||||
.collect();
|
||||
let batch = TwiggsMoneyFlow::new(21).unwrap().batch(&candles);
|
||||
let mut b = TwiggsMoneyFlow::new(21).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -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,265 @@
|
||||
//! Volume RSI — Wilder's RSI applied to the volume stream.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Volume RSI — the Relative Strength Index computed on **volume** changes
|
||||
/// instead of price changes.
|
||||
///
|
||||
/// Wilder's [`Rsi`](crate::Rsi) measures the balance of up- versus down-*price*
|
||||
/// moves; the Volume RSI applies the identical accumulator to the bar-over-bar
|
||||
/// change in volume:
|
||||
///
|
||||
/// ```text
|
||||
/// change_t = volume_t − volume_{t−1}
|
||||
/// gain = max(change, 0), loss = max(−change, 0)
|
||||
/// avg_gain, avg_loss = Wilder-smoothed over `period`
|
||||
/// VolumeRSI = 100 * avg_gain / (avg_gain + avg_loss)
|
||||
/// ```
|
||||
///
|
||||
/// Readings above `50` mean volume is expanding (more was added than removed over
|
||||
/// the smoothing window) and tend to confirm the prevailing move; readings below
|
||||
/// `50` mark contracting participation. Output is bounded in `[0, 100]`; a stretch
|
||||
/// of unchanged volume drives both averages to `0` and the indicator reports the
|
||||
/// neutral `50` rather than an undefined `0 / 0`.
|
||||
///
|
||||
/// Only the candle's **volume** is used. The first bar sets the previous volume,
|
||||
/// then `period` changes seed Wilder's averages, so the first value lands after
|
||||
/// `period + 1` inputs. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, VolumeRsi};
|
||||
///
|
||||
/// let mut indicator = VolumeRsi::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let v = 1_000.0 + (f64::from(i) * 0.3).sin() * 400.0;
|
||||
/// let c = Candle::new(100.0, 101.0, 99.0, 100.5, v, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VolumeRsi {
|
||||
period: usize,
|
||||
prev_volume: Option<f64>,
|
||||
seed_gains: f64,
|
||||
seed_losses: f64,
|
||||
seed_count: usize,
|
||||
avg_gain: Option<f64>,
|
||||
avg_loss: Option<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl VolumeRsi {
|
||||
/// Construct a Volume RSI with the given Wilder smoothing `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
prev_volume: None,
|
||||
seed_gains: 0.0,
|
||||
seed_losses: 0.0,
|
||||
seed_count: 0,
|
||||
avg_gain: None,
|
||||
avg_loss: None,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured smoothing period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
|
||||
let denom = avg_gain + avg_loss;
|
||||
if denom == 0.0 {
|
||||
50.0
|
||||
} else {
|
||||
100.0 * (avg_gain / denom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for VolumeRsi {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let volume = candle.volume;
|
||||
let Some(prev) = self.prev_volume else {
|
||||
self.prev_volume = Some(volume);
|
||||
return None;
|
||||
};
|
||||
let change = volume - prev;
|
||||
self.prev_volume = Some(volume);
|
||||
let gain = if change > 0.0 { change } else { 0.0 };
|
||||
let loss = if change < 0.0 { -change } else { 0.0 };
|
||||
|
||||
if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
|
||||
let n = self.period as f64;
|
||||
let new_ag = (ag * (n - 1.0) + gain) / n;
|
||||
let new_al = (al * (n - 1.0) + loss) / n;
|
||||
self.avg_gain = Some(new_ag);
|
||||
self.avg_loss = Some(new_al);
|
||||
let v = Self::rsi_from_avgs(new_ag, new_al);
|
||||
self.last = Some(v);
|
||||
return Some(v);
|
||||
}
|
||||
|
||||
self.seed_gains += gain;
|
||||
self.seed_losses += loss;
|
||||
self.seed_count += 1;
|
||||
if self.seed_count == self.period {
|
||||
let n = self.period as f64;
|
||||
let ag = self.seed_gains / n;
|
||||
let al = self.seed_losses / n;
|
||||
self.avg_gain = Some(ag);
|
||||
self.avg_loss = Some(al);
|
||||
let v = Self::rsi_from_avgs(ag, al);
|
||||
self.last = Some(v);
|
||||
return Some(v);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_volume = None;
|
||||
self.seed_gains = 0.0;
|
||||
self.seed_losses = 0.0;
|
||||
self.seed_count = 0;
|
||||
self.avg_gain = None;
|
||||
self.avg_loss = None;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"VolumeRsi"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
/// Candle whose only material field here is `volume`.
|
||||
fn vol_candle(volume: f64) -> Candle {
|
||||
Candle::new_unchecked(100.0, 101.0, 99.0, 100.5, volume, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(VolumeRsi::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let v = VolumeRsi::new(14).unwrap();
|
||||
assert_eq!(v.period(), 14);
|
||||
assert_eq!(v.warmup_period(), 15);
|
||||
assert_eq!(v.name(), "VolumeRsi");
|
||||
assert!(!v.is_ready());
|
||||
assert_eq!(v.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut v = VolumeRsi::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|i| vol_candle(1_000.0 + f64::from(i))).collect();
|
||||
let out = v.batch(&candles);
|
||||
// warmup_period == period + 1 == 4: first emission at index 3.
|
||||
for o in out.iter().take(3) {
|
||||
assert!(o.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rising_volume_is_one_hundred() {
|
||||
// Every change positive -> avg_loss 0 -> RSI 100.
|
||||
let mut v = VolumeRsi::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (1..=40).map(|i| vol_candle(f64::from(i) * 100.0)).collect();
|
||||
let last = v.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falling_volume_is_zero() {
|
||||
let mut v = VolumeRsi::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (1..=40)
|
||||
.map(|i| vol_candle(5_000.0 - f64::from(i) * 100.0))
|
||||
.collect();
|
||||
let last = v.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_volume_is_neutral() {
|
||||
// Unchanged volume -> no gains and no losses -> neutral 50.
|
||||
let mut v = VolumeRsi::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..20).map(|_| vol_candle(2_000.0)).collect();
|
||||
let last = v.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut v = VolumeRsi::new(14).unwrap();
|
||||
let candles: Vec<Candle> = (0..200)
|
||||
.map(|i| vol_candle(1_000.0 + (f64::from(i) * 0.3).sin() * 600.0))
|
||||
.collect();
|
||||
for o in v.batch(&candles).into_iter().flatten() {
|
||||
assert!((0.0..=100.0).contains(&o));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut v = VolumeRsi::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| vol_candle(1_000.0 + f64::from(i)))
|
||||
.collect();
|
||||
v.batch(&candles);
|
||||
assert!(v.is_ready());
|
||||
v.reset();
|
||||
assert!(!v.is_ready());
|
||||
assert_eq!(v.value(), None);
|
||||
assert_eq!(v.update(vol_candle(1_000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| vol_candle(1_000.0 + (f64::from(i) * 0.25).sin() * 500.0))
|
||||
.collect();
|
||||
let batch = VolumeRsi::new(14).unwrap().batch(&candles);
|
||||
let mut b = VolumeRsi::new(14).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! Volume-Weighted MACD — MACD built on volume-weighted moving averages.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::indicators::vwma::Vwma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`VolumeWeightedMacd`]: the three classic MACD series, but with the
|
||||
/// fast and slow averages volume-weighted.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct VolumeWeightedMacdOutput {
|
||||
/// Fast VWMA − slow VWMA.
|
||||
pub macd: f64,
|
||||
/// EMA of `macd` over the signal period.
|
||||
pub signal: f64,
|
||||
/// `macd − signal`.
|
||||
pub histogram: f64,
|
||||
}
|
||||
|
||||
/// Volume-Weighted MACD — the MACD oscillator computed from **volume-weighted**
|
||||
/// moving averages instead of plain EMAs.
|
||||
///
|
||||
/// ```text
|
||||
/// macd = VWMA(close, fast) − VWMA(close, slow)
|
||||
/// signal = EMA(macd, signal_period)
|
||||
/// histogram = macd − signal
|
||||
/// ```
|
||||
///
|
||||
/// Standard [`MacdIndicator`](crate::MacdIndicator) smooths price with exponential
|
||||
/// averages that ignore volume. The volume-weighted variant (Buff Dormeier and
|
||||
/// others) replaces each average with a [`Vwma`], so heavy-volume bars dominate
|
||||
/// the trend estimate and the oscillator leans toward where real participation
|
||||
/// occurred. Crossovers backed by volume therefore appear sooner and noise from
|
||||
/// thin bars is damped. The signal line keeps a standard EMA, matching the
|
||||
/// classic histogram construction.
|
||||
///
|
||||
/// `fast` must be strictly smaller than `slow`. The first output lands after
|
||||
/// `slow + signal − 1` inputs: `slow` to seed the slow VWMA, then `signal − 1`
|
||||
/// more to seed the signal EMA. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, VolumeWeightedMacd};
|
||||
///
|
||||
/// let mut indicator = VolumeWeightedMacd::new(12, 26, 9).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VolumeWeightedMacd {
|
||||
fast: Vwma,
|
||||
slow: Vwma,
|
||||
signal_ema: Ema,
|
||||
fast_period: usize,
|
||||
slow_period: usize,
|
||||
signal_period: usize,
|
||||
last: Option<VolumeWeightedMacdOutput>,
|
||||
}
|
||||
|
||||
impl VolumeWeightedMacd {
|
||||
/// Construct a volume-weighted MACD 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> {
|
||||
if fast == 0 || slow == 0 || signal == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if fast >= slow {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "fast period must be strictly less than slow period",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
fast: Vwma::new(fast)?,
|
||||
slow: Vwma::new(slow)?,
|
||||
signal_ema: Ema::new(signal)?,
|
||||
fast_period: fast,
|
||||
slow_period: slow,
|
||||
signal_period: signal,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured periods as `(fast, slow, signal)`.
|
||||
pub const fn periods(&self) -> (usize, usize, usize) {
|
||||
(self.fast_period, self.slow_period, self.signal_period)
|
||||
}
|
||||
|
||||
/// Most recent fully-computed output if available.
|
||||
pub const fn value(&self) -> Option<VolumeWeightedMacdOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for VolumeWeightedMacd {
|
||||
type Input = Candle;
|
||||
type Output = VolumeWeightedMacdOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<VolumeWeightedMacdOutput> {
|
||||
let fast = self.fast.update(candle);
|
||||
let slow = self.slow.update(candle);
|
||||
if let (Some(f), Some(s)) = (fast, slow) {
|
||||
let macd = f - s;
|
||||
let signal = self.signal_ema.update(macd)?;
|
||||
let out = VolumeWeightedMacdOutput {
|
||||
macd,
|
||||
signal,
|
||||
histogram: macd - signal,
|
||||
};
|
||||
self.last = Some(out);
|
||||
return Some(out);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.fast.reset();
|
||||
self.slow.reset();
|
||||
self.signal_ema.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.slow_period + self.signal_period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"VolumeWeightedMacd"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(close: f64, volume: f64) -> Candle {
|
||||
Candle::new_unchecked(close, close, close, close, volume, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_periods() {
|
||||
assert!(matches!(
|
||||
VolumeWeightedMacd::new(0, 26, 9),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
VolumeWeightedMacd::new(26, 12, 9),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
VolumeWeightedMacd::new(12, 12, 9),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let m = VolumeWeightedMacd::new(12, 26, 9).unwrap();
|
||||
assert_eq!(m.periods(), (12, 26, 9));
|
||||
assert_eq!(m.warmup_period(), 34);
|
||||
assert_eq!(m.name(), "VolumeWeightedMacd");
|
||||
assert!(!m.is_ready());
|
||||
assert_eq!(m.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut m = VolumeWeightedMacd::new(2, 4, 3).unwrap();
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| candle(100.0 + f64::from(i), 1_000.0))
|
||||
.collect();
|
||||
let out = m.batch(&candles);
|
||||
let warmup = m.warmup_period(); // 4 + 3 - 1 = 6
|
||||
assert_eq!(warmup, 6);
|
||||
for v in out.iter().take(warmup - 1) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[warmup - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_has_positive_macd() {
|
||||
// A steady advance with equal volume -> fast VWMA leads slow -> macd > 0.
|
||||
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| candle(100.0 + f64::from(i), 1_000.0))
|
||||
.collect();
|
||||
let last = m.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last.macd > 0.0,
|
||||
"uptrend should give positive macd, got {}",
|
||||
last.macd
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_is_macd_minus_signal() {
|
||||
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
candle(
|
||||
100.0 + (f64::from(i) * 0.3).sin() * 5.0,
|
||||
1_000.0 + f64::from(i),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for o in m.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(o.histogram, o.macd - o.signal, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_volume_matches_plain_macd() {
|
||||
// With constant volume, VWMA reduces to SMA, so volume-weighted MACD uses
|
||||
// SMA-based lines; it should still be a well-defined finite series.
|
||||
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| candle(100.0 + (f64::from(i) * 0.2).sin() * 4.0, 2_000.0))
|
||||
.collect();
|
||||
for o in m.batch(&candles).into_iter().flatten() {
|
||||
assert!(o.macd.is_finite() && o.signal.is_finite());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| candle(100.0 + f64::from(i), 1_000.0))
|
||||
.collect();
|
||||
m.batch(&candles);
|
||||
assert!(m.is_ready());
|
||||
m.reset();
|
||||
assert!(!m.is_ready());
|
||||
assert_eq!(m.value(), None);
|
||||
assert_eq!(m.update(candle(100.0, 1_000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| {
|
||||
candle(
|
||||
100.0 + (f64::from(i) * 0.25).sin() * 9.0,
|
||||
1_000.0 + f64::from(i),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let batch = VolumeWeightedMacd::new(12, 26, 9).unwrap().batch(&candles);
|
||||
let mut b = VolumeWeightedMacd::new(12, 26, 9).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Williams Accumulation/Distribution (WAD) — Larry Williams' cumulative line.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Williams Accumulation/Distribution — a cumulative price-only line that adds
|
||||
/// the day's accumulation on up-closes and subtracts the day's distribution on
|
||||
/// down-closes.
|
||||
///
|
||||
/// ```text
|
||||
/// if close > prev_close: AD = close − min(low, prev_close) (true low)
|
||||
/// if close < prev_close: AD = close − max(high, prev_close) (true high)
|
||||
/// if close = prev_close: AD = 0
|
||||
/// WAD_t = WAD_{t−1} + AD
|
||||
/// ```
|
||||
///
|
||||
/// Larry Williams' A/D line (distinct from Chaikin's volume-based
|
||||
/// [`Adl`](crate::Adl)) uses **no volume at all** — it measures accumulation as
|
||||
/// how far price closed above the *true low* on up-days and distribution as how
|
||||
/// far it closed below the *true high* on down-days, then accumulates the result.
|
||||
/// A rising WAD that diverges from a flat or falling price is the classic
|
||||
/// accumulation signal; a falling WAD under a rising price warns of distribution.
|
||||
///
|
||||
/// The line is unbounded and its absolute level is meaningless — only its slope
|
||||
/// and divergences against price matter. The first candle has no previous close,
|
||||
/// so it seeds the reference and emits nothing; thereafter every bar emits the
|
||||
/// running total. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Wad};
|
||||
///
|
||||
/// let mut indicator = Wad::new();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..20 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Wad {
|
||||
prev_close: Option<f64>,
|
||||
line: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl Wad {
|
||||
/// Construct a new Williams A/D line. The line is parameter-free.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Wad {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let Some(prev_close) = self.prev_close else {
|
||||
self.prev_close = Some(candle.close);
|
||||
return None;
|
||||
};
|
||||
let ad = if candle.close > prev_close {
|
||||
candle.close - candle.low.min(prev_close)
|
||||
} else if candle.close < prev_close {
|
||||
candle.close - candle.high.max(prev_close)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.line += ad;
|
||||
self.prev_close = Some(candle.close);
|
||||
self.last = Some(self.line);
|
||||
Some(self.line)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.line = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The first bar only seeds the reference close; the first value lands on
|
||||
// the second bar.
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Wad"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(low, high, low, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let wad = Wad::new();
|
||||
assert_eq!(wad.warmup_period(), 2);
|
||||
assert_eq!(wad.name(), "Wad");
|
||||
assert!(!wad.is_ready());
|
||||
assert_eq!(wad.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_seeds_without_output() {
|
||||
let mut wad = Wad::new();
|
||||
assert_eq!(wad.update(candle(101.0, 99.0, 100.0)), None);
|
||||
assert!(wad.update(candle(102.0, 100.0, 101.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_close_accumulates() {
|
||||
// close rises from 100 -> 101; true low = min(low, prev_close) = min(100,100)=100;
|
||||
// AD = 101 - 100 = 1.
|
||||
let mut wad = Wad::new();
|
||||
wad.update(candle(101.0, 99.0, 100.0));
|
||||
let v = wad.update(candle(102.0, 100.0, 101.0)).unwrap();
|
||||
assert_relative_eq!(v, 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn down_close_distributes() {
|
||||
// close falls 100 -> 99; true high = max(high, prev_close) = max(101,100)=101;
|
||||
// AD = 99 - 101 = -2.
|
||||
let mut wad = Wad::new();
|
||||
wad.update(candle(102.0, 100.0, 100.0));
|
||||
let v = wad.update(candle(101.0, 98.0, 99.0)).unwrap();
|
||||
assert_relative_eq!(v, -2.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_close_adds_nothing() {
|
||||
let mut wad = Wad::new();
|
||||
wad.update(candle(101.0, 99.0, 100.0));
|
||||
let v = wad.update(candle(105.0, 95.0, 100.0)).unwrap();
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_is_monotone() {
|
||||
let mut wad = Wad::new();
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
candle(base + 1.0, base - 1.0, base)
|
||||
})
|
||||
.collect();
|
||||
let mut prev = f64::NEG_INFINITY;
|
||||
for v in wad.batch(&candles).into_iter().flatten() {
|
||||
assert!(v >= prev, "WAD must rise in an uptrend");
|
||||
prev = v;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut wad = Wad::new();
|
||||
let candles: Vec<Candle> = (0..10)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
candle(base + 1.0, base - 1.0, base)
|
||||
})
|
||||
.collect();
|
||||
wad.batch(&candles);
|
||||
assert!(wad.is_ready());
|
||||
wad.reset();
|
||||
assert!(!wad.is_ready());
|
||||
assert_eq!(wad.value(), None);
|
||||
assert_eq!(wad.update(candle(101.0, 99.0, 100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.3).sin() * 8.0;
|
||||
candle(base + 2.0, base - 2.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let batch = Wad::new().batch(&candles);
|
||||
let mut b = Wad::new();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -60,30 +60,31 @@ pub use indicators::{
|
||||
AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCycle, AdaptiveLaguerreFilter, Adl,
|
||||
AdvanceBlock, AdvanceDecline, AdvanceDeclineRatio, Adx, AdxOutput, Adxr, Alligator,
|
||||
AlligatorOutput, Alma, Alpha, AmihudIlliquidity, AnchoredRsi, AnchoredVwap, Apo, Aroon,
|
||||
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,
|
||||
AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrRatchet, AtrRatchetOutput,
|
||||
AtrTrailingStop, AutoFib, AutoFibOutput, Autocorrelation, AverageDailyRange, AverageDrawdown,
|
||||
AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BeltHold, Beta,
|
||||
BetaNeutralSpread, BetterVolume, 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, ElderSafeZone, ElderSafeZoneOutput, 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,
|
||||
@@ -91,54 +92,59 @@ pub use indicators::{
|
||||
HistoricalVolatility, Hma, HoltWinters, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput,
|
||||
HtTrendMode, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput,
|
||||
IdenticalThreeCrows, InNeck, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput,
|
||||
InstantaneousTrendline, IntradayMomentumIndex, IntradayVolatilityProfile,
|
||||
InstantaneousTrendline, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile,
|
||||
IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, Jma, JumpIndicator,
|
||||
KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KasePermissionStochastic,
|
||||
KasePermissionStochasticOutput, KellyCriterion, Keltner, KeltnerOutput, Kicking,
|
||||
KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LadderBottom, LaguerreRsi,
|
||||
LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel,
|
||||
LinRegChannelOutput, LinRegIntercept, LinRegSlope, LinearRegression, LiquidationFeatures,
|
||||
LiquidationFeaturesOutput, LogReturn, LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope,
|
||||
MaEnvelopeOutput, MacdExt, MacdFix, MacdHistogram, MacdIndicator, MacdOutput, Mama, MamaOutput,
|
||||
MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown,
|
||||
McClellanOscillator, McClellanSummationIndex, McGinleyDynamic, MedianAbsoluteDeviation,
|
||||
MedianMa, MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi, MinusDm, Mom,
|
||||
MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nvi, OIPriceDivergence, OIWeighted,
|
||||
Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu, OpeningRange, OpeningRangeOutput,
|
||||
OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, OrderFlowImbalance,
|
||||
OuHalfLife, OvernightGap, OvernightIntradayReturn, OvernightIntradayReturnOutput, PainIndex,
|
||||
PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa,
|
||||
PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo,
|
||||
PointAndFigureBars, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Psar, Pvi,
|
||||
Qqe, QqeOutput, Qstick, QuotedSpread, RSquared, RealizedSpread, RealizedVolatility,
|
||||
RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB, RelativeStrengthOutput,
|
||||
RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi, Roc, Rocp, Rocr, Rocr100,
|
||||
RogersSatchellVolatility, RollMeasure, RollingCorrelation, RollingCovariance, RollingIqr,
|
||||
RollingPercentileRank, RollingQuantile, RollingVwap, RoofingFilter, Rsi, Rsx, Rvi,
|
||||
RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow,
|
||||
SessionHighLowOutput, SessionRange, SessionRangeOutput, SessionVwap, Shark, SharpeRatio,
|
||||
ShootingStar, ShortLine, SignedVolume, SineWave, SineWeightedMa, Skewness, Sma, Smi, Smma,
|
||||
SortinoRatio, SpearmanCorrelation, SpinningTop, SpreadAr1Coefficient, SpreadBollingerBands,
|
||||
SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError, StandardErrorBands,
|
||||
StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop,
|
||||
StickSandwich, StochRsi, Stochastic, StochasticCci, StochasticOutput, SuperSmoother,
|
||||
SuperTrend, SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown,
|
||||
TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
|
||||
TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential,
|
||||
TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeDrives, ThreeInside,
|
||||
ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex,
|
||||
Tii, TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
|
||||
TradeImbalance, TrendLabel, TrendStrengthIndex, TreynorRatio, Triangle, Trima, Trin,
|
||||
TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput,
|
||||
TtmTrend, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator,
|
||||
UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea,
|
||||
ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya,
|
||||
VoltyStop, VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend,
|
||||
VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
|
||||
VwapStdDevBandsOutput, Vwma, Vzo, WavePm, WaveTrend, WaveTrendOutput, Wedge, WeightedClose,
|
||||
WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots,
|
||||
WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput,
|
||||
ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
|
||||
KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KaseDevStop, KaseDevStopOutput,
|
||||
KasePermissionStochastic, KasePermissionStochasticOutput, KellyCriterion, Keltner,
|
||||
KeltnerOutput, Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda,
|
||||
LadderBottom, LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle,
|
||||
LinRegChannel, LinRegChannelOutput, LinRegIntercept, LinRegSlope, LinearRegression,
|
||||
LiquidationFeatures, LiquidationFeaturesOutput, LogReturn, LongLeggedDoji, LongLine,
|
||||
LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, MacdHistogram, MacdIndicator,
|
||||
MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex, MatHold,
|
||||
MatchingLow, MaxDrawdown, McClellanOscillator, McClellanSummationIndex, McGinleyDynamic,
|
||||
MedianAbsoluteDeviation, MedianChannel, MedianChannelOutput, MedianMa, MedianPrice, Mfi,
|
||||
Microprice, MidPoint, MidPrice, MinusDi, MinusDm, ModifiedMaStop, ModifiedMaStopOutput, Mom,
|
||||
MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nrtr, NrtrOutput, 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,
|
||||
RollingCovariance, RollingIqr, RollingPercentileRank, RollingQuantile, RollingVwap,
|
||||
RoofingFilter, Rsi, Rsx, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore,
|
||||
SeparatingLines, SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput,
|
||||
SessionVwap, Shark, SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave,
|
||||
SineWeightedMa, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop,
|
||||
SpreadAr1Coefficient, SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst,
|
||||
StalledPattern, StandardError, StandardErrorBands, StandardErrorBandsOutput, StarcBands,
|
||||
StarcBandsOutput, Stc, StdDev, StepTrailingStop, StickSandwich, StochRsi, Stochastic,
|
||||
StochasticCci, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput,
|
||||
TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential,
|
||||
TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei,
|
||||
TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema,
|
||||
TermStructureBasis, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside,
|
||||
ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, TimeBasedStop,
|
||||
TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
|
||||
TradeImbalance, TradeVolumeIndex, TrendLabel, TrendStrengthIndex, TreynorRatio, Triangle,
|
||||
Trima, Trin, TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze,
|
||||
TtmSqueezeOutput, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, 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, VolumeRsi, VolumeWeightedMacd,
|
||||
VolumeWeightedMacdOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
|
||||
VwapStdDevBandsOutput, Vwma, Vzo, Wad, 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 **423 indicators** across
|
||||
- A per-indicator deep dive for every one of the **447 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.8",
|
||||
"version": "0.6.3",
|
||||
"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.8",
|
||||
"wickra-darwin-x64": "0.5.8",
|
||||
"wickra-linux-arm64-gnu": "0.5.8",
|
||||
"wickra-linux-x64-gnu": "0.5.8",
|
||||
"wickra-win32-arm64-msvc": "0.5.8",
|
||||
"wickra-win32-x64-msvc": "0.5.8"
|
||||
"wickra-darwin-arm64": "0.6.3",
|
||||
"wickra-darwin-x64": "0.6.3",
|
||||
"wickra-linux-arm64-gnu": "0.6.3",
|
||||
"wickra-linux-x64-gnu": "0.6.3",
|
||||
"wickra-win32-arm64-msvc": "0.6.3",
|
||||
"wickra-win32-x64-msvc": "0.6.3"
|
||||
}
|
||||
},
|
||||
"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, MacdHistogram, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Qqe, RSquared, RealizedVolatility, RecoveryFactor, RegimeLabel, RenkoTrailingStop, Rmi, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, Rsx, RviVolatility, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, TrendStrengthIndex, Trima, Trix, Tsf, TsfOscillator, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, WavePm, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3};
|
||||
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.
|
||||
@@ -115,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);
|
||||
@@ -268,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, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Psar, Pvi, Qstick, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, StochasticCci, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeOfDayReturnProfile, TpoProfile, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
|
||||
use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrRatchet, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BetterVolume, 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, ElderSafeZone, 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, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KaseDevStop, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, ModifiedMaStop, MorningDojiStar, MorningEveningStar, Natr, Nrtr, 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, TimeBasedStop, TimeOfDayReturnProfile, TpoProfile, TradeVolumeIndex, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VolatilityCone, VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeRsi, VolumeWeightedMacd, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, Wad, 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,10 +73,17 @@ 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);
|
||||
|
||||
// --- Trailing Stops ---
|
||||
drive(|| ModifiedMaStop::new(14).unwrap(), &candles);
|
||||
drive(|| TimeBasedStop::new(5).unwrap(), &candles);
|
||||
drive(|| Nrtr::new(2.0).unwrap(), &candles);
|
||||
drive(|| AtrRatchet::new(14, 4.0, 0.1).unwrap(), &candles);
|
||||
drive(|| ElderSafeZone::new(14, 2.0).unwrap(), &candles);
|
||||
drive(|| Psar::new(0.02, 0.02, 0.20).unwrap(), &candles);
|
||||
drive(SarExt::classic, &candles);
|
||||
drive(|| SuperTrend::new(14, 3.0).unwrap(), &candles);
|
||||
@@ -85,6 +93,7 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
drive(|| HiLoActivator::new(3).unwrap(), &candles);
|
||||
drive(|| VoltyStop::new(14, 2.0).unwrap(), &candles);
|
||||
drive(|| YoyoExit::new(14, 2.0).unwrap(), &candles);
|
||||
drive(|| KaseDevStop::new(30, 1.0).unwrap(), &candles);
|
||||
|
||||
// --- Trend & Directional ---
|
||||
drive(|| KasePermissionStochastic::new(9, 3).unwrap(), &candles);
|
||||
@@ -131,6 +140,13 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
drive(HighLowRange::new, &candles);
|
||||
|
||||
// --- Volume ---
|
||||
drive(|| BetterVolume::new(14).unwrap(), &candles);
|
||||
drive(IntradayIntensity::new, &candles);
|
||||
drive(|| TradeVolumeIndex::new(0.25).unwrap(), &candles);
|
||||
drive(|| TwiggsMoneyFlow::new(21).unwrap(), &candles);
|
||||
drive(Wad::new, &candles);
|
||||
drive(|| VolumeRsi::new(14).unwrap(), &candles);
|
||||
drive(|| VolumeWeightedMacd::new(12, 26, 9).unwrap(), &candles);
|
||||
drive(Obv::new, &candles);
|
||||
drive(|| Mfi::new(14).unwrap(), &candles);
|
||||
drive(Vwap::new, &candles);
|
||||
@@ -246,6 +262,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