Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0061c73b8 | |||
| 23d636fd97 | |||
| fce26cf881 | |||
| 91f6f67257 | |||
| 4caaa1db97 | |||
| 12681e4b1b | |||
| 91e05e3c26 | |||
| 9d0983b666 | |||
| 13c8250488 | |||
| e5305ffa94 | |||
| 46be7a54ea | |||
| bca61322b5 | |||
| fc6f619550 | |||
| 91aa6fffbf | |||
| 5862401958 | |||
| ff5a047078 | |||
| dc415a77fd | |||
| e385734275 | |||
| 3a46b210bb | |||
| 943825d6a0 | |||
| d16df1e224 | |||
| 34c097aee2 | |||
| acd7e8dc52 | |||
| ceaeb90a22 | |||
| 57e26fb22f | |||
| 8431b1400c | |||
| ed01604a18 | |||
| 05fe7ffa90 | |||
| e97c3389fe | |||
| 4526278fa0 | |||
| 80850c81f7 | |||
| 707f29e8e4 | |||
| 389200f855 | |||
| 81406e7a1b | |||
| c78b84e186 | |||
| fc6f3d80c2 | |||
| 2991ba411d | |||
| 83e34c6f71 | |||
| 67feec598a | |||
| 3dfbc415c5 | |||
| 6b8c6a0e7f | |||
| db186b18d3 | |||
| 654da5722f | |||
| aacb9280f1 | |||
| d2bc000892 | |||
| 1f4bf9e3a6 | |||
| d36d514f56 | |||
| 6e0464930e | |||
| 13bc801f89 | |||
| ac8f6acf08 | |||
| 4f81222aed | |||
| 0d2acad28d | |||
| b228a70d7d | |||
| 8dc7158912 | |||
| fcb221ec03 | |||
| a93af60796 | |||
| 5a1d607807 | |||
| ea9da12d86 | |||
| 716eb40206 | |||
| 8115d3b33d | |||
| 4250ed99f4 | |||
| 995f119010 | |||
| 05d2e5dc61 | |||
| 404bcb040c | |||
| 00ce899cc3 | |||
| b6ead740e8 | |||
| 755f4aa0f6 | |||
| 4d602df8a3 | |||
| 3ab2d6ec2d | |||
| 5e96d41916 |
@@ -1,3 +1,18 @@
|
||||
# Shell scripts must keep LF line endings so they run on Linux/macOS CI and
|
||||
# local shells regardless of the committer's platform autocrlf setting.
|
||||
*.sh text eol=lf
|
||||
|
||||
# The cbindgen-generated C header is committed; pin it to LF so its CI drift
|
||||
# check (regenerate + `git diff`) never trips on a CRLF normalization.
|
||||
bindings/c/include/wickra.h text eol=lf
|
||||
|
||||
# C# sources (including the generated binding) are pinned to LF so the committed
|
||||
# files stay stable regardless of the committer's autocrlf setting.
|
||||
*.cs text eol=lf
|
||||
|
||||
# Go sources (including the generated binding) are pinned to LF so gofmt's CI
|
||||
# check never trips on a CRLF checkout on Windows.
|
||||
*.go text eol=lf
|
||||
go.mod text eol=lf
|
||||
go.sum text eol=lf
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ assignees: ""
|
||||
## Environment
|
||||
|
||||
- Wickra version:
|
||||
- Language / binding: <!-- Rust crate / Python / Node / WASM -->
|
||||
- Language / binding: <!-- Rust crate / Python / Node / WASM / C ABI / C# (.NET) / Go -->
|
||||
- OS and architecture:
|
||||
- Rust / Python / Node version (If relevant):
|
||||
- Rust / Python / Node / .NET version (If relevant):
|
||||
|
||||
## Additional context
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ assignees: []
|
||||
| Binding version | `e.g. python 0.4.2 / node 0.4.2` |
|
||||
| OS / arch | `e.g. Windows 11 x86_64, Linux glibc` |
|
||||
| Rust toolchain | `rustc --version` (If building from source) |
|
||||
| Python / Node version | `python --version` / `node --version` |
|
||||
| Python / Node / .NET version | `python --version` / `node --version` / `dotnet --version` |
|
||||
|
||||
## Minimal reproducer
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ assignees: ""
|
||||
- [ ] Should be exposed in the Python binding
|
||||
- [ ] Should be exposed in the Node binding
|
||||
- [ ] Should be exposed in the WASM binding
|
||||
- [ ] Should be exposed in the C ABI
|
||||
- [ ] Should be exposed in the C# / .NET binding
|
||||
- [ ] Should be exposed in the Go binding
|
||||
|
||||
## Additional context
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ assignees: []
|
||||
## Affected code path
|
||||
|
||||
- Indicator / API: `e.g. EMA.update`
|
||||
- Binding: `Rust / Python / Node / Wasm`
|
||||
- Binding: `Rust / Python / Node / Wasm / C ABI / C# (.NET) / Go`
|
||||
- Hot loop or one-shot call?
|
||||
|
||||
## Versions compared
|
||||
|
||||
@@ -33,4 +33,4 @@ import wickra as ta
|
||||
## Environment (Only if relevant)
|
||||
|
||||
- Wickra version: `e.g. 0.4.2`
|
||||
- Binding: `Rust / Python / Node / Wasm`
|
||||
- Binding: `Rust / Python / Node / Wasm / C ABI / C# (.NET) / Go`
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` is clean.
|
||||
- [ ] `cargo test --workspace` passes.
|
||||
- [ ] New behaviour has tests; bug fixes have a regression test.
|
||||
- [ ] Public API changes are mirrored in the Python / Node / WASM bindings
|
||||
- [ ] Public API changes are mirrored in the Python / Node / WASM bindings, and the C ABI + C# + Go bindings are regenerated
|
||||
and their type stubs (If applicable).
|
||||
- [ ] The relevant page on the [documentation site](https://docs.wickra.org)
|
||||
and the `README.md` are updated (If applicable). Docs edits go to a
|
||||
|
||||
@@ -23,6 +23,9 @@ Please fill in the sections below. Delete any that don't apply.
|
||||
- [ ] Python binding (`bindings/python`)
|
||||
- [ ] Node.js binding (`bindings/node`)
|
||||
- [ ] WebAssembly binding (`bindings/wasm`)
|
||||
- [ ] C ABI (`bindings/c`)
|
||||
- [ ] C# / .NET binding (`bindings/csharp`)
|
||||
- [ ] Go binding (`bindings/go`)
|
||||
- [ ] Examples / docs
|
||||
|
||||
## Linked issues
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -53,6 +53,22 @@ jobs:
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Warm cargo registry (retry transient DNS/registry flakes)
|
||||
shell: bash
|
||||
# CARGO_NET_RETRY rides out short blips, but a longer runner DNS outage
|
||||
# outlasts cargo's rapid in-process retries: the crates.io index fetch
|
||||
# the first cargo step does ("Could not resolve host: index.crates.io")
|
||||
# then fails the whole job. Pre-fetch the dependency graph here with real
|
||||
# backoff so clippy/build/test resolve from the warmed local cache.
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if cargo fetch; then exit 0; fi
|
||||
echo "::warning::cargo fetch failed (attempt $attempt/5) — likely a registry/DNS flake; retrying in $((attempt * 20))s..."
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
echo "::error::cargo fetch still failing after 5 attempts"
|
||||
exit 1
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
@@ -232,6 +248,19 @@ jobs:
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Warm cargo registry (retry transient DNS/registry flakes)
|
||||
shell: bash
|
||||
# See the rust job: pre-fetch with backoff so the clippy index update
|
||||
# can't fail the job on a transient "Could not resolve host" DNS blip.
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if cargo fetch; then exit 0; fi
|
||||
echo "::warning::cargo fetch failed (attempt $attempt/5) — likely a registry/DNS flake; retrying in $((attempt * 20))s..."
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
echo "::error::cargo fetch still failing after 5 attempts"
|
||||
exit 1
|
||||
|
||||
- name: Clippy (bindings, all targets)
|
||||
run: cargo clippy -p wickra-node -p wickra-python --all-targets -- -D warnings
|
||||
|
||||
@@ -272,6 +301,19 @@ jobs:
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Warm cargo registry (retry transient DNS/registry flakes)
|
||||
shell: bash
|
||||
# See the rust job: pre-fetch with backoff so the first cargo step can't
|
||||
# fail the job on a transient "Could not resolve host" DNS blip.
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if cargo fetch; then exit 0; fi
|
||||
echo "::warning::cargo fetch failed (attempt $attempt/5) — likely a registry/DNS flake; retrying in $((attempt * 20))s..."
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
echo "::error::cargo fetch still failing after 5 attempts"
|
||||
exit 1
|
||||
|
||||
- name: Build on MSRV
|
||||
run: cargo build ${{ matrix.packages }} --verbose
|
||||
|
||||
@@ -580,6 +622,210 @@ jobs:
|
||||
working-directory: bindings/node
|
||||
run: node --test __tests__/
|
||||
|
||||
c-abi:
|
||||
name: C ABI on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Install cbindgen
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
with:
|
||||
tool: cbindgen
|
||||
|
||||
- name: Build the C ABI library (cdylib + staticlib)
|
||||
run: cargo build -p wickra-c --release
|
||||
|
||||
- name: Rust unit tests
|
||||
run: cargo test -p wickra-c
|
||||
|
||||
# The generated header is platform-independent, so checking drift on one OS
|
||||
# is enough — and avoids a spurious CRLF/LF diff on the Windows runner.
|
||||
- name: Check the committed header is in sync with cbindgen
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
cbindgen --config bindings/c/cbindgen.toml --crate wickra-c --output bindings/c/include/wickra.h
|
||||
if ! git diff --quiet -- bindings/c/include/wickra.h; then
|
||||
echo "::error::bindings/c/include/wickra.h is out of sync — run cbindgen and commit the result"
|
||||
git --no-pager diff -- bindings/c/include/wickra.h
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The real cross-language test: a foreign C consumer links the generated
|
||||
# header + the compiled library and runs. If this passes on all three OSes,
|
||||
# every C-capable language can link the same way.
|
||||
- name: Build and run the C smoke example (CMake + ctest)
|
||||
shell: bash
|
||||
run: |
|
||||
cmake -S examples/c -B examples/c/build
|
||||
cmake --build examples/c/build --config Release
|
||||
ctest --test-dir examples/c/build -C Release --output-on-failure
|
||||
|
||||
csharp:
|
||||
name: C# on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
# The binding links against the C ABI hub at runtime; build it first so the
|
||||
# DllImportResolver finds target/release/wickra.{dll,so,dylib}. .NET 8 SDK is
|
||||
# preinstalled on the GitHub runners, so no setup-dotnet step is needed.
|
||||
- name: Build the C ABI library
|
||||
run: cargo build -p wickra-c --release
|
||||
|
||||
- name: .NET info
|
||||
run: dotnet --info
|
||||
|
||||
- name: Test the C# binding
|
||||
run: dotnet test bindings/csharp/Wickra.Tests/Wickra.Tests.csproj -c Release
|
||||
|
||||
- name: Build the C# examples
|
||||
shell: bash
|
||||
run: |
|
||||
for d in streaming backtest multi_timeframe parallel_assets \
|
||||
strategy_rsi_mean_reversion strategy_macd_adx strategy_bollinger_squeeze \
|
||||
fetch_btcusdt live_binance; do
|
||||
dotnet build "examples/csharp/$d" -c Release
|
||||
done
|
||||
|
||||
# Run only the offline examples (fetch_btcusdt / live_binance need network).
|
||||
- name: Run the offline C# examples
|
||||
shell: bash
|
||||
run: |
|
||||
for d in streaming backtest multi_timeframe parallel_assets \
|
||||
strategy_rsi_mean_reversion strategy_macd_adx strategy_bollinger_squeeze; do
|
||||
dotnet run --project "examples/csharp/$d" -c Release --no-build
|
||||
done
|
||||
|
||||
go:
|
||||
name: Go on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Go is not reliably on PATH on every runner image, so install it
|
||||
# explicitly. cache: false — the cargo build dominates and the modules
|
||||
# have no external Go deps worth caching.
|
||||
- name: Set up Go
|
||||
id: setup-go
|
||||
continue-on-error: true
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: "stable"
|
||||
cache: false
|
||||
|
||||
- name: Retry Go setup (CDN flake)
|
||||
if: steps.setup-go.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-go failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Go (retry)
|
||||
if: steps.setup-go.outcome == 'failure'
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: "stable"
|
||||
cache: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
# The binding links against the C ABI hub via cgo; build it first and stage
|
||||
# the platform library under bindings/go/lib so cgo's LDFLAGS find it. Go is
|
||||
# preinstalled on the GitHub runners, so no setup-go step is needed.
|
||||
- name: Build the C ABI library
|
||||
run: cargo build -p wickra-c --release
|
||||
|
||||
- name: Stage the native library
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p bindings/go/lib
|
||||
case "$RUNNER_OS" in
|
||||
Linux) cp target/release/libwickra.so bindings/go/lib/ ;;
|
||||
macOS) cp target/release/libwickra.dylib bindings/go/lib/ ;;
|
||||
Windows) cp target/release/wickra.dll bindings/go/lib/ ;;
|
||||
esac
|
||||
|
||||
- name: Go info
|
||||
run: go version
|
||||
|
||||
- name: Check gofmt
|
||||
shell: bash
|
||||
run: |
|
||||
unformatted="$(gofmt -l bindings/go examples/go)"
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "gofmt needed on:"; echo "$unformatted"; exit 1
|
||||
fi
|
||||
|
||||
- name: Vet and test the Go binding
|
||||
shell: bash
|
||||
# On Windows there is no rpath; the loader resolves wickra.dll via PATH.
|
||||
run: |
|
||||
export PATH="$PWD/bindings/go/lib:$PATH"
|
||||
cd bindings/go
|
||||
go vet ./...
|
||||
go test ./...
|
||||
|
||||
- name: Build the Go examples
|
||||
shell: bash
|
||||
run: |
|
||||
export PATH="$PWD/bindings/go/lib:$PATH"
|
||||
cd examples/go
|
||||
go build ./...
|
||||
|
||||
# Run only the offline examples (fetch_btcusdt / live_binance need network).
|
||||
- name: Run the offline Go examples
|
||||
shell: bash
|
||||
run: |
|
||||
export PATH="$PWD/bindings/go/lib:$PATH"
|
||||
cd examples/go
|
||||
for d in streaming backtest multi_timeframe parallel_assets \
|
||||
strategy_rsi_mean_reversion strategy_macd_adx strategy_bollinger_squeeze; do
|
||||
go run "./$d"
|
||||
done
|
||||
|
||||
# The cross-library benchmark has moved to a dedicated scheduled workflow
|
||||
# (.github/workflows/bench.yml) — see audit finding R10. It runs nightly
|
||||
# at 03:00 UTC and on-demand via `workflow_dispatch`, and is no longer on
|
||||
|
||||
@@ -569,9 +569,146 @@ jobs:
|
||||
# the old "publish, then upload provenance" order would have the provenance
|
||||
# upload rejected once immutability is enabled.
|
||||
# --------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------
|
||||
# C ABI native libraries (bindings/c) — built per target on a native runner
|
||||
# (no cross toolchain needed) and attached to the GitHub Release as the
|
||||
# distribution channel. There is no package registry for the C ABI.
|
||||
# --------------------------------------------------------------------------
|
||||
c-abi-build:
|
||||
name: C ABI library (${{ matrix.target }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { host: ubuntu-latest, target: x86_64-unknown-linux-gnu }
|
||||
- { host: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu }
|
||||
- { host: macos-latest, target: x86_64-apple-darwin }
|
||||
- { host: macos-latest, target: aarch64-apple-darwin }
|
||||
- { host: windows-latest, target: x86_64-pc-windows-msvc }
|
||||
- { host: windows-11-arm, target: aarch64-pc-windows-msvc }
|
||||
runs-on: ${{ matrix.host }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Build the C ABI library (cdylib + staticlib)
|
||||
run: cargo build -p wickra-c --release --target ${{ matrix.target }}
|
||||
|
||||
- name: Package header + libraries
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
dir="wickra-c-${{ matrix.target }}"
|
||||
mkdir -p "$dir/include" "$dir/lib"
|
||||
cp bindings/c/include/wickra.h bindings/c/include/wickra.hpp "$dir/include/"
|
||||
for f in libwickra.so libwickra.a libwickra.dylib wickra.dll wickra.dll.lib wickra.lib; do
|
||||
src="target/${{ matrix.target }}/release/$f"
|
||||
[ -f "$src" ] && cp "$src" "$dir/lib/"
|
||||
done
|
||||
tar -czf "$dir.tar.gz" "$dir"
|
||||
echo "packaged $dir:"; ls -lR "$dir"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: c-abi-${{ matrix.target }}
|
||||
path: wickra-c-${{ matrix.target }}.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
# Pack and publish the .NET binding to NuGet. Independent of the GitHub-release
|
||||
# job so a C# hiccup never blocks the C/C++ asset release. Authentication uses
|
||||
# NuGet Trusted Publishing (OIDC) — no long-lived API key. The 'wickra-release'
|
||||
# trusted-publishing policy on nuget.org (owner KingchenC, repo wickra-lib/wickra,
|
||||
# workflow release.yml) exchanges the GitHub OIDC token for a short-lived key.
|
||||
csharp-publish:
|
||||
name: Publish to NuGet
|
||||
needs: c-abi-build
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # request the GitHub OIDC token for trusted publishing
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download the C ABI native libraries
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: c-abi-*
|
||||
path: c-abi-artifacts
|
||||
|
||||
- name: Stage native libraries into runtimes/<rid>/native
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
declare -A RID=(
|
||||
[x86_64-unknown-linux-gnu]=linux-x64
|
||||
[aarch64-unknown-linux-gnu]=linux-arm64
|
||||
[x86_64-apple-darwin]=osx-x64
|
||||
[aarch64-apple-darwin]=osx-arm64
|
||||
[x86_64-pc-windows-msvc]=win-x64
|
||||
[aarch64-pc-windows-msvc]=win-arm64
|
||||
)
|
||||
base=bindings/csharp/Wickra/runtimes
|
||||
for target in "${!RID[@]}"; do
|
||||
archive=$(find c-abi-artifacts -name "wickra-c-$target.tar.gz" | head -1)
|
||||
if [ -z "$archive" ]; then
|
||||
echo "::error::missing native artifact for $target"; exit 1
|
||||
fi
|
||||
tmp=$(mktemp -d); tar -xzf "$archive" -C "$tmp"
|
||||
dest="$base/${RID[$target]}/native"; mkdir -p "$dest"
|
||||
for f in libwickra.so libwickra.dylib wickra.dll; do
|
||||
src=$(find "$tmp" -name "$f" | head -1)
|
||||
[ -n "$src" ] && cp "$src" "$dest/"
|
||||
done
|
||||
echo "staged ${RID[$target]}:"; ls -l "$dest"
|
||||
done
|
||||
|
||||
- name: Pack
|
||||
shell: bash
|
||||
run: |
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
dotnet pack bindings/csharp/Wickra/Wickra.csproj -c Release -p:Version="$version" -o nupkg
|
||||
|
||||
# Exchange the GitHub OIDC token for a short-lived (~1h) NuGet API key.
|
||||
# 'user' is the nuget.org profile name (the package owner), not an email.
|
||||
- name: NuGet login (OIDC -> temporary API key)
|
||||
uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0
|
||||
id: nuget_login
|
||||
with:
|
||||
user: KingchenC
|
||||
|
||||
# Pass the temporary key through the environment (not string-interpolated
|
||||
# into the script) so it cannot be parsed as shell — avoids template injection.
|
||||
- name: Push to NuGet
|
||||
shell: bash
|
||||
env:
|
||||
NUGET_API_KEY: ${{ steps.nuget_login.outputs.NUGET_API_KEY }}
|
||||
run: |
|
||||
dotnet nuget push "nupkg/*.nupkg" --api-key "$NUGET_API_KEY" \
|
||||
--source https://api.nuget.org/v3/index.json --skip-duplicate
|
||||
|
||||
- name: Upload the NuGet package as a build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: nuget-package
|
||||
path: nupkg/*.nupkg
|
||||
if-no-files-found: error
|
||||
|
||||
github-release:
|
||||
name: Attach assets to the draft GitHub Release
|
||||
needs: [cargo-publish, python-publish, node-publish, wasm-publish]
|
||||
needs: [cargo-publish, python-publish, node-publish, wasm-publish, c-abi-build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -644,7 +781,7 @@ jobs:
|
||||
# the provenance bundle is attached (P24, immutability-ready).
|
||||
draft: true
|
||||
body: |
|
||||
Wickra ${{ github.ref_name }} — streaming-first technical indicators across 4 language registries.
|
||||
Wickra ${{ github.ref_name }} — streaming-first technical indicators across 4 language registries plus a C ABI.
|
||||
|
||||
### Install
|
||||
|
||||
@@ -665,6 +802,9 @@ jobs:
|
||||
darwin-arm64, win32-x64-msvc)
|
||||
- `wickra-*.tgz` — npm-pack tarballs (main package + per-platform subpackages + WASM)
|
||||
- `*.crate` — cargo source crates (wickra-core, wickra-data, wickra)
|
||||
- `wickra-c-<target>.tar.gz` — C ABI: `include/wickra.h` + `wickra.hpp`
|
||||
and the cdylib/staticlib per target (linux/macos/windows × x64/arm64),
|
||||
the hub for C / C++ / Go / C# / Java / R
|
||||
|
||||
### Auto-generated changelog
|
||||
|
||||
|
||||
@@ -33,6 +33,13 @@ jobs:
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
# The default GITHUB_TOKEN cannot read classic branch-protection
|
||||
# rules, so the Branch-Protection check fails with an internal error
|
||||
# and scores -1. A read-only fine-grained PAT (Administration: read,
|
||||
# Contents: read, Metadata: read) supplied as SCORECARD_TOKEN lets the
|
||||
# check read the protection settings. See
|
||||
# https://github.com/ossf/scorecard-action/blob/main/docs/authentication/fine-grained-auth-token.md
|
||||
repo_token: ${{ secrets.SCORECARD_TOKEN }}
|
||||
# Publish to the public OpenSSF endpoint that backs the README badge.
|
||||
publish_results: true
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ name: Sync indicator count
|
||||
# 2. GitHub repo "About" description — synced on push to main / v* tag
|
||||
# 3. Docs site: index.md / overview.md / Indicators-Overview.md
|
||||
# (wickra-lib/wickra-docs) — synced on push to main / v* tag*
|
||||
# 4. Marketing site count (wickra-lib/webpage: index.md /
|
||||
# 4. Marketing site count (wickra-lib/webpage: index.md / about.md /
|
||||
# .vitepress/config.ts) — push to main / v* tag*
|
||||
# 5. org profile README count (wickra-lib/.github, profile/README.md)
|
||||
# — synced on push to main / v* tag*
|
||||
@@ -42,10 +42,10 @@ name: Sync indicator count
|
||||
# single source of truth for what the bindings reach.
|
||||
#
|
||||
# Design: on PRs this workflow is a READ-ONLY check. The indicator wiring
|
||||
# (ScriptHelpers/_common.py wire_readme_counter) bumps both README.md and
|
||||
# docs/README.md inside the author's code commit, so the counter is already
|
||||
# correct by the time CI runs. If it is not, the check below fails loud and
|
||||
# asks the author to re-run the wiring — it never pushes a fix-up commit.
|
||||
# bumps both README.md and docs/README.md inside the author's code commit, so
|
||||
# the counter is already correct by the time CI runs. If it is not, the check
|
||||
# below fails loud and asks the author to re-run the wiring — it never pushes a
|
||||
# fix-up commit.
|
||||
#
|
||||
# (An earlier version pushed a GITHUB_TOKEN "sync indicator count" commit to
|
||||
# the PR head. Because GITHUB_TOKEN pushes trigger no workflows, that commit
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
# actually live (Cloudflare Pages, P8.1); merging this PR is therefore
|
||||
# gated on the domain resolving, otherwise the About link would 404.
|
||||
homepage="https://docs.wickra.org"
|
||||
desc="Streaming-first technical indicators with a Rust core and Python, Node.js, and WebAssembly bindings. ${n} indicators, O(1) per-tick updates, no system dependencies. Drop-in TA-Lib replacement."
|
||||
desc="Streaming-first technical indicators with a Rust core and Python, Node.js, WebAssembly, C ABI, .NET, and Go bindings. ${n} indicators, O(1) per-tick updates, no system dependencies. Drop-in TA-Lib replacement."
|
||||
# Enforce the homepage unconditionally — it is a constant, so this both
|
||||
# corrects the stale kingchenc URL and self-heals any future drift.
|
||||
# Same Administration-write permission as --description (no extra scope).
|
||||
@@ -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)."
|
||||
@@ -366,14 +366,14 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
cd webpage-count
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md .vitepress/config.ts
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md about.md .vitepress/config.ts
|
||||
if git diff --quiet; then
|
||||
echo "Webpage 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 .vitepress/config.ts
|
||||
git add index.md about.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/webpage failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
|
||||
|
||||
+21
-8
@@ -27,16 +27,28 @@ or replace lives behind a separate crate boundary.
|
||||
│ no I/O, no deps │ │ optional features │
|
||||
└──────────────────────┘ └────────────────────┘
|
||||
▲
|
||||
│ (every binding wraps the same core)
|
||||
│
|
||||
┌────────────┴───────────┬─────────────────────┐
|
||||
│ │ │
|
||||
┌──▼──────┐ ┌───────▼──────┐ ┌───────▼────────┐
|
||||
│ Python │ │ Node │ │ WASM │
|
||||
│ (PyO3) │ │ (napi-rs) │ │ (wasm-bindgen) │
|
||||
└─────────┘ └──────────────┘ └────────────────┘
|
||||
│ every binding wraps the same core
|
||||
┌────────────┼────────────┬────────────────┐
|
||||
│ │ │ │
|
||||
┌──▼───────┐ ┌──▼───────┐ ┌──▼───────────┐ ┌──▼──────────────────┐
|
||||
│ Python │ │ Node │ │ WASM │ │ C ABI (cbindgen) │
|
||||
│ (PyO3) │ │ (napi-rs)│ │(wasm-bindgen)│ │ cdylib + header │
|
||||
└──────────┘ └──────────┘ └──────────────┘ └─────────┬───────────┘
|
||||
│ linked by
|
||||
┌──────────▼──────────┐
|
||||
│ C · C++ · C# · Go │
|
||||
│ · Java · R │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
Python, Node and WASM are *native* Rust bindings (PyO3 / napi-rs /
|
||||
wasm-bindgen). The C ABI is the *hub* every other C-capable language links
|
||||
against: it builds to a `cdylib`/`staticlib` plus a generated `wickra.h`, and
|
||||
downstream languages link that one artifact rather than each re-wrapping the
|
||||
core. C and C++ link it directly; the **C# / .NET** binding (`bindings/csharp`,
|
||||
on NuGet) and the **Go** binding (`bindings/go`, cgo) are generated from
|
||||
`wickra.h`, with Java / R planned the same way.
|
||||
|
||||
| Crate | Path | What it owns | Public deps |
|
||||
|---|---|---|---|
|
||||
| `wickra-core` | `crates/wickra-core` | every indicator, the `Indicator` trait, `BatchExt`, `Candle`/`Tick` types, `Error` | `thiserror`, `rayon` (parallel batch) |
|
||||
@@ -45,6 +57,7 @@ or replace lives behind a separate crate boundary.
|
||||
| `wickra-python` | `bindings/python` | `_wickra` PyO3 module + Python package | `pyo3`, `numpy`, depends on `wickra-core` |
|
||||
| `wickra-node` | `bindings/node` | NAPI-RS native binding | `napi`, depends on `wickra-core` |
|
||||
| `wickra-wasm` | `bindings/wasm` | WebAssembly binding | `wasm-bindgen`, depends on `wickra-core` |
|
||||
| `wickra-c` | `bindings/c` | C ABI hub — `cdylib`/`staticlib` + generated `wickra.h` (cbindgen) | depends on `wickra-core` |
|
||||
| `wickra-examples` | `examples/rust` | runnable binary examples | depends on `wickra`, `wickra-data` |
|
||||
|
||||
The `fuzz/` directory is **excluded** from the workspace (it has its own
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Benchmarks
|
||||
|
||||
Read these as **relative** speedups on identical input — absolute µs depend on
|
||||
CPU, memory clock and OS scheduler, not a universal contract. **Streaming is the
|
||||
headline**: it is where Wickra's design pays off and where the gap is measured in
|
||||
orders of magnitude, not percent. The batch numbers come second and are shown
|
||||
honestly — the leanest crates edge Wickra out on the simple recurrences, and that
|
||||
is a deliberate trade for warmup/NaN semantics, not a ceiling.
|
||||
|
||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
|
||||
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).
|
||||
|
||||
## 1. Streaming — the structural win
|
||||
|
||||
Live trading feeds one tick at a time. Wickra updates every indicator in **O(1)**;
|
||||
batch-only libraries (TA-Lib, tulipy, finta, pandas-ta) have no incremental API
|
||||
and must recompute the whole history on every tick. Only `talipp` (Python) and
|
||||
`ta-rs` / `yata` (Rust) carry real per-tick state. This is the gap the library
|
||||
was built to expose.
|
||||
|
||||
**Python — per-tick latency** (seed 5 000 bars, then feed ticks one at a time):
|
||||
|
||||
| Indicator | **★ Wickra** | talipp | TA-Lib (recompute) |
|
||||
|------------------|------------------:|------------------|-----------------------|
|
||||
| SMA(20) | **0.089 µs ★** | 0.96 µs (11×) | 422 µs (4 700×) |
|
||||
| EMA(20) | **0.111 µs ★** | 1.19 µs (11×) | 430 µs (3 900×) |
|
||||
| RSI(14) | **0.061 µs ★** | 0.95 µs (16×) | 298 µs (4 900×) |
|
||||
| MACD(12, 26, 9) | **0.079 µs ★** | 3.30 µs (42×) | 327 µs (4 100×) |
|
||||
| Bollinger(20, 2) | **0.089 µs ★** | 4.97 µs (56×) | 296 µs (3 300×) |
|
||||
|
||||
Against the only other incremental Python peer Wickra is **11–56× faster**;
|
||||
against the recompute-on-every-tick libraries it is **2 800–19 000× faster**
|
||||
(`finta` RSI hits 19 000×). tulipy / pandas-ta land in the same recompute band
|
||||
as TA-Lib.
|
||||
|
||||
**Rust — per-tick latency** (whole 50 000-bar series, lower = faster):
|
||||
|
||||
| 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 | — |
|
||||
|
||||
`ta-rs` hands back a bare `f64` from the first tick with no warmup and no
|
||||
validation; it leads several rows by giving those guarantees up. Against `kand`,
|
||||
Wickra wins streaming RSI, Bollinger and ATR. `yata` exposes only SMA/EMA as
|
||||
raw-value methods, so its other rows are omitted rather than faked.
|
||||
|
||||
## 2. Batch — competitive, not the headline
|
||||
|
||||
Whole series in one call. Here hand-tuned C (`tulipy`, TA-Lib) and the leanest
|
||||
Rust crate (`kand`) win the simple recurrences — Wickra trades a few µs per pass
|
||||
for the `None`-warmup, NaN-safety and bit-exact `batch == streaming` guarantees
|
||||
none of them keep. It still wins several rows outright and beats the rest of the
|
||||
field everywhere.
|
||||
|
||||
**Python** (20 000-bar pass, µs/op, lower = faster):
|
||||
|
||||
| Indicator | Wickra | TA-Lib | tulipy | pandas-ta | finta |
|
||||
|------------------|---------:|---------:|---------:|----------:|---------:|
|
||||
| SMA(20) | 22.2 | **15.6** | 15.9 | 32.7 | 290.1 |
|
||||
| EMA(20) | 30.5 | **30.4** | 30.9 | 46.7 | 198.5 |
|
||||
| RSI(14) | 52.3 | 72.0 | **34.2** | 88.8 | 812.3 |
|
||||
| MACD(12, 26, 9) | 129.8 | 111.1 | **38.4** | 286.8 | 716.7 |
|
||||
| Bollinger(20, 2) | 87.2 | 74.6 | **37.9** | 474.3 | 1255.5 |
|
||||
| ATR(14) | 74.7 | 87.3 | **35.5** | — | 3496.4 |
|
||||
|
||||
Wickra beats pandas-ta and finta on every row and TA-Lib on RSI and ATR;
|
||||
tulipy's SIMD C (and TA-Lib on SMA/EMA) lead the remaining rows.
|
||||
|
||||
**Rust** (50 000-bar pass, µs, lower = faster). Only Wickra and `kand` expose a
|
||||
batch API; `ta-rs` and `yata` are streaming-only:
|
||||
|
||||
| Indicator | **★ Wickra** | kand |
|
||||
|------------------|------------------:|-------:|
|
||||
| SMA(20) | 53 | **41** |
|
||||
| EMA(20) | 111 | **71** |
|
||||
| RSI(14) | **221 ★** | 259 |
|
||||
| MACD(12, 26, 9) | 533 | **327** |
|
||||
| Bollinger(20, 2) | **404 ★** | 460 |
|
||||
| ATR(14) | **122 ★** | 169 |
|
||||
|
||||
Run the suite yourself:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
+303
-1
@@ -7,6 +7,281 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.7.7] - 2026-06-09
|
||||
### Added
|
||||
- **Go binding (`bindings/go`)** — a cgo binding over the C ABI hub exposing all
|
||||
514 indicators as idiomatic types with `New<Indicator>` constructors and
|
||||
`Update`/`Batch`/`Reset`/`Close` methods, generated from `wickra.h`. Handles are
|
||||
freed by `Close()` with a `runtime.SetFinalizer` backstop. Ships a full example
|
||||
suite mirroring the C and C# examples; distributed as a subdirectory module
|
||||
(`go get github.com/wickra-lib/wickra/bindings/go`).
|
||||
|
||||
## [0.7.6] - 2026-06-09
|
||||
### Added
|
||||
- **C# / .NET binding (`bindings/csharp`)** — the first language stecker on the
|
||||
C ABI hub. Exposes all 514 indicators as idiomatic `IDisposable` classes via
|
||||
`[LibraryImport]` source-generated P/Invoke, generated from `wickra.h`. Ships
|
||||
on NuGet as `Wickra` with prebuilt native libraries for six target triples
|
||||
(win/linux/osx × x64/arm64), plus a full example suite mirroring the C examples.
|
||||
|
||||
## [0.7.5] - 2026-06-09
|
||||
### Added
|
||||
- **C ABI (`bindings/c`)** — a `cdylib` + `staticlib` plus a generated
|
||||
`include/wickra.h` exposing all 514 indicators and 10 bar builders over an
|
||||
opaque-handle C ABI: the hub any C-capable language (C, C++, Go, C#, Java, R)
|
||||
links against, complementing the native Python/Node/WASM bindings. Ships a
|
||||
full example suite (streaming, backtest, multi-timeframe, OpenMP parallel
|
||||
fan-out, three educational strategies, and Binance fetch/live over `curl`)
|
||||
mirroring the other bindings, plus an optional `wickra.hpp` C++ RAII wrapper.
|
||||
|
||||
## [0.7.4] - 2026-06-08
|
||||
- **Three-Line Break** — Three-line-break bars (reversal needs N-line break) (`THREE_LINE_BREAK_BARS`).
|
||||
- **Run** — Run bars (consecutive same-direction tick runs) (`RUN_BARS`).
|
||||
- **Imbalance** — Imbalance bars (tick-rule signed imbalance threshold) (`IMBALANCE_BARS`).
|
||||
- **Dollar** — Dollar bars (fixed traded value per bar, Lopez de Prado) (`DOLLAR_BARS`).
|
||||
- **Volume** — Volume bars (fixed traded volume per bar) (`VOLUME_BARS`).
|
||||
- **Tick** — Tick bars (fixed candle count per bar) (`TICK_BARS`).
|
||||
- **Range** — Range bars (fixed price-range bricks) (`RANGE_BARS`).
|
||||
|
||||
## [0.7.3] - 2026-06-08
|
||||
- **M2Measure** — M2 measure (Modigliani; Sharpe expressed in benchmark return units) (`M2Measure`).
|
||||
- **UpsidePotentialRatio** — Upside Potential Ratio (upside mean over downside deviation) (`UpsidePotentialRatio`).
|
||||
- **GainToPainRatio** — Gain-to-Pain Ratio (sum of returns over sum of losses) (`GainToPainRatio`).
|
||||
- **CommonSenseRatio** — Common Sense Ratio (tail ratio times gain-to-pain) (`CommonSenseRatio`).
|
||||
- **KRatio** — K-Ratio (Kestner; equity-curve slope over its standard error) (`KRatio`).
|
||||
- **TailRatio** — Tail Ratio (95th over absolute 5th return percentile) (`TailRatio`).
|
||||
- **MartinRatio** — Martin Ratio (Ulcer Performance Index; return over RMS drawdown) (`MartinRatio`).
|
||||
- **BurkeRatio** — Burke Ratio (return over root-sum-squared drawdowns) (`BurkeRatio`).
|
||||
- **SterlingRatio** — Sterling Ratio (mean return over average drawdown) (`SterlingRatio`).
|
||||
|
||||
## [0.7.2] - 2026-06-08
|
||||
- **Composite Profile** — multi-session composite volume profile exposing POC, VAH and VAL (`CompositeProfile`).
|
||||
- **High/Low Volume Nodes** — highest- and lowest-volume price nodes in the profile (`HighLowVolumeNodes`).
|
||||
- **Profile Shape** — profile shape classification (b/P/D normal) as a numeric code (`ProfileShape`).
|
||||
- **Single Prints** — count of single-print (low-activity) price levels in the profile (`SinglePrints`).
|
||||
- **Naked POC** — most recent untouched (naked) point of control level (`NakedPoc`).
|
||||
|
||||
## [0.7.1] - 2026-06-08
|
||||
- **Open-Interest Momentum** — rate-of-change of open interest over a rolling window (`OpenInterestMomentum`).
|
||||
- **Funding-Implied APR** — annualised funding rate (per-interval funding times intervals per year) (`FundingImpliedApr`).
|
||||
- **Perpetual Premium Index** — relative premium of the mark price over the index price (`PerpetualPremiumIndex`).
|
||||
- **OI-to-Volume Ratio** — open interest divided by taker volume (position turnover proxy) (`OiToVolumeRatio`).
|
||||
- **Estimated Leverage Ratio** — open interest divided by aggregate long+short position size (leverage proxy) (`EstimatedLeverageRatio`).
|
||||
|
||||
## [0.7.0] - 2026-06-08
|
||||
- **Hasbrouck Information Share** — variance-ratio proxy for each venue's share of price discovery (Hasbrouck information share) (`HasbrouckInformationShare`).
|
||||
- **PIN** — probability of informed trading from rolling buy/sell imbalance (EKOP single-window estimator) (`Pin`).
|
||||
- **Trade-Sign Autocorrelation** — lag-1 autocorrelation of the signed trade aggressor (order-flow persistence) (`TradeSignAutocorrelation`).
|
||||
|
||||
## [0.6.9] - 2026-06-08
|
||||
- **Tristar** — a three-doji star reversal: three consecutive dojis with the middle gapped above (bearish) or below (bullish) its neighbours (`Tristar`).
|
||||
- **Harami Cross** — a Harami whose second candle is a contained doji, a stronger reversal than a plain Harami (`HaramiCross`).
|
||||
- **Tower Top/Bottom** — a tall bar, a small pause bar, then a tall opposite bar marking a reversal (`TowerTopBottom`).
|
||||
- **Frying Pan Bottom** — a rounded (U-shaped) accumulation base over the lookback window, confirmed when price recovers above the rim (`FryPanBottom`).
|
||||
- **Dumpling Top** — a rounded (dome-shaped) distribution top over the lookback window, confirmed when price breaks below the start (`DumplingTop`).
|
||||
- **New Price Lines** — flags a run of N consecutive new closing highs (+1) or lows (-1), the eight/ten-new-price-lines exhaustion gauge (`NewPriceLines`).
|
||||
|
||||
## [0.6.8] - 2026-06-08
|
||||
- **Smoothed Heikin-Ashi** — a Heikin-Ashi candle computed from EMA-smoothed OHLC, damping noise into a cleaner trend candle (`SmoothedHeikinAshi`).
|
||||
- **Heikin-Ashi Oscillator** — the Heikin-Ashi candle body (`ha_close − ha_open`), optionally EMA-smoothed, as a zero-line oscillator (`HeikinAshiOscillator`).
|
||||
- **Three Line Break** — the trend direction of a line-break chart, reversing only when the close breaks the extreme of the last N lines (`ThreeLineBreak`).
|
||||
- **Equivolume** — a chart box whose height is the bar range and whose width is volume-relative, fusing price range with activity (`Equivolume`).
|
||||
- **CandleVolume** — a candle whose body is close-minus-open and whose width is volume-relative, a volume-weighted candle chart (`CandleVolume`).
|
||||
|
||||
## [0.6.7] - 2026-06-08
|
||||
- **TD Camouflage** — a DeMark qualifier flagging hidden intrabar strength or weakness against the prior close (`TDCamouflage`).
|
||||
- **TD Clop** — a DeMark two-bar open/close engulfing reversal where the bar opens beyond and closes back across the prior body (`TDClop`).
|
||||
- **TD Clopwin** — the inside-body cousin of TD Clop, marking a compression bar whose direction hints at the next move (`TDClopwin`).
|
||||
- **TD Propulsion** — a DeMark continuation thrust that opens on the trend side and closes beyond the prior bar's extreme (`TDPropulsion`).
|
||||
- **TD Trap** — an inside ("trap") bar followed by a close beyond its range, triggering a directional breakout signal (`TDTrap`).
|
||||
- **TD D-Wave** — a streaming Elliott-style swing-wave counter labelling the market's 1–5 impulse / A–C correction sequence (`TDDWave`).
|
||||
- **TD Moving Averages** — the DeMark ST1 (fast) and ST2 (slow) median-price trend ribbon whose crossover frames the trend (`TDMovingAverage`).
|
||||
|
||||
## [0.6.6] - 2026-06-08
|
||||
- **Pivot Reversal** — a breakout signal when price closes through the most recently confirmed swing pivot (`PIVOT_REVERSAL`).
|
||||
- **Volume-Weighted Support/Resistance** — a band whose edges are the volume-weighted average of recent highs and lows (`VOLUME_WEIGHTED_SR`).
|
||||
- **Andrews Pitchfork** — median line and two parallels projected from the last three swing pivots (`ANDREWS_PITCHFORK`).
|
||||
- **Murrey Math Lines** — T. H. Murrey's eighths grid over the recent trading range, each level acting as support/resistance (`MURREY_MATH_LINES`).
|
||||
- **Central Pivot Range** — the classic pivot flanked by two central levels gauging the day's expected character (`CENTRAL_PIVOT_RANGE`).
|
||||
- **Faster scalar batch paths** — `Ema`, `Rsi`, `BollingerBands`, `MacdIndicator` and `Atr` gained dedicated batch fast paths (used by the Python bindings) that strip per-element `Option`/validation overhead and the intermediate `Vec<Option<_>>` allocation, while staying *bit-for-bit* equal to replaying `update` (including the SMA/Bollinger drift-reseed). Python batch is ~2× faster on EMA/RSI/MACD/ATR; streaming is unchanged.
|
||||
- **Cross-library benchmark refresh** — `benchmarks/compare_libraries.py` now measures the median across timing rounds (`--rounds` / `--streaming-rounds`), adds `--skip-batch` / `--skip-streaming`, and drives every peer through the streaming arena (recompute for batch-only libraries). `wickra-bench` compares the batch fast paths against `kand`.
|
||||
|
||||
## [0.6.5] - 2026-06-07
|
||||
- **Autocorrelation Periodogram** — Ehlers autocorrelation periodogram: dominant cycle period estimate (`AUTOCORRPGRAM`).
|
||||
- **Even Better Sinewave** — Ehlers Even Better Sinewave: normalized cycle-phase oscillator (`EVENBETTERSINE`).
|
||||
- **Bandpass Filter** — Ehlers bandpass filter: isolates a frequency band around the dominant cycle (`BANDPASS`).
|
||||
- **Adaptive CCI** — Adaptive CCI: efficiency-ratio-adaptive CCI on typical price (`ADAPTIVECCI`).
|
||||
- **Universal Oscillator** — Ehlers Universal Oscillator: SuperSmoother-based normalized cycle oscillator (`UNIVERSALOSC`).
|
||||
- **Adaptive RSI** — Adaptive RSI: dominant-cycle-tuned RSI length (Ehlers) (`ADAPTIVERSI`).
|
||||
- **Correlation Trend Indicator** — Ehlers Correlation Trend Indicator: Pearson correlation of price vs time (`CTI`).
|
||||
- **Trendflex** — Ehlers Trendflex: trend-following companion to Reflex (`TRENDFLEX`).
|
||||
- **Reflex** — Ehlers Reflex: trend-cycle oscillator measuring slope-adjusted displacement (`REFLEX`).
|
||||
- **Highpass Filter** — Ehlers highpass filter: removes low-frequency trend, leaving cyclic component (`HIGHPASS`).
|
||||
|
||||
## [0.6.4] - 2026-06-07
|
||||
- **Kendall Tau** — Kendall rank correlation (tau-b) over a rolling window of paired observations (`KENDALLTAU`).
|
||||
- **Sample Entropy** — Sample entropy: regularity/complexity of a rolling series (Richman-Moorman) (`SAMPLEENT`).
|
||||
- **Shannon Entropy** — Shannon entropy of a rolling value distribution over fixed bins (`SHANNONENT`).
|
||||
- **Rolling Min-Max Scaler** — Rolling min-max scaler mapping the latest value to 0..1 over a rolling window (`ROLLINGMINMAX`).
|
||||
- **Jarque-Bera** — Jarque-Bera normality test statistic over a rolling window (`JARQUEBERA`).
|
||||
|
||||
## [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`).
|
||||
- **PPO Histogram** — the Percentage Price Oscillator with its signal EMA and the resulting zero-centered histogram (`PpoHistogram`).
|
||||
|
||||
## [0.5.7] - 2026-06-04
|
||||
- **Qstick** — Qstick (Chande), the SMA of the candle body (close − open) as a net buying/selling pressure gauge (`QSTICK`).
|
||||
- **TTM Trend** — TTM Trend (John Carter), +1/−1 by whether the close sits above the SMA of recent median prices (`TTM_TREND`).
|
||||
- **Trend Strength Index** — trend strength index, the signed r² of a linear regression of price against time (`TREND_STRENGTH_INDEX`).
|
||||
- **Polarized Fractal Efficiency** — polarized fractal efficiency (Hannula), directional trend efficiency over a fractal lookback (`POLARIZED_FRACTAL_EFFICIENCY`).
|
||||
- **Wave PM** — Wave PM (Kase), a variance-normalised peak-momentum statistic (`WAVE_PM`).
|
||||
- **Gator Oscillator** — Gator Oscillator (Bill Williams), the Alligator convergence/divergence histogram (`GATOR_OSCILLATOR`).
|
||||
- **Kase Permission Stochastic** — Kase Permission Stochastic, a double-smoothed stochastic used as a trade-permission filter (`KASE_PERMISSION_STOCHASTIC`).
|
||||
|
||||
## [0.5.6] - 2026-06-04
|
||||
- **QQE** — quantitative qualitative estimation, a smoothed RSI with an ATR-of-RSI trailing line (`QQE`).
|
||||
- **Intraday Momentum Index** — intraday momentum index (Chande), RSI on the open-to-close body (`IMI`).
|
||||
- **Elder Ray** — Elder Ray bull power and bear power around an EMA of close (`ElderRay`).
|
||||
- **Derivative Oscillator** — derivative oscillator (Constance Brown), a double-smoothed RSI histogram (`DerivativeOscillator`).
|
||||
- **RMI** — relative momentum index (RMI), RSI over a multi-bar momentum lookback (`RMI`).
|
||||
- **Stochastic CCI** — stochastic CCI, a stochastic oscillator over the CCI (`StochasticCCI`).
|
||||
- **Dynamic Momentum Index** — dynamic momentum index (Chande), a volatility-adaptive RSI (`DynamicMomentumIndex`).
|
||||
- **RSX** — RSX, a Jurik-style three-stage smoothed RSI (`RSX`).
|
||||
- **Fisher RSI** — Fisher RSI, the Fisher transform of a normalised RSI (`FisherRSI`).
|
||||
- **Disparity Index** — disparity index, the percent gap between price and its moving average (`DisparityIndex`).
|
||||
|
||||
## [0.5.5] - 2026-06-04
|
||||
- **GD** — generalized DEMA (GD), Tillson's volume-factor double EMA and the building block of T3 (`GD`).
|
||||
- **GMA** — geometric moving average (GMA), the rolling geometric mean of prices (`GMA`).
|
||||
- **Holt-Winters** — Holt's linear (double exponential) smoothing with level and trend components (`HoltWinters`).
|
||||
- **Adaptive Laguerre** — Ehlers adaptive Laguerre filter with median-error-adaptive gamma (`AdaptiveLaguerre`).
|
||||
- **Median MA** — median moving average, the rolling median of prices (`MedianMA`).
|
||||
- **EHMA** — exponential Hull moving average (EHMA), the Hull construction built from EMAs (`EHMA`).
|
||||
- **SWMA** — sine-weighted moving average (SWMA), a symmetric half-cycle sine window (`SWMA`).
|
||||
|
||||
## [0.5.4] - 2026-06-04
|
||||
- **Roll Measure** — effective spread implied by the negative serial covariance of trade-price changes (Roll 1984) (`RollMeasure`).
|
||||
- **Amihud Illiquidity** — average absolute log return per unit of traded value (price-impact liquidity proxy, Amihud 2002) (`AmihudIlliquidity`).
|
||||
- **VPIN** — volume-synchronised probability of informed trading (volume-bucketed order-flow toxicity) (`Vpin`).
|
||||
- **Order Flow Imbalance** — rolling sum of best-level order-flow events (Cont-Kukanov-Stoikov OFI) (`OrderFlowImbalance`).
|
||||
- **Expectancy** — expected return per unit of average loss (R-multiple) over a rolling window of returns (`Expectancy`).
|
||||
- **Win Rate** — fraction of strictly-positive returns over a rolling window (`WinRate`).
|
||||
- **Regime Label** — volatility-quantile regime classification: −1 calm / 0 normal / +1 stressed, by where the rolling volatility sits in its own recent distribution (`RegimeLabel`).
|
||||
- **Jump Indicator** — flags return outliers beyond `threshold ×` trailing return volatility (−1 down / 0 / +1 up) (`JumpIndicator`).
|
||||
- **Trend Label** — discrete trend state from the sign of the rolling least-squares slope (−1 / 0 / +1) (`TrendLabel`).
|
||||
- **High-Low Range** — bar high-low range as a fraction of close (scale-free per-bar volatility) (`HighLowRange`).
|
||||
- **Wick Ratio** — signed upper-vs-lower shadow imbalance as a fraction of the range (`WickRatio`).
|
||||
- **Body Size Percent** — absolute candle body as a fraction of the bar range (`BodySizePct`).
|
||||
- **Close vs Open** — signed body as a fraction of the open price, `(close − open) / open` (`CloseVsOpen`).
|
||||
- **Spread AR(1) Coefficient** — first-order autoregression coefficient of the spread `a − b` (direct cointegration / mean-reversion strength) (`SpreadAr1Coefficient`).
|
||||
- **Rolling Quantile** — interpolated q-th quantile over a trailing window (type-7 / NumPy default) (`RollingQuantile`).
|
||||
- **Rolling Percentile Rank** — percentile rank of the latest value within its trailing window (`RollingPercentileRank`).
|
||||
- **Rolling IQR** — interquartile range (Q3 − Q1) over a trailing window (robust dispersion) (`RollingIqr`).
|
||||
- **Realized Volatility** — square root of the summed squared log returns (raw, un-annualised quadratic variation) (`RealizedVolatility`).
|
||||
- **Log Return** — logarithmic return over a fixed lag, `ln(price_t / price_{t−period})` (`LogReturn`).
|
||||
|
||||
## [0.5.3] - 2026-06-04
|
||||
- **Fibonacci Time Zones** — vertical markers at Fibonacci bar-distances (1/2/3/5/8/...) from the latest swing pivot (`FIB_TIME_ZONES`).
|
||||
- **Fibonacci Channel** — a sloped base trendline plus parallel lines at Fibonacci multiples of the channel width (`FIB_CHANNEL`).
|
||||
- **Fibonacci Arcs** — semicircular retracement levels centred on the swing end, normalised by leg bar-width (`FIB_ARCS`).
|
||||
- **Fibonacci Fan** — three trendlines fanning from a swing start through its 38.2/50/61.8% retracement levels (`FIB_FAN`).
|
||||
- **Fibonacci Confluence** — densest cluster of retracement levels across recent swing legs (price + strength) (`FIB_CONFLUENCE`).
|
||||
- **Golden Pocket** — the 0.618-0.65 optimal-trade-entry band of the most recent swing leg (`GOLDEN_POCKET`).
|
||||
- **Auto-Fibonacci** — retracement anchored on the dominant (largest-magnitude) leg among recent swings (`AUTO_FIB`).
|
||||
- **Fibonacci Projection** — measured-move target zone from the last three pivots (A-B-C), projecting A->B from C (`FIB_PROJECTION`).
|
||||
- **Fibonacci Extension** — projects the latest swing leg to the canonical extension ratios (127.2/141.4/161.8/200/261.8%) (`FIB_EXTENSION`).
|
||||
- **Fibonacci Retracement** — seven retracement levels (0/23.6/38.2/50/61.8/78.6/100%) of the most recent confirmed swing leg (`FIB_RETRACEMENT`).
|
||||
|
||||
## [0.5.2] - 2026-06-03
|
||||
|
||||
### Added
|
||||
- **Three Drives** — three symmetric drives with extension legs; bullish +1, bearish -1 (`THREE_DRIVES`).
|
||||
- **Cypher** — five-point harmonic whose D retraces XC by 0.786; bullish +1, bearish -1 (`CYPHER`).
|
||||
- **Shark** — five-point harmonic with an expansion leg and 0.886-1.13 D; bullish +1, bearish -1 (`SHARK`).
|
||||
- **Crab** — five-point harmonic with the deepest (1.618 XA) D completion; bullish +1, bearish -1 (`CRAB`).
|
||||
- **Bat** — five-point harmonic with a shallow B and 0.886 D completion; bullish +1, bearish -1 (`BAT`).
|
||||
- **Butterfly** — five-point harmonic with an extended (1.27-1.618 XA) D; bullish +1, bearish -1 (`BUTTERFLY`).
|
||||
- **Gartley** — five-point harmonic with a 0.786 D completion; bullish +1, bearish -1 (`GARTLEY`).
|
||||
- **AB=CD** — four-point AB=CD harmonic: BC retraces AB, CD mirrors AB; bullish +1, bearish -1 (`ABCD`).
|
||||
- **Cup and Handle** — rounded base with a shallow handle near the rim; bullish +1, inverse -1 (`CUP_AND_HANDLE`).
|
||||
- **Rectangle / Range** — flat support and resistance; mean-reversion signal off the just-touched boundary; support +1, resistance -1 (`RECTANGLE_RANGE`).
|
||||
- **Flag / Pennant** — shallow consolidation against a sharp pole; continuation in the pole direction; bull +1, bear -1 (`FLAG_PENNANT`).
|
||||
- **Wedge (rising/falling)** — both trendlines slope the same way but converge; rising wedge -1, falling wedge +1 (`WEDGE`).
|
||||
- **Triangle (asc/desc/sym)** — converging trendlines; ascending +1, descending -1, symmetrical follows the last swing (`TRIANGLE`).
|
||||
- **Head and Shoulders** — central head flanked by two matching shoulders over a flat neckline; top -1, inverse +1 (`HEAD_AND_SHOULDERS`).
|
||||
- **Triple Top / Bottom** — three matching peaks / troughs; a stronger reversal than the double; bearish -1, bullish +1 (`TRIPLE_TOP_BOTTOM`).
|
||||
- **Double Top / Bottom** — twin-peak / twin-trough reversal confirmed on the second matching swing extreme; bearish -1, bullish +1 (`DOUBLE_TOP_BOTTOM`).
|
||||
|
||||
## [0.5.1] - 2026-06-03
|
||||
|
||||
### Added — Seasonality & Session family (12 indicators)
|
||||
|
||||
- **Volume-by-Time Profile** — mean traded volume bucketed by intraday time (`VOLUME_BY_TIME_PROFILE`).
|
||||
- **Intraday Volatility Profile** — return standard deviation bucketed by intraday time (`INTRADAY_VOLATILITY_PROFILE`).
|
||||
- **Day-of-Week Profile** — mean bar return bucketed by weekday (`DAY_OF_WEEK_PROFILE`).
|
||||
- **Time-of-Day Return Profile** — mean bar return bucketed by intraday time (`TIME_OF_DAY_RETURN_PROFILE`).
|
||||
- **Seasonal Z-Score** — z-score of the current return versus the same hour-of-day history (`SEASONAL_Z_SCORE`).
|
||||
- **Turn-of-Month** — mean daily return inside the turn-of-month window (`TURN_OF_MONTH`).
|
||||
- **Overnight/Intraday Return** — decomposition of session return into overnight and intraday legs (`OVERNIGHT_INTRADAY_RETURN`).
|
||||
- **Overnight Gap** — close-to-open return across the session boundary (`OVERNIGHT_GAP`).
|
||||
- **Average Daily Range** — mean high-low range of the last N completed sessions (`AVERAGE_DAILY_RANGE`).
|
||||
- **Session Range** — per-session (Asia/EU/US) high-low range (`SESSION_RANGE`).
|
||||
- **Session High/Low** — running high and low of the current session (`SESSION_HIGH_LOW`).
|
||||
- **Session VWAP** — session-anchored volume-weighted average price (`SESSION_VWAP`).
|
||||
|
||||
## [0.5.0] - 2026-06-03
|
||||
|
||||
### Added
|
||||
@@ -1168,7 +1443,34 @@ 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.0...HEAD
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.7.7...HEAD
|
||||
[0.7.7]: https://github.com/wickra-lib/wickra/compare/v0.7.6...v0.7.7
|
||||
[0.7.6]: https://github.com/wickra-lib/wickra/compare/v0.7.5...v0.7.6
|
||||
[0.7.5]: https://github.com/wickra-lib/wickra/compare/v0.7.4...v0.7.5
|
||||
[0.7.4]: https://github.com/wickra-lib/wickra/compare/v0.7.3...v0.7.4
|
||||
[0.7.3]: https://github.com/wickra-lib/wickra/compare/v0.7.2...v0.7.3
|
||||
[0.7.2]: https://github.com/wickra-lib/wickra/compare/v0.7.1...v0.7.2
|
||||
[0.7.1]: https://github.com/wickra-lib/wickra/compare/v0.7.0...v0.7.1
|
||||
[0.7.0]: https://github.com/wickra-lib/wickra/compare/v0.6.9...v0.7.0
|
||||
[0.6.9]: https://github.com/wickra-lib/wickra/compare/v0.6.8...v0.6.9
|
||||
[0.6.8]: https://github.com/wickra-lib/wickra/compare/v0.6.7...v0.6.8
|
||||
[0.6.7]: https://github.com/wickra-lib/wickra/compare/v0.6.6...v0.6.7
|
||||
[0.6.6]: https://github.com/wickra-lib/wickra/compare/v0.6.5...v0.6.6
|
||||
[0.6.5]: https://github.com/wickra-lib/wickra/compare/v0.6.4...v0.6.5
|
||||
[0.6.4]: https://github.com/wickra-lib/wickra/compare/v0.6.3...v0.6.4
|
||||
[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
|
||||
[0.5.5]: https://github.com/wickra-lib/wickra/compare/v0.5.4...v0.5.5
|
||||
[0.5.4]: https://github.com/wickra-lib/wickra/compare/v0.5.3...v0.5.4
|
||||
[0.5.3]: https://github.com/wickra-lib/wickra/compare/v0.5.2...v0.5.3
|
||||
[0.5.2]: https://github.com/wickra-lib/wickra/compare/v0.5.1...v0.5.2
|
||||
[0.5.1]: https://github.com/wickra-lib/wickra/compare/v0.5.0...v0.5.1
|
||||
[0.5.0]: https://github.com/wickra-lib/wickra/compare/v0.4.7...v0.5.0
|
||||
[0.4.7]: https://github.com/wickra-lib/wickra/compare/v0.4.6...v0.4.7
|
||||
[0.4.6]: https://github.com/wickra-lib/wickra/compare/v0.4.5...v0.4.6
|
||||
|
||||
+39
-1
@@ -21,6 +21,9 @@ licensed as above, without any additional terms or conditions.
|
||||
| `bindings/python` | PyO3 bindings (`wickra` on PyPI). |
|
||||
| `bindings/node` | napi-rs bindings (`wickra` on npm). |
|
||||
| `bindings/wasm` | wasm-bindgen bindings (`wickra-wasm` on npm). |
|
||||
| `bindings/c` | C ABI — `cdylib` + `staticlib` + generated `include/wickra.h`. The hub for C / C++ and any C-capable language. |
|
||||
| `bindings/csharp` | .NET binding over the C ABI (`Wickra` on NuGet) — `[LibraryImport]` P/Invoke generated from `wickra.h`. |
|
||||
| `bindings/go` | Go binding over the C ABI via cgo (module tag `bindings/go/vX.Y.Z`) — wrappers generated from `wickra.h`. |
|
||||
| `examples/` | Runnable examples. |
|
||||
| `docs/` | Pointer to the documentation site (docs.wickra.org); the docs live in the `wickra-lib/wickra-docs` repo. |
|
||||
|
||||
@@ -102,7 +105,12 @@ installed. Dependabot also keeps the `.github/requirements` pins current.
|
||||
- **Streaming parity.** An indicator's `batch` output must equal the sequence
|
||||
of `update` calls.
|
||||
- **Bindings.** A change to a public indicator API must be mirrored across the
|
||||
Python, Node, and WASM bindings, including their type stubs / `.d.ts`.
|
||||
Python, Node, and WASM bindings, including their type stubs / `.d.ts`. The C ABI
|
||||
(`bindings/c`) is generated from the core, so regenerate it from the core and
|
||||
commit `src/lib.rs` + `include/wickra.h`. The C# binding (`bindings/csharp`) is
|
||||
generated from `wickra.h`, so regenerate and commit its `Generated/*.g.cs` too.
|
||||
The Go binding (`bindings/go`) is likewise generated from `wickra.h`, so
|
||||
regenerate and commit `indicators_gen.go` (`gofmt`-clean).
|
||||
- **Docs.** Update the relevant page on the
|
||||
[documentation site](https://docs.wickra.org) and the
|
||||
`README.md` when behaviour or the public API changes. The docs live in
|
||||
@@ -122,3 +130,33 @@ installed. Dependabot also keeps the `.github/requirements` pins current.
|
||||
Use the issue templates under
|
||||
[`.github/ISSUE_TEMPLATE`](.github/ISSUE_TEMPLATE). For security-sensitive
|
||||
reports, follow [`SECURITY.md`](SECURITY.md) instead of opening a public issue.
|
||||
|
||||
## Developer Certificate of Origin (DCO)
|
||||
|
||||
All contributions to Wickra are made under the [Developer Certificate of
|
||||
Origin (DCO) 1.1](DCO). By signing off on your commits you certify that you
|
||||
wrote the patch, or otherwise have the right to submit it under the project's
|
||||
`MIT OR Apache-2.0` license.
|
||||
|
||||
Sign off every commit by adding a `Signed-off-by` trailer with your real name
|
||||
and email — Git adds it automatically with the `-s` flag:
|
||||
|
||||
```bash
|
||||
git commit -s -m "your message"
|
||||
```
|
||||
|
||||
This produces a trailer of the form:
|
||||
|
||||
```
|
||||
Signed-off-by: Your Name <you@example.com>
|
||||
```
|
||||
|
||||
The name and email must match the commit author. Commits without a valid
|
||||
sign-off line cannot be merged. To sign off a commit you already made, amend it
|
||||
with `git commit -s --amend`, or sign off a range with an interactive rebase.
|
||||
|
||||
## Governance
|
||||
|
||||
Wickra's decision-making and maintainership are described in
|
||||
[`GOVERNANCE.md`](GOVERNANCE.md); the current maintainers are listed in
|
||||
[`MAINTAINERS.md`](MAINTAINERS.md).
|
||||
|
||||
Generated
+121
-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.0"
|
||||
version = "0.7.7"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1876,9 +1953,28 @@ dependencies = [
|
||||
"wickra-data",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wickra-bench"
|
||||
version = "0.7.7"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"kand",
|
||||
"ta",
|
||||
"wickra",
|
||||
"wickra-data",
|
||||
"yata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wickra-c"
|
||||
version = "0.7.7"
|
||||
dependencies = [
|
||||
"wickra-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.5.0"
|
||||
version = "0.7.7"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
@@ -1888,7 +1984,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.5.0"
|
||||
version = "0.7.7"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"csv",
|
||||
@@ -1905,7 +2001,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-examples"
|
||||
version = "0.0.0"
|
||||
version = "0.7.7"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -1915,7 +2011,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-node"
|
||||
version = "0.5.0"
|
||||
version = "0.7.7"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -1925,7 +2021,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.5.0"
|
||||
version = "0.7.7"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -1934,7 +2030,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.5.0"
|
||||
version = "0.7.7"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
@@ -1991,6 +2087,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 +2196,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"
|
||||
|
||||
+4
-2
@@ -7,12 +7,14 @@ members = [
|
||||
"bindings/python",
|
||||
"bindings/wasm",
|
||||
"bindings/node",
|
||||
"bindings/c",
|
||||
"examples/rust",
|
||||
"crates/wickra-bench",
|
||||
]
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.0"
|
||||
version = "0.7.7"
|
||||
authors = ["kingchenc <support@wickra.org>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.86"
|
||||
@@ -24,7 +26,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
|
||||
categories = ["finance", "mathematics", "science"]
|
||||
|
||||
[workspace.dependencies]
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.5.0" }
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.7.7" }
|
||||
|
||||
thiserror = "2"
|
||||
rayon = "1.10"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
Developer Certificate of Origin
|
||||
Version 1.1
|
||||
|
||||
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this
|
||||
license document, but changing it is not allowed.
|
||||
|
||||
|
||||
Developer's Certificate of Origin 1.1
|
||||
|
||||
By making a contribution to this project, I certify that:
|
||||
|
||||
(a) The contribution was created in whole or in part by me and I
|
||||
have the right to submit it under the open source license
|
||||
indicated in the file; or
|
||||
|
||||
(b) The contribution is based upon previous work that, to the best
|
||||
of my knowledge, is covered under an appropriate open source
|
||||
license and I have the right under that license to submit that
|
||||
work with modifications, whether created in whole or in part
|
||||
by me, under the same open source license (unless I am
|
||||
permitted to submit under a different license), as indicated
|
||||
in the file; or
|
||||
|
||||
(c) The contribution was provided directly to me by some other
|
||||
person who certified (a), (b) or (c) and I have not modified
|
||||
it.
|
||||
|
||||
(d) I understand and agree that this project and the contribution
|
||||
are public and that a record of the contribution (including all
|
||||
personal information I submit with it, including my sign-off) is
|
||||
maintained indefinitely and may be redistributed consistent with
|
||||
this project or the open source license(s) involved.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Governance
|
||||
|
||||
Wickra is an open-source project maintained under a **single-maintainer
|
||||
("BDFL") model**. This document describes how decisions are made and how the
|
||||
project is run, so contributors know what to expect.
|
||||
|
||||
## Roles
|
||||
|
||||
- **Maintainer.** The maintainer (see [`MAINTAINERS.md`](MAINTAINERS.md)) is
|
||||
responsible for the project's direction, reviews and merges changes, cuts
|
||||
releases, and has final say on all technical and project decisions.
|
||||
- **Contributors.** Anyone who proposes changes via pull requests, files
|
||||
issues, improves documentation, or otherwise participates. Contributors do
|
||||
not need any special status to take part.
|
||||
|
||||
## Decision-making
|
||||
|
||||
- Day-to-day technical decisions (APIs, indicator implementations, refactors)
|
||||
are made by the maintainer, informed by discussion on issues and pull
|
||||
requests.
|
||||
- Proposals are raised as GitHub issues or pull requests. Significant or
|
||||
breaking changes should be opened as an issue first to agree on the approach
|
||||
before implementation.
|
||||
- The maintainer aims to act transparently: rationale for non-trivial decisions
|
||||
is recorded in the relevant issue, pull request, or commit message.
|
||||
|
||||
## Contribution flow
|
||||
|
||||
All changes — including the maintainer's own — go through pull requests so that
|
||||
CI (tests, linting, static analysis) runs against them, and so the change
|
||||
history is reviewable. Contribution requirements are documented in
|
||||
[`CONTRIBUTING.md`](CONTRIBUTING.md), including the Developer Certificate of
|
||||
Origin sign-off that every commit must carry.
|
||||
|
||||
## Becoming a maintainer
|
||||
|
||||
The project currently has one maintainer. Maintainership may be extended to
|
||||
contributors who have demonstrated sustained, high-quality involvement, at the
|
||||
current maintainer's discretion. If the project grows to multiple maintainers,
|
||||
this document will be updated to describe shared decision-making.
|
||||
|
||||
## Continuity and succession
|
||||
|
||||
The project is designed to survive the loss of any single individual, so that
|
||||
issues can be triaged, proposed changes accepted, and releases published within
|
||||
one week of confirmed loss of the maintainer:
|
||||
|
||||
- **Credentials.** All credentials required to operate the project — the
|
||||
`wickra-lib` GitHub organization, the publishing tokens for crates.io, PyPI
|
||||
and npm, and the `wickra.org` domain registrar — are stored in a password
|
||||
manager. A trusted contact (a family member) holds **emergency access** to
|
||||
that password manager and can obtain these credentials if the maintainer can
|
||||
no longer continue.
|
||||
- **Continuity actions.** With that access, the trusted contact (or a delegate
|
||||
they appoint) can create and close issues, accept pull requests, and publish
|
||||
releases through the existing CI/CD workflows.
|
||||
- **Account recovery.** The maintainer's GitHub account has recovery configured,
|
||||
and ownership of the `wickra-lib` organization can be transferred to a new
|
||||
maintainer.
|
||||
- **Legal rights.** Legal rights to the project name and DNS are covered by the
|
||||
maintainer's estate arrangements.
|
||||
|
||||
## Code of conduct
|
||||
|
||||
All participants are expected to follow the
|
||||
[Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
## Changes to this document
|
||||
|
||||
This governance model may evolve as the project grows. Changes are made via
|
||||
pull request and take effect once merged.
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative
|
||||
Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 kingchenc and the Wickra contributors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 kingchenc and the Wickra contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Maintainers
|
||||
|
||||
This file lists the current maintainers of Wickra. See
|
||||
[`GOVERNANCE.md`](GOVERNANCE.md) for what the role entails and how the project
|
||||
is run.
|
||||
|
||||
| Maintainer | GitHub | Areas |
|
||||
| --- | --- | --- |
|
||||
| kingchenc | [@kingchenc](https://github.com/kingchenc) | All (core, bindings, CI/release, docs) |
|
||||
|
||||
## Contacting the maintainers
|
||||
|
||||
- General questions and support: see [`SUPPORT.md`](SUPPORT.md).
|
||||
- Bug reports and feature requests: open an issue using the
|
||||
[issue templates](.github/ISSUE_TEMPLATE).
|
||||
- Security reports: follow [`SECURITY.md`](SECURITY.md) — do **not** open a
|
||||
public issue.
|
||||
@@ -1,24 +1,27 @@
|
||||
<p align="center">
|
||||
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=339" 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=514" alt="Wickra — streaming-first technical indicators" width="100%"></a>
|
||||
</p>
|
||||
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/codeql.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://github.com/wickra-lib/wickra/releases/latest)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
[](#license)
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/wickra-lib/wickra)
|
||||
[](https://github.com/wickra-lib/wickra/attestations)
|
||||
[](https://docs.wickra.org)
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/codeql.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://github.com/wickra-lib/wickra/releases/latest)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
[](https://www.nuget.org/packages/Wickra)
|
||||
[](#license)
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/wickra-lib/wickra)
|
||||
[](https://www.bestpractices.dev/projects/13094)
|
||||
[](https://github.com/wickra-lib/wickra/attestations)
|
||||
[](https://docs.wickra.org)
|
||||
|
||||
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
|
||||
|
||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
|
||||
machine that updates in O(1) per new data point, so live trading bots and
|
||||
native bindings for Python, Node.js and WebAssembly, plus a C ABI that C, C++,
|
||||
C# / .NET, Go and any other C-capable language links against. Every indicator is a
|
||||
state machine that updates in O(1) per new data point, so live trading bots and
|
||||
historical backtests share the exact same implementation.
|
||||
|
||||
```python
|
||||
@@ -45,9 +48,12 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
|
||||
- **Quickstarts** — [Rust](https://docs.wickra.org/Quickstart-Rust),
|
||||
[Python](https://docs.wickra.org/Quickstart-Python),
|
||||
[Node](https://docs.wickra.org/Quickstart-Node),
|
||||
[WASM](https://docs.wickra.org/Quickstart-WASM).
|
||||
[WASM](https://docs.wickra.org/Quickstart-WASM),
|
||||
[C](https://docs.wickra.org/Quickstart-C),
|
||||
[C#](https://docs.wickra.org/Quickstart-CSharp),
|
||||
[Go](https://docs.wickra.org/Quickstart-Go).
|
||||
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
|
||||
every one of the 339 indicators; start at the
|
||||
every one of the 514 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),
|
||||
@@ -57,111 +63,109 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
|
||||
[TA-Lib migration](https://docs.wickra.org/TA-Lib-Migration),
|
||||
[FAQ](https://docs.wickra.org/FAQ).
|
||||
|
||||
## Why Wickra
|
||||
|
||||
Most TA libraries are fast, *or* multi-language, *or* broad. Wickra refuses to
|
||||
pick. It's the streaming-first engine built for the workload the others treat as
|
||||
an afterthought — **live, tick-by-tick data** — without giving up the breadth of
|
||||
a full batch library, and without making you reimplement your indicators four
|
||||
times to get there.
|
||||
|
||||
- **The biggest streaming-native catalogue, period.** 514 indicators across 24
|
||||
families — candlesticks, harmonic & chart patterns, market profile, market
|
||||
breadth, Renko/Kagi/Point&Figure bars, Ehlers DSP cycles, risk/performance
|
||||
metrics — every single one updating in **O(1) per tick**. TA-Lib ships ~150 and
|
||||
none of them stream.
|
||||
- **One Rust core, five first-class targets.** Native **Python · Node.js ·
|
||||
WebAssembly · Rust** plus a **C ABI** for C / C++, C# / .NET, Go and any other C-capable language —
|
||||
identical math, identical results, zero per-language reimplementation and zero
|
||||
GIL bottleneck.
|
||||
- **Correct by construction, not by hope.** Every `update` validates its input,
|
||||
runs a real warmup, and returns an `Option` so a single bad tick can't silently
|
||||
poison state. `batch == streaming` is **bit-exact, fuzzed and 100 %-line-covered
|
||||
for all 514 indicators**.
|
||||
- **Orders of magnitude faster where it counts.** In streaming Wickra is **11–56×**
|
||||
faster than the only other incremental peer and **thousands of times** faster
|
||||
than recompute-on-every-tick libraries. On batch it wins several rows outright
|
||||
and trades the simple recurrences (SMA, EMA, MACD) for its guarantees — and
|
||||
the losses are shown, not hidden.
|
||||
- **Install in one line, anywhere.** `pip install wickra` / `npm install wickra` —
|
||||
precompiled wheels and binaries, **no C toolchain, none of TA-Lib's setup pain**.
|
||||
macOS · Linux · Windows.
|
||||
- **Batteries included.** Indicator chaining, a streaming OHLCV CSV reader, and a
|
||||
live Binance kline feed ship in the box.
|
||||
- **Truly permissive.** **MIT OR Apache-2.0** — drop it straight into commercial
|
||||
and closed-source work.
|
||||
|
||||
Every other library forces one of those compromises. Wickra doesn't:
|
||||
|
||||
| Library | Install | Streaming | Languages | Indicators | Active |
|
||||
|------------------|-------------|-------------|-----------------------------|-----------:|--------|
|
||||
| **★ Wickra**| **clean** | **yes, O(1)** | **Rust · Python · Node · WASM** | **514** | **yes** |
|
||||
| | | | **C · C# · Go** | | |
|
||||
| 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 |
|
||||
|
||||
Broad, multi-language, streaming-native **and** honest about its trade-offs — at
|
||||
the same time. That's the combination no one else ships.
|
||||
|
||||
## 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 |
|
||||
## Benchmarks
|
||||
|
||||
Wickra is the only library that combines all of: clean install, streaming,
|
||||
multi-language reach, and active maintenance.
|
||||
Wickra updates every indicator in **O(1)** per tick. In **streaming** — the
|
||||
workload it is built for — it is **11–56× faster** than the only other incremental
|
||||
peer and **thousands of times** faster than recompute-on-every-tick libraries.
|
||||
**Batch** is competitive: it wins several rows outright and trades a few µs
|
||||
elsewhere for `None`-warmup, NaN-safety and bit-exact `batch == streaming`.
|
||||
|
||||
## Benchmark: how much faster is "streaming-first"?
|
||||
|
||||
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.
|
||||
|
||||
- **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.
|
||||
|
||||
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.
|
||||
|
||||
### Batch — single full pass over a 20 000-bar series
|
||||
|
||||
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.
|
||||
|
||||
| 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) |
|
||||
|
||||
### Streaming — per-tick latency after seeding with 5 000 historical bars
|
||||
|
||||
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 (per tick)** | talipp (per tick) |
|
||||
|-----------|---------------------|---------------------------|
|
||||
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
|
||||
|
||||
> 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.
|
||||
|
||||
Run the suite yourself:
|
||||
|
||||
```bash
|
||||
pip install -e bindings/python[bench]
|
||||
python -m benchmarks.compare_libraries
|
||||
```
|
||||
Full tables (Rust + Python, streaming + batch) and how to reproduce them live in
|
||||
**[BENCHMARKS.md](BENCHMARKS.md)**.
|
||||
|
||||
## Indicators
|
||||
|
||||
339 streaming-first indicators across twenty families. Every one passes the
|
||||
514 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).
|
||||
|
||||
| Family | Indicators |
|
||||
|--------|-----------|
|
||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
|
||||
| Momentum Oscillators | RSI (Wilder), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia, ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100) |
|
||||
| Trend & Directional | MACD, MACD Fixed (MACDFIX), MACD Extended (MACDEXT), ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter, Plus DM, Minus DM, Plus DI, Minus DI, DX |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
||||
| Alt-Chart Bars | Renko (box-size bricks), Kagi (reversal-amount lines), Point & Figure (X/O columns) |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down, Two Crows, Upside Gap Two Crows, Identical Three Crows, Three Line Strike, Three Stars in the South, Abandoned Baby, Advance Block, Belt-hold, Breakaway, Counterattack, Doji Star, Dragonfly Doji, Gravestone Doji, Long-Legged Doji, Rickshaw Man, Evening Doji Star, Morning Doji Star, Gap Side-by-Side White, High-Wave, Hikkake, Modified Hikkake, Homing Pigeon, On-Neck, In-Neck, Thrusting, Separating Lines, Kicking, Kicking by Length, Ladder Bottom, Mat Hold, Matching Low, Long Line, Short Line, Rising Three Methods, Falling Three Methods, Upside Gap Three Methods, Downside Gap Three Methods, Stalled Pattern, Stick Sandwich, Takuri, Closing Marubozu, Opening Marubozu, Tasuki Gap, Unique Three River, Concealing Baby Swallow |
|
||||
| Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, Depth Slope, Signed Volume, Cumulative Volume Delta, Trade Imbalance, Effective Spread, Realized Spread, Kyle's Lambda, Footprint |
|
||||
| Derivatives | Funding Rate, Funding Rate Mean, Funding Rate Z-Score, Funding Basis, Open-Interest Delta, OI / Price Divergence, OI-Weighted Price, Long/Short Ratio, Taker Buy/Sell Ratio, Liquidation Features, Term-Structure Basis, Calendar Spread |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Volume Profile (histogram), TPO Profile, Initial Balance, Opening Range |
|
||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, SWMA, GMA, EHMA, Median MA, Adaptive Laguerre, GD, Holt-Winters |
|
||||
| Momentum Oscillators | RSI (Wilder), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia, ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100), Disparity Index, Fisher RSI, RSX, Dynamic Momentum Index, Stochastic CCI, RMI, Derivative Oscillator, Elder Ray, Intraday Momentum Index, QQE |
|
||||
| Trend & Directional | MACD, MACD Fixed (MACDFIX), MACD Extended (MACDEXT), ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter, Plus DM, Minus DM, Plus DI, Minus DI, DX, TTM Trend, Trend Strength Index, Qstick, Polarized Fractal Efficiency, Wave PM, Gator Oscillator, Kase Permission Stochastic |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC, TSF Oscillator, MACD Histogram, PPO Histogram |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Volatility Cone |
|
||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands, Quartile Bands, Bomar Bands, Median Channel, Projection Bands, Projection Oscillator |
|
||||
| Trailing Stops | Parabolic SAR, Parabolic SAR Extended (SAREXT), SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop, 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, Jarque-Bera, Rolling Min-Max Scaler, Shannon Entropy, Sample Entropy, Kendall Tau |
|
||||
| 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, Highpass Filter, Reflex, Trendflex, Correlation Trend Indicator, Adaptive RSI, Universal Oscillator, Adaptive CCI, Bandpass Filter, Even Better Sinewave, Autocorrelation Periodogram |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag, Central Pivot Range, Murrey Math Lines, Andrews Pitchfork, Volume-Weighted Support/Resistance, Pivot Reversal |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level, TD Camouflage, TD Clop, TD Clopwin, TD Propulsion, TD Trap, TD D-Wave, TD Moving Averages |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi, Heikin-Ashi Oscillator, Three Line Break, Smoothed Heikin-Ashi, Equivolume, CandleVolume |
|
||||
| Alt-Chart Bars | Renko (box-size bricks), Kagi (reversal-amount lines), Point & Figure (X/O columns), Range, Tick, Volume, Dollar, Imbalance, Run, Three-Line Break |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down, Two Crows, Upside Gap Two Crows, Identical Three Crows, Three Line Strike, Three Stars in the South, Abandoned Baby, Advance Block, Belt-hold, Breakaway, Counterattack, Doji Star, Dragonfly Doji, Gravestone Doji, Long-Legged Doji, Rickshaw Man, Evening Doji Star, Morning Doji Star, Gap Side-by-Side White, High-Wave, Hikkake, Modified Hikkake, Homing Pigeon, On-Neck, In-Neck, Thrusting, Separating Lines, Kicking, Kicking by Length, Ladder Bottom, Mat Hold, Matching Low, Long Line, Short Line, Rising Three Methods, Falling Three Methods, Upside Gap Three Methods, Downside Gap Three Methods, Stalled Pattern, Stick Sandwich, Takuri, Closing Marubozu, Opening Marubozu, Tasuki Gap, Unique Three River, Concealing Baby Swallow, Tristar, Harami Cross, Tower Top/Bottom, Dumpling Top, New Price Lines, Frying Pan Bottom |
|
||||
| Chart Patterns | Double Top / Bottom, Triple Top / Bottom, Head and Shoulders, Triangle (asc/desc/sym), Wedge (rising/falling), Flag / Pennant, Rectangle / Range, Cup and Handle |
|
||||
| Harmonic Patterns | AB=CD, Gartley, Butterfly, Bat, Crab, Shark, Cypher, Three Drives |
|
||||
| Fibonacci | Fibonacci Retracement, Fibonacci Extension, Fibonacci Projection, Auto-Fibonacci, Golden Pocket, Fibonacci Confluence, Fibonacci Fan, Fibonacci Arcs, Fibonacci Channel, Fibonacci Time Zones |
|
||||
| Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, Depth Slope, Signed Volume, Cumulative Volume Delta, Trade Imbalance, Effective Spread, Realized Spread, Kyle's Lambda, Footprint, Order Flow Imbalance, VPIN, Amihud Illiquidity, Roll Measure, Trade-Sign Autocorrelation, Hasbrouck Information Share |
|
||||
| Derivatives | Funding Rate, Funding Rate Mean, Funding Rate Z-Score, Funding Basis, Open-Interest Delta, OI / Price Divergence, OI-Weighted Price, Long/Short Ratio, Taker Buy/Sell Ratio, Liquidation Features, Term-Structure Basis, Calendar Spread, Estimated Leverage Ratio, OI-to-Volume Ratio, Perpetual Premium Index, Funding-Implied APR, Open-Interest Momentum |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Volume Profile (histogram), TPO Profile, Initial Balance, Opening Range, Naked POC, Single Prints, Profile Shape, High/Low Volume Nodes, Composite Profile |
|
||||
| Market Breadth | Advance/Decline Line, Advance/Decline Ratio, Advance/Decline Volume Line, McClellan Oscillator, McClellan Summation Index, TRIN / Arms Index, Breadth Thrust, New Highs - New Lows, High-Low Index, Percent Above Moving Average, Up/Down Volume Ratio, Bullish Percent Index, Cumulative Volume Index, Absolute Breadth Index, TICK Index |
|
||||
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
|
||||
| Seasonality & Session | Session VWAP, Session High/Low, Session Range, Average Daily Range, Overnight Gap, Overnight/Intraday Return, Turn-of-Month, Seasonal Z-Score, Time-of-Day Return Profile, Day-of-Week Profile, Intraday Volatility Profile, Volume-by-Time Profile |
|
||||
|
||||
Every candlestick pattern emits a signed per-bar value — `+1.0` bullish,
|
||||
`−1.0` bearish, `0.0` none — so the family drops straight into a feature matrix
|
||||
@@ -169,8 +173,9 @@ as one column each. `Doji` is direction-less by default (`+1.0` / `0.0`);
|
||||
construct it in signed mode (`Doji::new().signed()`, `Doji(signed=True)`,
|
||||
`new Doji(true)`) for a dragonfly / gravestone `±1` reading.
|
||||
|
||||
Adding a new indicator means implementing one trait in Rust; all four bindings
|
||||
inherit it automatically.
|
||||
Adding a new indicator means implementing one trait in Rust; every binding
|
||||
inherits it automatically (the C ABI — and the C# and Go bindings generated from
|
||||
it — regenerate from the core).
|
||||
|
||||
## Languages
|
||||
|
||||
@@ -180,12 +185,16 @@ inherit it automatically.
|
||||
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
|
||||
| Browser / WASM | `npm install wickra-wasm` | `examples/wasm/index.html` |
|
||||
| Rust | `cargo add wickra` | `examples/rust/src/bin/backtest.rs` |
|
||||
| C / C++ (C ABI) | header + library, see [`bindings/c`](bindings/c) | `examples/c/streaming.c` |
|
||||
| C# / .NET (C ABI) | `dotnet add package Wickra`, see [`bindings/csharp`](bindings/csharp) | `examples/csharp/streaming` |
|
||||
| Go (cgo, C ABI) | `go get github.com/wickra-lib/wickra/bindings/go`, see [`bindings/go`](bindings/go) | `examples/go/streaming` |
|
||||
|
||||
Each binding ships several runnable examples (streaming, backtest, live feed);
|
||||
[`examples/README.md`](examples/README.md) is the full cross-language index.
|
||||
|
||||
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
|
||||
memory-safe implementation.
|
||||
The wickra-core crate is `unsafe`-forbidden, so the native bindings are
|
||||
memory-safe end to end. The C ABI runs the same safe core; only its thin FFI
|
||||
boundary uses `unsafe`, and the caller owns handle lifetimes (`_new` / `_free`).
|
||||
|
||||
## Rust API
|
||||
|
||||
@@ -240,25 +249,33 @@ A Python live-trading example using the public `websockets` package lives at
|
||||
```
|
||||
wickra/
|
||||
├── crates/
|
||||
│ ├── wickra-core/ core engine + all 339 indicators
|
||||
│ ├── wickra-core/ core engine + all 514 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)
|
||||
│ └── wasm/ wasm-bindgen (browsers, bundlers, Node)
|
||||
│ ├── wasm/ wasm-bindgen (browsers, bundlers, Node)
|
||||
│ ├── c/ C ABI (cdylib + staticlib) + generated include/wickra.h
|
||||
│ ├── csharp/ .NET binding over the C ABI (publishes on NuGet)
|
||||
│ └── go/ Go binding over the C ABI via cgo (module tag)
|
||||
├── examples/ examples/README.md indexes every language
|
||||
│ ├── data/ real BTCUSDT OHLCV datasets, one per timeframe
|
||||
│ ├── rust/ Rust workspace member (`wickra-examples`)
|
||||
│ ├── python/ backtest, live trading, parallel assets, multi-tf
|
||||
│ ├── node/ streaming, backtest, live trading (load `wickra`)
|
||||
│ └── wasm/ browser demo for `wickra-wasm`
|
||||
│ ├── wasm/ browser demo for `wickra-wasm`
|
||||
│ ├── c/ C smoke + streaming, C++ RAII wrapper
|
||||
│ ├── csharp/ streaming, backtest, strategies (load `Wickra`)
|
||||
│ └── go/ streaming, backtest, strategies (cgo binding)
|
||||
└── .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
|
||||
|
||||
@@ -266,7 +283,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
|
||||
@@ -278,6 +296,18 @@ wasm-pack build bindings/wasm --target web --release --features panic-hook
|
||||
|
||||
# Node binding (requires @napi-rs/cli)
|
||||
cd bindings/node && npm install && npm run build && npm test
|
||||
|
||||
# C ABI (cdylib + staticlib + generated header)
|
||||
cargo build -p wickra-c --release
|
||||
cmake -S examples/c -B examples/c/build -DWICKRA_LIB_DIR="$PWD/target/release"
|
||||
cmake --build examples/c/build && ctest --test-dir examples/c/build --output-on-failure
|
||||
|
||||
# C# / .NET binding (requires the .NET 8 SDK; links the C ABI above)
|
||||
dotnet test bindings/csharp/Wickra.Tests/Wickra.Tests.csproj
|
||||
|
||||
# Go binding (requires a C compiler for cgo; links the C ABI above)
|
||||
cp target/release/libwickra.so bindings/go/lib/ # .dylib on macOS, wickra.dll on Windows
|
||||
cd bindings/go && go test ./...
|
||||
```
|
||||
|
||||
## Testing
|
||||
@@ -297,6 +327,8 @@ Every layer is covered; run the suites with the commands in
|
||||
values across all indicators.
|
||||
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
|
||||
and reference values.
|
||||
- `bindings/go`: `go test` cases covering one indicator per FFI archetype
|
||||
(scalar/batch, multi-output, bars, profile, array input), reset, and lifecycle.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -366,3 +398,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>
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Roadmap
|
||||
|
||||
This roadmap describes the project's direction at a high level. It is
|
||||
intentionally non-binding: priorities shift with feedback and available time,
|
||||
and the authoritative, up-to-date view of planned work is the
|
||||
[issue tracker](https://github.com/wickra-lib/wickra/issues). Shipped changes
|
||||
are recorded in [`CHANGELOG.md`](CHANGELOG.md).
|
||||
|
||||
## Status
|
||||
|
||||
Wickra is **pre-1.0**. The public API is largely stable but may still change in
|
||||
minor releases; breaking changes are called out in the changelog.
|
||||
|
||||
## Themes
|
||||
|
||||
- **Indicator coverage.** Continue broadening the indicator catalogue across
|
||||
families (trend, momentum, volatility, volume, statistics, market profile,
|
||||
and more), each with the same streaming/batch parity and test guarantees.
|
||||
- **API stabilization toward 1.0.** Settle the public `Indicator` and
|
||||
`BarBuilder` traits and the binding surfaces, then commit to semantic
|
||||
versioning stability for a 1.0 release.
|
||||
- **Performance.** Keep per-tick updates O(1) and maintain the benchmark suite;
|
||||
investigate further allocation and cache improvements.
|
||||
- **Bindings parity.** Keep the Python, Node.js and WebAssembly bindings — plus
|
||||
the C ABI and the C# / .NET and Go bindings generated from it — in lockstep with the
|
||||
Rust core, including type stubs and platform coverage.
|
||||
- **Documentation.** Maintain a deep-dive page per indicator on
|
||||
<https://docs.wickra.org>, plus quickstarts and cookbook material.
|
||||
- **Project health.** Maintain test coverage, static and dynamic analysis,
|
||||
signed releases, and supply-chain monitoring.
|
||||
|
||||
## How to influence the roadmap
|
||||
|
||||
Open or comment on an issue, or start with the
|
||||
[feature-request template](.github/ISSUE_TEMPLATE/feature_request.md).
|
||||
Well-scoped proposals and pull requests are the most effective way to move an
|
||||
item forward.
|
||||
+100
@@ -41,3 +41,103 @@ PyPI/npm packages, and the build/release workflows in `.github/workflows/`.
|
||||
|
||||
Out of scope: vulnerabilities in third-party dependencies (report those
|
||||
upstream; we track them via Dependabot and `cargo-deny`).
|
||||
|
||||
## Security assurance case
|
||||
|
||||
This is a short, evidence-backed argument for why Wickra can be used safely.
|
||||
|
||||
**Security requirements.** Wickra is a computational library: it ingests
|
||||
numeric market data and produces indicator values. It stores no user
|
||||
credentials, authenticates no external users, and implements no cryptography of
|
||||
its own. The requirements are therefore: (1) memory safety and freedom from
|
||||
undefined behaviour, (2) robust handling of untrusted/degenerate numeric input
|
||||
without panics or unbounded resource use, (3) integrity of the published
|
||||
artifacts, and (4) a healthy dependency supply chain.
|
||||
|
||||
**How the requirements are met.**
|
||||
|
||||
- *Memory safety* — the core and all bindings are written in Rust. The crates
|
||||
forbid or minimise `unsafe`, so the compiler guarantees memory and thread
|
||||
safety for the indicator logic. The one exception is the C ABI
|
||||
([`bindings/c`](bindings/c)), whose thin FFI shim is necessarily `unsafe`
|
||||
because it dereferences caller-supplied pointers; it adds no indicator logic,
|
||||
validates every handle for NULL, and never lets a panic cross the boundary, so
|
||||
the safe core's guarantees still cover all computation.
|
||||
- *Input robustness* — every indicator validates its parameters and rejects
|
||||
non-finite inputs at construction; behaviour on edge cases (flat markets,
|
||||
warmup, reset) is pinned by unit tests, and the public update paths are
|
||||
exercised by coverage-guided fuzzing (`cargo-fuzz` / libFuzzer) in CI.
|
||||
- *Static and dynamic analysis* — every push and pull request runs Clippy
|
||||
(`clippy::pedantic`, warnings-as-errors), CodeQL, fuzzing, and the full test
|
||||
suite, with 100% line coverage on the core crate tracked by Codecov.
|
||||
- *Artifact integrity* — releases are built in CI, commits and tags are signed,
|
||||
the `main` branch requires signed commits, and release artifacts carry build
|
||||
provenance attestations.
|
||||
- *Supply chain* — dependencies are pinned and monitored with Dependabot and
|
||||
audited with `cargo-deny` (license + advisory checks) on every change.
|
||||
|
||||
**Residual risk.** The optional `live-binance` feature opens a TLS WebSocket to
|
||||
an exchange using the platform TLS library; transport security therefore
|
||||
depends on that library, not on Wickra. Wickra is not a trading system and is
|
||||
provided "as is" — see the disclaimers in `README.md` and the licenses.
|
||||
|
||||
## Secrets management
|
||||
|
||||
The project stores **no** secrets or credentials in the version control system.
|
||||
Secrets required by automation (publishing tokens, the about-sync PAT) are kept
|
||||
exclusively as **GitHub Actions encrypted secrets** and referenced via the
|
||||
`secrets.*` context; they are never written to the repository, logs, or build
|
||||
artifacts. GitHub **secret scanning with push protection** is enabled to block
|
||||
accidental commits of credentials. Secrets follow least privilege (the narrowest
|
||||
scope that works) and are rotated when a holder changes or on suspected
|
||||
exposure.
|
||||
|
||||
## Verifying releases
|
||||
|
||||
Released artifacts can be verified for integrity and authenticity:
|
||||
|
||||
- **Build provenance.** Release assets carry GitHub build provenance
|
||||
attestations. Verify a downloaded asset with the GitHub CLI:
|
||||
`gh attestation verify <file> --repo wickra-lib/wickra`.
|
||||
- **Signed tags.** Each release corresponds to a signed git tag (`vX.Y.Z`);
|
||||
the tag signature identifies the maintainer who authorised the release.
|
||||
- **Registry integrity.** Packages are distributed over HTTPS from crates.io,
|
||||
PyPI and npm, which serve package checksums that package managers verify on
|
||||
install.
|
||||
|
||||
The release is published only by the maintainer through the tag-triggered
|
||||
release workflow, so a verified tag signature establishes the expected
|
||||
publisher identity.
|
||||
|
||||
## Support timeline and end of support
|
||||
|
||||
Wickra is **pre-1.0**: only the **latest released `0.y.z`** version receives
|
||||
security fixes. When a newer release is published, the previous version
|
||||
**immediately reaches end of support** and will not receive further fixes;
|
||||
users should upgrade to the latest release. The supported-versions table above
|
||||
is authoritative. After the `1.0.0` release this policy will be revised to
|
||||
support a defined window of releases.
|
||||
|
||||
## Remediation policy (dependencies and code scanning)
|
||||
|
||||
- **Severity threshold.** Vulnerabilities of **medium severity or higher** in
|
||||
the project's own code or its dependencies are remediated promptly and before
|
||||
the next release; lower-severity findings are addressed on a best-effort
|
||||
basis.
|
||||
- **Automated enforcement (SCA).** Every change is evaluated by `cargo-deny`
|
||||
(RUSTSEC advisories + license policy) and Dependabot; a known-vulnerable
|
||||
dependency fails CI and **blocks the change** until resolved or explicitly
|
||||
waived with justification.
|
||||
- **Automated enforcement (SAST).** Every change is evaluated by CodeQL and
|
||||
Clippy (`-D warnings`); findings **block the change** in CI until fixed.
|
||||
- **Pre-release gate.** A release is not cut while an unresolved medium-or-higher
|
||||
SCA/SAST finding is outstanding.
|
||||
|
||||
## Vulnerability exploitability (VEX)
|
||||
|
||||
Advisories reported by `cargo-deny`/Dependabot for third-party dependencies that
|
||||
do **not** affect Wickra (e.g. the vulnerable code path is not reachable, or the
|
||||
affected feature is not enabled) are triaged and recorded — with the
|
||||
not-affected justification — in the `cargo-deny` configuration (`deny.toml`) and
|
||||
the relevant pull request, rather than forcing an unnecessary dependency bump.
|
||||
This serves as the project's exploitability (VEX) record.
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Support
|
||||
|
||||
Thanks for using Wickra! Here is where to get help, depending on what you need.
|
||||
|
||||
## Documentation first
|
||||
|
||||
Most questions are answered in the documentation:
|
||||
|
||||
- **Docs site:** <https://docs.wickra.org> — quickstarts for Rust, Python,
|
||||
Node.js, WebAssembly, C, C# and Go, a per-indicator reference, warmup periods, the
|
||||
data layer, and an FAQ.
|
||||
- **README:** <https://github.com/wickra-lib/wickra#readme> — installation and a
|
||||
quick overview.
|
||||
- **API docs (Rust):** <https://docs.rs/wickra>.
|
||||
|
||||
## Questions and help
|
||||
|
||||
- Ask a question with the
|
||||
[question issue template](.github/ISSUE_TEMPLATE/question.md).
|
||||
- Browse [existing issues](https://github.com/wickra-lib/wickra/issues) — your
|
||||
question may already be answered.
|
||||
|
||||
## Bugs and feature requests
|
||||
|
||||
- **Bugs:** use the bug-report issue template.
|
||||
- **Feature requests / new indicators:** use the feature-request template.
|
||||
|
||||
## Security issues
|
||||
|
||||
Please do **not** report security vulnerabilities through public issues. Follow
|
||||
the process in [`SECURITY.md`](SECURITY.md) (private GitHub advisory or email).
|
||||
|
||||
## Support expectations
|
||||
|
||||
Wickra is maintained by a single maintainer on a best-effort basis. Issues are
|
||||
triaged and acknowledged as time allows; there is no commercial support or SLA.
|
||||
Clear, reproducible reports get help fastest.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Threat model
|
||||
|
||||
This document describes Wickra's attack surface and the threats considered,
|
||||
together with their mitigations. It complements the security assurance case in
|
||||
[`SECURITY.md`](SECURITY.md). Wickra is a computational technical-analysis
|
||||
library (a Rust core with Python, Node.js and WebAssembly bindings plus a C ABI
|
||||
and the .NET and Go bindings built on it),
|
||||
not a network service or trading system; the attack surface is correspondingly
|
||||
small.
|
||||
|
||||
## Assets
|
||||
|
||||
- **Integrity of computed indicator values** — consumers may use them in
|
||||
automated decisions, so silently wrong output is the primary concern.
|
||||
- **Availability of the calling process** — a library must not crash or hang
|
||||
its host on malformed input.
|
||||
- **Integrity of published artifacts** — the crates, wheels and npm packages
|
||||
users install.
|
||||
- **The build and release pipeline** and its secrets (publishing tokens).
|
||||
|
||||
## Actors / trust boundaries
|
||||
|
||||
- **Library consumer** (trusted) — calls the API with numeric data. Data may
|
||||
originate from untrusted sources (e.g. a market feed), so *input values* are
|
||||
treated as untrusted even though the caller is trusted.
|
||||
- **Optional live feed** — with the `live-binance` feature, data crosses a
|
||||
network boundary from an exchange over TLS.
|
||||
- **Contributors** (semi-trusted) — propose changes via pull requests.
|
||||
- **Supply chain** — upstream dependencies and the CI/CD platform.
|
||||
|
||||
## Threats and mitigations
|
||||
|
||||
| Threat | Mitigation |
|
||||
| --- | --- |
|
||||
| Memory-safety exploit (buffer overflow, UAF) via crafted input | Pure safe Rust; `unsafe` is forbidden/minimised, so the compiler precludes these classes. |
|
||||
| Misuse of the C ABI FFI boundary (invalid/dangling handle, undersized batch buffer) | The C ABI (`bindings/c`) is the sole `unsafe` surface. Its shim adds no logic, NULL-checks every handle (returning `NaN`/no-op), writes only into caller-sized buffers, and catches panics so none cross the boundary. A caller passing a non-NULL but dangling pointer is undefined behaviour by C's own contract — out of scope, the same as any C library. |
|
||||
| Denial of service via malformed/degenerate input (NaN, infinities, extreme magnitudes) | Indicators reject non-finite inputs and validate parameters at construction; update paths are exercised by coverage-guided fuzzing and unit tests for edge cases. |
|
||||
| Silently incorrect results | 100% line coverage on the core crate; reference-value tests against known-good sources; streaming/batch parity tests. |
|
||||
| Integer overflow / panics | `clippy::pedantic` with `-D warnings`; debug assertions and overflow checks enabled in test/fuzz builds. |
|
||||
| Adversary-in-the-middle on the optional live feed | Connection uses TLS via the platform library; transport security is delegated to that reviewed implementation. |
|
||||
| Compromised dependency (supply chain) | Dependencies pinned (`Cargo.lock`, hash-locked CI requirements), monitored by Dependabot, audited by `cargo-deny` (advisories + licenses) on every change. |
|
||||
| Malicious or accidental change to `main` | Branch protection requires signed commits and blocks force-push and deletion; all changes flow through pull requests with required CI; static analysis (CodeQL, Clippy) and fuzzing run on every change. |
|
||||
| Compromised CI / leaked secrets | Workflows use least-privilege `permissions:`; secrets live only as encrypted GitHub Actions secrets; secret scanning with push protection is enabled; workflows are linted by `zizmor`. |
|
||||
| Tampered release artifact | Releases are built in CI, tags are signed, and assets carry build provenance attestations (verifiable with `gh attestation verify`). |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Wickra implements no authentication, authorization or cryptography of its own,
|
||||
stores no user data, and exposes no network listener; those threat classes do
|
||||
not apply.
|
||||
- Vulnerabilities in third-party dependencies that do not affect Wickra are
|
||||
tracked as exploitability (VEX) records (see [`SECURITY.md`](SECURITY.md)).
|
||||
|
||||
## Maintenance
|
||||
|
||||
This threat model is reviewed when the architecture changes materially (for
|
||||
example, a new input family, a new network feature, or a new release channel).
|
||||
@@ -0,0 +1,46 @@
|
||||
[package]
|
||||
name = "wickra-c"
|
||||
description = "C ABI (cdylib + staticlib) for the Wickra streaming-first technical indicators library — the hub every C-capable language (C, C++, Go, C#, Java, R) links against."
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme = "README.md"
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "wickra"
|
||||
crate-type = ["cdylib", "staticlib"]
|
||||
|
||||
# The C ABI inherently needs `unsafe` (raw pointers across the FFI boundary,
|
||||
# `#[export_name]` symbol control). The workspace forbids `unsafe_code`, so this
|
||||
# crate cannot inherit `workspace = true`; it mirrors every workspace lint and
|
||||
# only relaxes `unsafe_code` to `allow` (parity with how the proc-macro bindings
|
||||
# emit their unsafe). The Rust core stays `unsafe`-forbidden — this is the one
|
||||
# crate where the boundary lives.
|
||||
[lints.rust]
|
||||
unsafe_code = "allow"
|
||||
missing_debug_implementations = "warn"
|
||||
unreachable_pub = "warn"
|
||||
unused_must_use = "deny"
|
||||
|
||||
[lints.clippy]
|
||||
all = { level = "warn", priority = -1 }
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
module_name_repetitions = "allow"
|
||||
must_use_candidate = "allow"
|
||||
missing_errors_doc = "allow"
|
||||
missing_panics_doc = "allow"
|
||||
cast_precision_loss = "allow"
|
||||
cast_possible_truncation = "allow"
|
||||
cast_sign_loss = "allow"
|
||||
similar_names = "allow"
|
||||
float_cmp = "allow"
|
||||
|
||||
[dependencies]
|
||||
wickra-core = { workspace = true }
|
||||
@@ -0,0 +1,80 @@
|
||||
# Wickra — C / C++
|
||||
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://github.com/wickra-lib/wickra/releases/latest)
|
||||
[](https://github.com/wickra-lib/wickra#license)
|
||||
|
||||
**Streaming-first technical indicators for C and C++. A prebuilt shared/static
|
||||
library plus a generated `wickra.h` — no system dependencies.**
|
||||
|
||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||
bindings for Python, Node.js and WebAssembly, plus a C ABI for C/C++, C#, Go and any
|
||||
other C-capable language. Every indicator is an O(1)
|
||||
streaming state machine, so live trading bots and historical backtests share
|
||||
the exact same implementation. This package is the **C ABI hub**: it compiles the
|
||||
core to a C-compatible shared/static library plus a generated header, so any
|
||||
C-capable language (C, C++, Go, C#, Java, R) links against one artifact instead
|
||||
of re-wrapping every indicator natively.
|
||||
|
||||
## Install
|
||||
|
||||
Grab the prebuilt header + library for your platform from the
|
||||
[GitHub releases](https://github.com/wickra-lib/wickra/releases) — each archive
|
||||
has `wickra.h`, the optional `wickra.hpp` C++ wrapper, and the shared/static
|
||||
library — or build from source:
|
||||
|
||||
```bash
|
||||
cargo build -p wickra-c --release
|
||||
# -> target/release/libwickra.{so,dylib} or wickra.dll (+ import lib) + a staticlib
|
||||
```
|
||||
|
||||
Then compile against the header and link the library
|
||||
(`cc app.c -I include -L lib -lwickra -lm -o app`).
|
||||
|
||||
## Quick start
|
||||
|
||||
```c
|
||||
#include "wickra.h"
|
||||
|
||||
struct Rsi *rsi = wickra_rsi_new(14); /* NULL on invalid params */
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
double v = wickra_rsi_update(rsi, prices[i]); /* NaN during warmup */
|
||||
if (v == v && v > 70.0) /* v == v is the NaN check */
|
||||
printf("overbought\n");
|
||||
}
|
||||
wickra_rsi_free(rsi); /* exactly once per _new */
|
||||
```
|
||||
|
||||
Every indicator is an opaque handle with the same five functions —
|
||||
`_new` / `_update` / `_batch` / `_reset` / `_free`. `update` is O(1); there is no
|
||||
RAII across the C boundary, so each `_new` needs exactly one `_free`, and every
|
||||
function is NULL-safe (a NULL handle yields `NaN` or a no-op, never a crash).
|
||||
Multi-output indicators (MACD, Bollinger, ADX, …) take a pointer to a `#[repr(C)]`
|
||||
struct and return a `bool`. The optional `wickra.hpp` wraps any handle in a
|
||||
move-only `wickra::Handle` for exception-safe C++ lifetimes.
|
||||
|
||||
## Documentation
|
||||
|
||||
The full indicator catalogue, guides, quickstarts, and API reference live in
|
||||
the main repository and documentation site:
|
||||
|
||||
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
|
||||
- **Docs** (C quickstart, cookbook, TA-Lib migration): <https://docs.wickra.org/Quickstart-C>
|
||||
- **Runnable examples:** [`examples/c/`](https://github.com/wickra-lib/wickra/tree/main/examples/c)
|
||||
|
||||
Wickra ships native bindings for Python, Node.js, WebAssembly and Rust, plus this
|
||||
C ABI hub that any C-capable language (C, C++, Go, C#, Java, R) links against —
|
||||
all exposing the same indicators from the shared Rust core.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Wickra is an indicator toolkit, not a trading system. The values it computes
|
||||
are deterministic transforms of the input data — they are not financial advice
|
||||
and do not predict the market. Any use in a live trading context is at your own
|
||||
risk. The library is provided **as is**, without warranty of any kind.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under either of [Apache-2.0](https://github.com/wickra-lib/wickra/blob/main/LICENSE-APACHE)
|
||||
or [MIT](https://github.com/wickra-lib/wickra/blob/main/LICENSE-MIT) at your option.
|
||||
@@ -0,0 +1,20 @@
|
||||
language = "C"
|
||||
header = "/* Wickra C ABI — generated by cbindgen. Do not edit by hand. */"
|
||||
include_guard = "WICKRA_H"
|
||||
pragma_once = true
|
||||
# Wrap the declarations in `extern "C"` under __cplusplus so the header is usable
|
||||
# from C++ (the optional wickra.hpp RAII layer and any C++ consumer).
|
||||
cpp_compat = true
|
||||
tab_width = 4
|
||||
# Off: cbindgen copies the Rust struct/fn doc comments verbatim, and some core
|
||||
# indicator docs contain markdown (e.g. `**1/8**/**7/8**`) whose `*/` would close
|
||||
# the C block comment early and break the header. Usage docs live in the crate
|
||||
# README and examples; the header is a pure declaration contract.
|
||||
documentation = false
|
||||
|
||||
[parse]
|
||||
# Parse wickra-core too so the opaque indicator handle types (Sma, Ema, …) are
|
||||
# discovered and emitted as forward-declared opaque structs. Their fields are
|
||||
# never exposed — only `T *` handles cross the boundary.
|
||||
parse_deps = true
|
||||
include = ["wickra-core"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
||||
// Optional C++ convenience layer over the Wickra C ABI (`wickra.h`).
|
||||
//
|
||||
// The C ABI hands out raw handles that must be released exactly once with the
|
||||
// matching `wickra_<ind>_free`. `wickra::Handle` wraps that in a move-only RAII
|
||||
// owner so the free happens automatically at scope exit:
|
||||
//
|
||||
// #include "wickra.hpp"
|
||||
//
|
||||
// wickra::Handle<Sma, wickra_sma_free> sma(wickra_sma_new(14));
|
||||
// if (sma) {
|
||||
// double v = wickra_sma_update(sma.get(), 42.0); // NaN during warmup
|
||||
// }
|
||||
// // sma is freed here
|
||||
//
|
||||
// This is header-only and adds no runtime cost beyond the C calls themselves.
|
||||
|
||||
#ifndef WICKRA_HPP
|
||||
#define WICKRA_HPP
|
||||
|
||||
#include "wickra.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace wickra {
|
||||
|
||||
/// Move-only RAII owner of a Wickra handle. `T` is the opaque indicator type and
|
||||
/// `Free` its `wickra_<ind>_free` function.
|
||||
template <typename T, void (*Free)(T *)>
|
||||
class Handle {
|
||||
public:
|
||||
explicit Handle(T *ptr) noexcept : ptr_(ptr) {}
|
||||
|
||||
~Handle() {
|
||||
if (ptr_ != nullptr) {
|
||||
Free(ptr_);
|
||||
}
|
||||
}
|
||||
|
||||
Handle(const Handle &) = delete;
|
||||
Handle &operator=(const Handle &) = delete;
|
||||
|
||||
Handle(Handle &&other) noexcept : ptr_(std::exchange(other.ptr_, nullptr)) {}
|
||||
|
||||
Handle &operator=(Handle &&other) noexcept {
|
||||
if (this != &other) {
|
||||
if (ptr_ != nullptr) {
|
||||
Free(ptr_);
|
||||
}
|
||||
ptr_ = std::exchange(other.ptr_, nullptr);
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
/// The raw handle, for passing to the `wickra_<ind>_*` functions.
|
||||
T *get() const noexcept { return ptr_; }
|
||||
|
||||
/// True if the handle is non-null (construction succeeded).
|
||||
explicit operator bool() const noexcept { return ptr_ != nullptr; }
|
||||
|
||||
private:
|
||||
T *ptr_;
|
||||
};
|
||||
|
||||
} // namespace wickra
|
||||
|
||||
#endif // WICKRA_HPP
|
||||
+44290
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
# .NET build output
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
|
||||
# NuGet packaging output
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
|
||||
# Native libraries staged for packaging (produced by the release pipeline from
|
||||
# the wickra-c-<triple>.tar.gz assets; never committed to source).
|
||||
Wickra/runtimes/
|
||||
@@ -0,0 +1,78 @@
|
||||
# Wickra — .NET
|
||||
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://www.nuget.org/packages/Wickra)
|
||||
[](https://github.com/wickra-lib/wickra#license)
|
||||
|
||||
**Streaming-first technical indicators for .NET. `dotnet add package Wickra` —
|
||||
prebuilt native library, no system dependencies.**
|
||||
|
||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||
bindings for Python, Node.js and WebAssembly, plus a C ABI for C/C++, C#, Go and any
|
||||
other C-capable language. Every indicator is an O(1)
|
||||
streaming state machine, so live trading bots and historical backtests share
|
||||
the exact same implementation. This package is the .NET binding; it consumes the
|
||||
C ABI hub through `[LibraryImport]` P/Invoke and exposes all 514 streaming-first
|
||||
indicators as idiomatic `IDisposable` classes.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
dotnet add package Wickra
|
||||
```
|
||||
|
||||
The native library ships prebuilt per platform (Linux, macOS, Windows — x64 and
|
||||
arm64) under `runtimes/<rid>/native/`, selected automatically. There is nothing
|
||||
to compile. Targets .NET 8 and later.
|
||||
|
||||
## Quick start
|
||||
|
||||
```csharp
|
||||
using Wickra;
|
||||
|
||||
// Batch: run an indicator over a whole series (NaN at warmup positions).
|
||||
var prices = Enumerable.Range(0, 1000).Select(i => 100.0 + i * 0.1).ToArray();
|
||||
using var sma = new Sma(20);
|
||||
double[] values = sma.Batch(prices);
|
||||
|
||||
// Streaming: the same indicator, fed tick by tick in O(1).
|
||||
using var rsi = new Rsi(14);
|
||||
foreach (var price in liveFeed)
|
||||
{
|
||||
var value = rsi.Update(price); // NaN during warmup, no recomputation
|
||||
if (double.IsFinite(value) && value > 70)
|
||||
{
|
||||
Console.WriteLine("overbought");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Batch(prices)` and feeding the same prices through `Update()` produce identical
|
||||
values — the equivalence is enforced by the test suite. Multi-output indicators
|
||||
(MACD, Bollinger, ADX, …) return a nullable `record struct`, `null` while warming up.
|
||||
|
||||
## Documentation
|
||||
|
||||
The full indicator catalogue, guides, quickstarts, and API reference live in
|
||||
the main repository and documentation site:
|
||||
|
||||
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
|
||||
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||
- **Runnable examples:** [`examples/csharp/`](https://github.com/wickra-lib/wickra/tree/main/examples/csharp)
|
||||
|
||||
Wickra ships native bindings for Python, Node.js, WebAssembly and Rust, plus a
|
||||
C ABI hub that any C-capable language (C, C++, Go, C#, Java, R) links against —
|
||||
all exposing the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Wickra is an indicator toolkit, not a trading system. The values it computes
|
||||
are deterministic transforms of the input data — they are not financial advice
|
||||
and do not predict the market. Any use in a live trading context is at your own
|
||||
risk. The library is provided **as is**, without warranty of any kind.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under either of [Apache-2.0](https://github.com/wickra-lib/wickra/blob/main/LICENSE-APACHE)
|
||||
or [MIT](https://github.com/wickra-lib/wickra/blob/main/LICENSE-MIT) at your option.
|
||||
@@ -0,0 +1,144 @@
|
||||
using Wickra;
|
||||
using Xunit;
|
||||
|
||||
namespace Wickra.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// One representative per FFI archetype, exercising every marshalling path the
|
||||
/// generator produces (scalar, candle, pairwise, multi-output, bars, profile,
|
||||
/// values-profile, array-input). Garbage marshalling surfaces as NaN, wild
|
||||
/// values, or crashes — so finite/sane assertions are the real check.
|
||||
/// </summary>
|
||||
public class ArchetypeTests
|
||||
{
|
||||
private static (double open, double high, double low, double close, double volume, long ts) Candle(int i)
|
||||
{
|
||||
var close = 100.0 + 10.0 * Math.Sin(i * 0.3);
|
||||
var open = 100.0 + 10.0 * Math.Sin((i - 1) * 0.3);
|
||||
var high = Math.Max(open, close) + 1.0;
|
||||
var low = Math.Min(open, close) - 1.0;
|
||||
return (open, high, low, close, 1_000.0, i * 60_000L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scalar_Ema_IsFiniteAfterWarmup()
|
||||
{
|
||||
using var ema = new Ema(3);
|
||||
double last = double.NaN;
|
||||
for (var i = 1; i <= 10; i++)
|
||||
{
|
||||
last = ema.Update(i);
|
||||
}
|
||||
|
||||
Assert.True(double.IsFinite(last));
|
||||
Assert.InRange(last, 1.0, 10.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Candle_Atr_IsFinitePositive()
|
||||
{
|
||||
using var atr = new Atr(3);
|
||||
double last = double.NaN;
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
var (o, h, l, c, v, ts) = Candle(i);
|
||||
last = atr.Update(o, h, l, c, v, ts);
|
||||
}
|
||||
|
||||
Assert.True(double.IsFinite(last));
|
||||
Assert.True(last > 0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pairwise_Beta_IsFinite()
|
||||
{
|
||||
using var beta = new Beta(5);
|
||||
double last = double.NaN;
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
var market = 100.0 + 10.0 * Math.Sin(i * 0.5);
|
||||
var asset = 50.0 + 6.0 * Math.Sin(i * 0.5 + 0.2);
|
||||
last = beta.Update(market, asset);
|
||||
}
|
||||
|
||||
Assert.True(double.IsFinite(last));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiOutput_Adx_ReturnsFiniteStruct()
|
||||
{
|
||||
using var adx = new Adx(5);
|
||||
AdxOutput? result = null;
|
||||
for (var i = 0; i < 60; i++)
|
||||
{
|
||||
var (o, h, l, c, v, ts) = Candle(i);
|
||||
result = adx.Update(o, h, l, c, v, ts);
|
||||
}
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(double.IsFinite(result!.Value.Adx));
|
||||
Assert.True(double.IsFinite(result.Value.PlusDi));
|
||||
Assert.True(double.IsFinite(result.Value.MinusDi));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bars_DollarBars_EmitsBars()
|
||||
{
|
||||
using var bars = new DollarBars(5_000.0);
|
||||
var total = 0;
|
||||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
var (o, h, l, c, v, ts) = Candle(i);
|
||||
total += bars.Update(o, h, l, c, v, ts).Length;
|
||||
}
|
||||
|
||||
Assert.True(total > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Profile_VolumeProfile_ReturnsValues()
|
||||
{
|
||||
using var profile = new VolumeProfile(20, 8);
|
||||
VolumeProfileOutputScalars? result = null;
|
||||
for (var i = 0; i < 60; i++)
|
||||
{
|
||||
var (o, h, l, c, v, ts) = Candle(i);
|
||||
result = profile.Update(o, h, l, c, v, ts);
|
||||
}
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result!.Value.Values);
|
||||
Assert.True(result.Value.PriceLow <= result.Value.PriceHigh);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProfileValues_DayOfWeekProfile_NoCrash()
|
||||
{
|
||||
using var profile = new DayOfWeekProfile(0);
|
||||
double[]? result = null;
|
||||
for (var i = 0; i < 60; i++)
|
||||
{
|
||||
var close = 100.0 + 5.0 * Math.Sin(i * 0.2);
|
||||
// one day apart so the day-of-week buckets fill
|
||||
result = profile.Update(close, close + 1, close - 1, close, 1_000.0, i * 86_400_000L);
|
||||
}
|
||||
|
||||
if (result is not null)
|
||||
{
|
||||
Assert.All(result, v => Assert.True(double.IsFinite(v)));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrayInput_DepthSlope_IsFinite()
|
||||
{
|
||||
using var slope = new DepthSlope();
|
||||
ReadOnlySpan<double> bidPrice = stackalloc double[] { 99.0, 98.0, 97.0 };
|
||||
ReadOnlySpan<double> bidSize = stackalloc double[] { 10.0, 20.0, 30.0 };
|
||||
ReadOnlySpan<double> askPrice = stackalloc double[] { 101.0, 102.0, 103.0 };
|
||||
ReadOnlySpan<double> askSize = stackalloc double[] { 12.0, 22.0, 32.0 };
|
||||
|
||||
var result = slope.Update(bidPrice, bidSize, askPrice, askSize);
|
||||
Assert.True(double.IsFinite(result));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Wickra;
|
||||
using Xunit;
|
||||
|
||||
namespace Wickra.Tests;
|
||||
|
||||
public class SmaTests
|
||||
{
|
||||
[Fact]
|
||||
public void StreamingMatchesReference()
|
||||
{
|
||||
using var sma = new Sma(3);
|
||||
Assert.True(double.IsNaN(sma.Update(1)));
|
||||
Assert.True(double.IsNaN(sma.Update(2)));
|
||||
Assert.Equal(2.0, sma.Update(3), 9);
|
||||
Assert.Equal(3.0, sma.Update(4), 9);
|
||||
Assert.Equal(4.0, sma.Update(5), 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchMatchesStreaming()
|
||||
{
|
||||
using var sma = new Sma(3);
|
||||
var output = sma.Batch(new double[] { 1, 2, 3, 4, 5 });
|
||||
|
||||
Assert.True(double.IsNaN(output[0]));
|
||||
Assert.True(double.IsNaN(output[1]));
|
||||
Assert.Equal(2.0, output[2], 9);
|
||||
Assert.Equal(3.0, output[3], 9);
|
||||
Assert.Equal(4.0, output[4], 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetClearsState()
|
||||
{
|
||||
using var sma = new Sma(3);
|
||||
sma.Update(1);
|
||||
sma.Update(2);
|
||||
sma.Update(3);
|
||||
sma.Reset();
|
||||
Assert.True(double.IsNaN(sma.Update(10)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroPeriodThrows()
|
||||
{
|
||||
// Zero is rejected by the native constructor (returns NULL) -> ArgumentException;
|
||||
// a negative period is caught earlier by the wrapper guard.
|
||||
Assert.Throws<ArgumentException>(() => new Sma(0));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Sma(-1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Wickra\Wickra.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<RootNamespace>Wickra</RootNamespace>
|
||||
<AssemblyName>Wickra</AssemblyName>
|
||||
|
||||
<!-- NuGet package metadata -->
|
||||
<PackageId>Wickra</PackageId>
|
||||
<Version>0.7.7</Version>
|
||||
<Authors>kingchenc</Authors>
|
||||
<Description>High-performance streaming technical-analysis indicators (514 indicators) for .NET, backed by the native Rust core via the Wickra C ABI.</Description>
|
||||
<PackageLicenseExpression>MIT OR Apache-2.0</PackageLicenseExpression>
|
||||
<PackageProjectUrl>https://github.com/wickra-lib/wickra</PackageProjectUrl>
|
||||
<RepositoryUrl>https://github.com/wickra-lib/wickra</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PackageTags>technical-analysis;indicators;trading;finance;streaming;ffi;native</PackageTags>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<IncludeSymbols>true</IncludeSymbols>
|
||||
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<!-- A managed package carrying per-RID native assets; not built per-RID itself. -->
|
||||
<IncludeBuildOutput>true</IncludeBuildOutput>
|
||||
<!-- NU5128: managed package carrying only per-RID native assets.
|
||||
CS1591: generated members are self-descriptive; hand-written API is documented. -->
|
||||
<NoWarn>$(NoWarn);NU5128;CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
Supported native runtime identifiers. The release pipeline builds the C ABI
|
||||
per target triple and stages the libraries under
|
||||
Wickra/runtimes/<rid>/native/ before `dotnet pack`:
|
||||
win-x64 win-arm64 linux-x64 linux-arm64 osx-x64 osx-arm64
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<WickraRuntimeIdentifiers>win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64</WickraRuntimeIdentifiers>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Native libraries are packed under runtimes/<rid>/native/ by the release pipeline,
|
||||
which unpacks the wickra-c-<triple>.tar.gz assets into the matching RID folders.
|
||||
For local development and tests the natives are resolved from the cargo target dir
|
||||
via WickraNative's DllImportResolver.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<None Include="runtimes/**/native/*" Pack="true" PackagePath="runtimes" Condition="Exists('runtimes')" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace Wickra;
|
||||
|
||||
/// <summary>
|
||||
/// Owns an opaque native indicator handle and releases it via the indicator's
|
||||
/// <c>_free</c> function. One generic handle type backs every indicator; the
|
||||
/// correct free routine is captured at construction time.
|
||||
/// </summary>
|
||||
internal sealed class WickraHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||
{
|
||||
private readonly Action<nint> _free;
|
||||
|
||||
internal WickraHandle(nint handle, Action<nint> free)
|
||||
: base(ownsHandle: true)
|
||||
{
|
||||
_free = free;
|
||||
SetHandle(handle);
|
||||
}
|
||||
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
_free(handle);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Wickra;
|
||||
|
||||
/// <summary>
|
||||
/// Native library resolution for the Wickra C ABI.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When consumed as a NuGet package the native library ships under
|
||||
/// <c>runtimes/<rid>/native/</c> and the default runtime resolver finds it
|
||||
/// automatically. For local development (project reference against a cargo build)
|
||||
/// the resolver additionally walks up the directory tree to locate
|
||||
/// <c>target/release</c> or <c>target/debug</c>. Every candidate is validated to
|
||||
/// actually export the Wickra ABI before it is accepted, so an unrelated library
|
||||
/// of the same name cannot shadow the real one.
|
||||
/// </remarks>
|
||||
internal static class WickraNative
|
||||
{
|
||||
/// <summary>The library name passed to <c>[LibraryImport]</c>.</summary>
|
||||
internal const string LibraryName = "wickra";
|
||||
|
||||
// Any exported symbol works as a fingerprint; sma_new exists in every build.
|
||||
private const string SentinelSymbol = "wickra_sma_new";
|
||||
|
||||
[ModuleInitializer]
|
||||
internal static void Register()
|
||||
{
|
||||
NativeLibrary.SetDllImportResolver(typeof(WickraNative).Assembly, Resolve);
|
||||
}
|
||||
|
||||
private static nint Resolve(string libraryName, System.Reflection.Assembly assembly, DllImportSearchPath? searchPath)
|
||||
{
|
||||
if (libraryName != LibraryName)
|
||||
{
|
||||
return nint.Zero;
|
||||
}
|
||||
|
||||
// 1. Default resolution (NuGet runtimes/ layout, app-local copies). Accept
|
||||
// only if it is genuinely the Wickra ABI; otherwise discard and fall through.
|
||||
if (NativeLibrary.TryLoad(libraryName, assembly, searchPath, out var handle))
|
||||
{
|
||||
if (Exports(handle))
|
||||
{
|
||||
return handle;
|
||||
}
|
||||
|
||||
NativeLibrary.Free(handle);
|
||||
}
|
||||
|
||||
// 2. Development fallback: locate the cargo build output.
|
||||
var fileName = NativeFileName();
|
||||
var dir = AppContext.BaseDirectory;
|
||||
for (var i = 0; i < 16 && dir is not null; i++)
|
||||
{
|
||||
foreach (var profile in new[] { "release", "debug" })
|
||||
{
|
||||
var candidate = Path.Combine(dir, "target", profile, fileName);
|
||||
if (File.Exists(candidate) && NativeLibrary.TryLoad(candidate, out var devHandle))
|
||||
{
|
||||
if (Exports(devHandle))
|
||||
{
|
||||
return devHandle;
|
||||
}
|
||||
|
||||
NativeLibrary.Free(devHandle);
|
||||
}
|
||||
}
|
||||
|
||||
dir = Path.GetDirectoryName(dir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||||
}
|
||||
|
||||
return nint.Zero;
|
||||
}
|
||||
|
||||
private static bool Exports(nint handle) => NativeLibrary.TryGetExport(handle, SentinelSymbol, out _);
|
||||
|
||||
private static string NativeFileName()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return "wickra.dll";
|
||||
}
|
||||
|
||||
return OperatingSystem.IsMacOS() ? "libwickra.dylib" : "libwickra.so";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
# Wickra — Go
|
||||
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://pkg.go.dev/github.com/wickra-lib/wickra/bindings/go)
|
||||
[](https://github.com/wickra-lib/wickra#license)
|
||||
|
||||
**Streaming-first technical indicators for Go, over the Wickra C ABI hub via cgo.**
|
||||
|
||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||
bindings for Python, Node.js and WebAssembly, plus a C ABI for C/C++, C#, Go and
|
||||
any other C-capable language. Every indicator is an O(1) streaming state machine,
|
||||
so live trading bots and historical backtests share the exact same
|
||||
implementation. This package is the Go binding; it consumes the C ABI hub through
|
||||
cgo and exposes all 514 streaming-first indicators as idiomatic types.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
go get github.com/wickra-lib/wickra/bindings/go
|
||||
```
|
||||
|
||||
The binding uses cgo, so a C compiler is required, and it links against the
|
||||
prebuilt Wickra C ABI library. Build that library from the workspace and stage
|
||||
it under this package's `lib/` directory:
|
||||
|
||||
```bash
|
||||
cargo build -p wickra-c --release
|
||||
cp target/release/libwickra.so bindings/go/lib/ # Linux
|
||||
cp target/release/libwickra.dylib bindings/go/lib/ # macOS
|
||||
cp target/release/wickra.dll bindings/go/lib/ # Windows (also on PATH at run time)
|
||||
```
|
||||
|
||||
On Linux and macOS the library path is baked in via rpath; on Windows the DLL
|
||||
must be discoverable at run time (next to the executable or on `PATH`).
|
||||
|
||||
## Quick start
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
wickra "github.com/wickra-lib/wickra/bindings/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Batch: run an indicator over a whole series (NaN at warmup positions).
|
||||
prices := make([]float64, 1000)
|
||||
for i := range prices {
|
||||
prices[i] = 100.0 + float64(i)*0.1
|
||||
}
|
||||
sma, _ := wickra.NewSma(20)
|
||||
defer sma.Close()
|
||||
values := sma.Batch(prices)
|
||||
|
||||
// Streaming: the same indicator, fed tick by tick in O(1).
|
||||
rsi, _ := wickra.NewRsi(14)
|
||||
defer rsi.Close()
|
||||
for _, price := range prices {
|
||||
value := rsi.Update(price) // NaN during warmup, no recomputation
|
||||
if value > 70 {
|
||||
fmt.Println("overbought")
|
||||
}
|
||||
}
|
||||
_ = values
|
||||
}
|
||||
```
|
||||
|
||||
`Batch(prices)` and feeding the same prices through `Update()` produce identical
|
||||
values — the equivalence is enforced by the test suite. Multi-output indicators
|
||||
(MACD, Bollinger, ADX, …) return `(Output, bool)`, with `false` while warming up.
|
||||
Every indicator owns a native handle freed by `Close()`; a finalizer is wired as
|
||||
a backstop, but call `Close()` (e.g. with `defer`) to release memory promptly.
|
||||
|
||||
## Documentation
|
||||
|
||||
The full indicator catalogue, guides, quickstarts, and API reference live in the
|
||||
main repository and documentation site:
|
||||
|
||||
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
|
||||
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||
- **Runnable examples:** [`examples/go/`](https://github.com/wickra-lib/wickra/tree/main/examples/go)
|
||||
|
||||
Wickra ships native bindings for Python, Node.js, WebAssembly and Rust, plus a
|
||||
C ABI hub that any C-capable language (C, C++, C#, Go, Java, R) links against —
|
||||
all exposing the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Wickra is an indicator toolkit, not a trading system. The values it computes are
|
||||
deterministic transforms of the input data — they are not financial advice and
|
||||
do not predict the market. Any use in a live trading context is at your own risk.
|
||||
The library is provided **as is**, without warranty of any kind.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under either of [Apache-2.0](https://github.com/wickra-lib/wickra/blob/main/LICENSE-APACHE)
|
||||
or [MIT](https://github.com/wickra-lib/wickra/blob/main/LICENSE-MIT) at your option.
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/wickra-lib/wickra/bindings/go
|
||||
|
||||
go 1.23
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
# Prebuilt Wickra C ABI libraries are provisioned locally / in CI, not committed.
|
||||
*.so
|
||||
*.dylib
|
||||
*.dll
|
||||
*.a
|
||||
*.lib
|
||||
*.exp
|
||||
@@ -0,0 +1,25 @@
|
||||
// Package wickra provides idiomatic Go bindings for the Wickra
|
||||
// technical-analysis library over its C ABI hub.
|
||||
//
|
||||
// Each indicator is an opaque-handle type with a New<Indicator> constructor and
|
||||
// Update/Batch/Reset/Close methods. Handles are freed by Close and, as a
|
||||
// backstop, by a finalizer; call Close explicitly to release native memory
|
||||
// promptly. The binding links against the prebuilt Wickra C ABI library
|
||||
// (libwickra.so/.dylib or wickra.dll) staged under ./lib — see the package
|
||||
// README for how to provision it.
|
||||
package wickra
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -I${SRCDIR}/../c/include
|
||||
#cgo linux LDFLAGS: -L${SRCDIR}/lib -lwickra -Wl,-rpath,${SRCDIR}/lib
|
||||
#cgo darwin LDFLAGS: -L${SRCDIR}/lib -lwickra -Wl,-rpath,${SRCDIR}/lib
|
||||
#cgo windows LDFLAGS: -L${SRCDIR}/lib -l:wickra.dll
|
||||
#include "wickra.h"
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrInvalidParams is returned by a New<Indicator> constructor when the native
|
||||
// constructor rejects the supplied parameters (for example a zero period).
|
||||
var ErrInvalidParams = errors.New("wickra: invalid indicator parameters")
|
||||
@@ -0,0 +1,151 @@
|
||||
package wickra
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// One indicator per FFI archetype, exercising the full New/Update/Batch/Reset/
|
||||
// Close surface against the real native library.
|
||||
|
||||
func TestScalarKnownValue(t *testing.T) {
|
||||
s, err := NewSma(3)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSma: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
var last float64
|
||||
for _, v := range []float64{1, 2, 3, 4, 5} {
|
||||
last = s.Update(v)
|
||||
}
|
||||
if math.Abs(last-4.0) > 1e-9 {
|
||||
t.Fatalf("sma(3) last = %v, want 4.0", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScalarBatchMatchesStreaming(t *testing.T) {
|
||||
input := []float64{1, 2, 3, 4, 5, 6, 7, 8}
|
||||
|
||||
stream, _ := NewSma(3)
|
||||
defer stream.Close()
|
||||
want := make([]float64, len(input))
|
||||
for i, v := range input {
|
||||
want[i] = stream.Update(v)
|
||||
}
|
||||
|
||||
batchInd, _ := NewSma(3)
|
||||
defer batchInd.Close()
|
||||
got := batchInd.Batch(input)
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("batch len = %d, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if math.IsNaN(want[i]) && math.IsNaN(got[i]) {
|
||||
continue
|
||||
}
|
||||
if math.Abs(got[i]-want[i]) > 1e-9 {
|
||||
t.Fatalf("batch[%d] = %v, streaming = %v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiOutput(t *testing.T) {
|
||||
m, err := NewMacdIndicator(3, 6, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("NewMacdIndicator: %v", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
var ok bool
|
||||
var out MacdOutput
|
||||
for i := 0; i < 30; i++ {
|
||||
out, ok = m.Update(100 + float64(i))
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("macd never produced a value after warmup")
|
||||
}
|
||||
if math.IsNaN(out.Macd) {
|
||||
t.Fatal("macd value is NaN after warmup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBars(t *testing.T) {
|
||||
rb, err := NewRangeBars(2.0)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRangeBars: %v", err)
|
||||
}
|
||||
defer rb.Close()
|
||||
|
||||
total := 0
|
||||
for _, p := range []float64{100, 101, 103, 104, 99, 96, 102, 108, 95, 110} {
|
||||
bars := rb.Update(p, p, p, p, 1, 0)
|
||||
total += len(bars)
|
||||
}
|
||||
if total == 0 {
|
||||
t.Fatal("range bars produced no bars over a 15-point move")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfile(t *testing.T) {
|
||||
vp, err := NewVolumeProfile(10, 24)
|
||||
if err != nil {
|
||||
t.Fatalf("NewVolumeProfile: %v", err)
|
||||
}
|
||||
defer vp.Close()
|
||||
|
||||
var ok bool
|
||||
var snap VolumeProfileOutputScalars
|
||||
for i := 0; i < 50; i++ {
|
||||
price := 100 + 5*math.Sin(float64(i)*0.3)
|
||||
snap, ok = vp.Update(price, price+1, price-1, price, 1000, int64(i))
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("volume profile never produced a snapshot")
|
||||
}
|
||||
if len(snap.Values) == 0 {
|
||||
t.Fatal("volume profile returned an empty values buffer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArrayInput(t *testing.T) {
|
||||
ob, err := NewOrderBookImbalanceFull()
|
||||
if err != nil {
|
||||
t.Fatalf("NewOrderBookImbalanceFull: %v", err)
|
||||
}
|
||||
defer ob.Close()
|
||||
|
||||
bidPrice := []float64{99.9, 99.8, 99.7}
|
||||
bidSize := []float64{5, 3, 2}
|
||||
askPrice := []float64{100.1, 100.2, 100.3}
|
||||
askSize := []float64{1, 1, 1}
|
||||
v := ob.Update(bidPrice, bidSize, askPrice, askSize)
|
||||
if math.IsNaN(v) {
|
||||
t.Fatal("order-book imbalance is NaN on a populated book")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetReturnsToWarmup(t *testing.T) {
|
||||
s, _ := NewSma(3)
|
||||
defer s.Close()
|
||||
for _, v := range []float64{1, 2, 3} {
|
||||
s.Update(v)
|
||||
}
|
||||
s.Reset()
|
||||
if got := s.Update(10); !math.IsNaN(got) {
|
||||
t.Fatalf("after reset first update = %v, want NaN (warmup)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidParams(t *testing.T) {
|
||||
if _, err := NewSma(0); err == nil {
|
||||
t.Fatal("NewSma(0) should return ErrInvalidParams")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseIsIdempotent(t *testing.T) {
|
||||
s, _ := NewSma(3)
|
||||
s.Close()
|
||||
s.Close() // must not panic or double-free
|
||||
}
|
||||
@@ -9,7 +9,8 @@
|
||||
prebuilt native binary, no system dependencies.**
|
||||
|
||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
|
||||
bindings for Python, Node.js and WebAssembly, plus a C ABI for C/C++, C#, Go and any
|
||||
other C-capable language. Every indicator is an O(1)
|
||||
streaming state machine, so live trading bots and historical backtests share
|
||||
the exact same implementation. This package is the Node.js binding (napi-rs);
|
||||
it exposes 200+ streaming-first indicators across sixteen families.
|
||||
@@ -55,8 +56,9 @@ the main repository and documentation site:
|
||||
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||
- **Runnable examples:** [`examples/node/`](https://github.com/wickra-lib/wickra/tree/main/examples/node)
|
||||
|
||||
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
|
||||
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
Wickra ships native bindings for Python, Node.js, WebAssembly and Rust, plus a
|
||||
C ABI hub that any C-capable language (C, C++, Go, C#, Java, R) links against —
|
||||
all exposing the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
|
||||
@@ -14,7 +14,18 @@ const wickra = require('..');
|
||||
// but intentionally not isReady/warmupPeriod, so they are excluded from the
|
||||
// Indicator completeness contract below (their interface is covered by the
|
||||
// dedicated bar-builder tests).
|
||||
const BAR_BUILDERS = new Set(['RenkoBars', 'KagiBars', 'PointAndFigureBars']);
|
||||
const BAR_BUILDERS = new Set([
|
||||
'RenkoBars',
|
||||
'KagiBars',
|
||||
'PointAndFigureBars',
|
||||
'RangeBars',
|
||||
'TickBars',
|
||||
'VolumeBars',
|
||||
'DollarBars',
|
||||
'ImbalanceBars',
|
||||
'RunBars',
|
||||
'ThreeLineBreakBars',
|
||||
]);
|
||||
|
||||
// An "indicator class" is an exported constructor whose prototype carries the
|
||||
// streaming `update` method. This excludes `version` (a plain function), the bar
|
||||
|
||||
@@ -28,6 +28,61 @@ function num(v) {
|
||||
// --- Scalar indicators: update(value) vs batch(prices) ---
|
||||
|
||||
const scalarFactories = {
|
||||
M2Measure: () => new wickra.M2Measure(20, 0.0, 0.02),
|
||||
UpsidePotentialRatio: () => new wickra.UpsidePotentialRatio(20, 0.0),
|
||||
GainToPainRatio: () => new wickra.GainToPainRatio(12),
|
||||
CommonSenseRatio: () => new wickra.CommonSenseRatio(20),
|
||||
KRatio: () => new wickra.KRatio(30),
|
||||
TailRatio: () => new wickra.TailRatio(20),
|
||||
MartinRatio: () => new wickra.MartinRatio(14),
|
||||
BurkeRatio: () => new wickra.BurkeRatio(12),
|
||||
SterlingRatio: () => new wickra.SterlingRatio(12),
|
||||
AUTOCORRPGRAM: () => new wickra.AUTOCORRPGRAM(10, 48),
|
||||
EVENBETTERSINE: () => new wickra.EVENBETTERSINE(40, 10),
|
||||
BANDPASS: () => new wickra.BANDPASS(20, 0.3),
|
||||
UNIVERSALOSC: () => new wickra.UNIVERSALOSC(20),
|
||||
ADAPTIVERSI: () => new wickra.ADAPTIVERSI(14),
|
||||
CTI: () => new wickra.CTI(20),
|
||||
TRENDFLEX: () => new wickra.TRENDFLEX(20),
|
||||
REFLEX: () => new wickra.REFLEX(20),
|
||||
HIGHPASS: () => new wickra.HIGHPASS(48),
|
||||
SAMPLEENT: () => new wickra.SAMPLEENT(20, 2, 0.2),
|
||||
SHANNONENT: () => new wickra.SHANNONENT(20, 8),
|
||||
ROLLINGMINMAX: () => new wickra.ROLLINGMINMAX(20),
|
||||
JARQUEBERA: () => new wickra.JARQUEBERA(20),
|
||||
BipowerVariation: () => new wickra.BipowerVariation(20),
|
||||
VolatilityOfVolatility: () => new wickra.VolatilityOfVolatility(20, 20),
|
||||
Garch11: () => new wickra.Garch11(0.000002, 0.1, 0.88),
|
||||
EwmaVolatility: () => new wickra.EwmaVolatility(0.94),
|
||||
PpoHistogram: () => new wickra.PpoHistogram(3, 6, 3),
|
||||
MacdHistogram: () => new wickra.MacdHistogram(3, 6, 3),
|
||||
TsfOscillator: () => new wickra.TsfOscillator(3),
|
||||
WAVE_PM: () => new wickra.WAVE_PM(32, 3),
|
||||
POLARIZED_FRACTAL_EFFICIENCY: () => new wickra.POLARIZED_FRACTAL_EFFICIENCY(10, 5),
|
||||
TREND_STRENGTH_INDEX: () => new wickra.TREND_STRENGTH_INDEX(20),
|
||||
DerivativeOscillator: () => new wickra.DerivativeOscillator(14, 5, 3, 9),
|
||||
RMI: () => new wickra.RMI(14, 5),
|
||||
DynamicMomentumIndex: () => new wickra.DynamicMomentumIndex(14),
|
||||
RSX: () => new wickra.RSX(14),
|
||||
FisherRSI: () => new wickra.FisherRSI(14),
|
||||
DisparityIndex: () => new wickra.DisparityIndex(14),
|
||||
HoltWinters: () => new wickra.HoltWinters(0.2, 0.1),
|
||||
GD: () => new wickra.GD(5, 0.7),
|
||||
AdaptiveLaguerre: () => new wickra.AdaptiveLaguerre(13),
|
||||
MedianMA: () => new wickra.MedianMA(14),
|
||||
EHMA: () => new wickra.EHMA(9),
|
||||
GMA: () => new wickra.GMA(14),
|
||||
SWMA: () => new wickra.SWMA(14),
|
||||
Expectancy: () => new wickra.Expectancy(20),
|
||||
WinRate: () => new wickra.WinRate(20),
|
||||
RegimeLabel: () => new wickra.RegimeLabel(5, 20),
|
||||
JumpIndicator: () => new wickra.JumpIndicator(20, 3.0),
|
||||
TrendLabel: () => new wickra.TrendLabel(10),
|
||||
RollingQuantile: () => new wickra.RollingQuantile(20, 0.5),
|
||||
RollingPercentileRank: () => new wickra.RollingPercentileRank(14),
|
||||
RollingIqr: () => new wickra.RollingIqr(14),
|
||||
RealizedVolatility: () => new wickra.RealizedVolatility(20),
|
||||
LogReturn: () => new wickra.LogReturn(1),
|
||||
TSF: () => new wickra.TSF(14),
|
||||
LINEARREG_INTERCEPT: () => new wickra.LINEARREG_INTERCEPT(14),
|
||||
ROCR100: () => new wickra.ROCR100(10),
|
||||
@@ -297,6 +352,58 @@ const candleScalar = {
|
||||
TasukiGap: { make: () => new wickra.TasukiGap(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
UniqueThreeRiver: { make: () => new wickra.UniqueThreeRiver(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
ConcealingBabySwallow: { make: () => new wickra.ConcealingBabySwallow(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
DoubleTopBottom: { make: () => new wickra.DoubleTopBottom(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TripleTopBottom: { make: () => new wickra.TripleTopBottom(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
HeadAndShoulders: { make: () => new wickra.HeadAndShoulders(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Triangle: { make: () => new wickra.Triangle(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Wedge: { make: () => new wickra.Wedge(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
FlagPennant: { make: () => new wickra.FlagPennant(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
RectangleRange: { make: () => new wickra.RectangleRange(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
CupAndHandle: { make: () => new wickra.CupAndHandle(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Abcd: { make: () => new wickra.Abcd(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Gartley: { make: () => new wickra.Gartley(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Butterfly: { make: () => new wickra.Butterfly(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Bat: { make: () => new wickra.Bat(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Crab: { make: () => new wickra.Crab(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Shark: { make: () => new wickra.Shark(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Cypher: { make: () => new wickra.Cypher(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
ThreeDrives: { make: () => new wickra.ThreeDrives(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
CloseVsOpen: { make: () => new wickra.CloseVsOpen(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
BodySizePct: { make: () => new wickra.BodySizePct(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
WickRatio: { make: () => new wickra.WickRatio(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
HighLowRange: { make: () => new wickra.HighLowRange(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
StochasticCCI: { make: () => new wickra.StochasticCCI(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
IMI: { make: () => new wickra.IMI(14), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TTM_TREND: { make: () => new wickra.TTM_TREND(6), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
Qstick: { make: () => new wickra.Qstick(10), step: (ind, i) => ind.update(open[i], close[i]), batch: (ind) => ind.batch(open, close) },
|
||||
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) },
|
||||
ADAPTIVECCI: { make: () => new wickra.ADAPTIVECCI(20), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
PivotReversal: { make: () => new wickra.PivotReversal(1, 1), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TDCamouflage: { make: () => new wickra.TDCamouflage(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDClop: { make: () => new wickra.TDClop(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDClopwin: { make: () => new wickra.TDClopwin(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDPropulsion: { make: () => new wickra.TDPropulsion(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDTrap: { make: () => new wickra.TDTrap(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDDWave: { make: () => new wickra.TDDWave(2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
HeikinAshiOscillator: { make: () => new wickra.HeikinAshiOscillator(5), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
ThreeLineBreak: { make: () => new wickra.ThreeLineBreak(3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
Tristar: { make: () => new wickra.Tristar(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
HaramiCross: { make: () => new wickra.HaramiCross(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TowerTopBottom: { make: () => new wickra.TowerTopBottom(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
DumplingTop: { make: () => new wickra.DumplingTop(9), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
NewPriceLines: { make: () => new wickra.NewPriceLines(5), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
FryPanBottom: { make: () => new wickra.FryPanBottom(9), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
NakedPoc: { make: () => new wickra.NakedPoc(20, 24), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
SinglePrints: { make: () => new wickra.SinglePrints(20, 24), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
ProfileShape: { make: () => new wickra.ProfileShape(20, 24), step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(candleScalar)) {
|
||||
@@ -369,6 +476,41 @@ const multi = {
|
||||
// Family 13: Ichimoku & alternative charts
|
||||
Ichimoku: { make: () => new wickra.Ichimoku(9, 26, 52, 26), fields: ['tenkan', 'kijun', 'senkouA', 'senkouB', 'chikou'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
HeikinAshi: { make: () => new wickra.HeikinAshi(), fields: ['open', 'high', 'low', 'close'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
FibRetracement: { make: () => new wickra.FibRetracement(), fields: ['level0', 'level236', 'level382', 'level500', 'level618', 'level786', 'level1000'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
FibExtension: { make: () => new wickra.FibExtension(), fields: ['level1272', 'level1414', 'level1618', 'level2000', 'level2618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
FibProjection: { make: () => new wickra.FibProjection(), fields: ['level618', 'level1000', 'level1618', 'level2618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
AutoFib: { make: () => new wickra.AutoFib(), fields: ['level0', 'level236', 'level382', 'level500', 'level618', 'level786', 'level1000'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
GoldenPocket: { make: () => new wickra.GoldenPocket(), fields: ['low', 'mid', 'high'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
FibConfluence: { make: () => new wickra.FibConfluence(), fields: ['price', 'strength'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
FibFan: { make: () => new wickra.FibFan(), fields: ['fan382', 'fan500', 'fan618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
FibArcs: { make: () => new wickra.FibArcs(), fields: ['arc382', 'arc500', 'arc618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
FibChannel: { make: () => new wickra.FibChannel(), fields: ['base', 'level618', 'level1000', 'level1618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
FibTimeZones: { make: () => new wickra.FibTimeZones(), fields: ['onZone', 'barsToNext'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
ElderRay: { make: () => new wickra.ElderRay(13), fields: ['bullPower', 'bearPower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
QQE: { make: () => new wickra.QQE(14, 5, 4.236), fields: ['rsiMa', 'trailingLine'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
GatorOscillator: { make: () => new wickra.GatorOscillator(13, 8, 5), fields: ['upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
KasePermissionStochastic: { make: () => new wickra.KasePermissionStochastic(9, 3), fields: ['fast', 'slow'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
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) },
|
||||
CentralPivotRange: { make: () => new wickra.CentralPivotRange(), fields: ['pivot', 'tc', 'bc'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
MurreyMathLines: { make: () => new wickra.MurreyMathLines(4), fields: ['mm8_8', 'mm7_8', 'mm6_8', 'mm5_8', 'mm4_8', 'mm3_8', 'mm2_8', 'mm1_8', 'mm0_8'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
AndrewsPitchfork: { make: () => new wickra.AndrewsPitchfork(2), fields: ['median', 'upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
VolumeWeightedSr: { make: () => new wickra.VolumeWeightedSr(3), fields: ['support', 'resistance'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
TDMovingAverage: { make: () => new wickra.TDMovingAverage(5, 13), fields: ['st1', 'st2'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
SmoothedHeikinAshi: { make: () => new wickra.SmoothedHeikinAshi(5), fields: ['open', 'high', 'low', 'close'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Equivolume: { make: () => new wickra.Equivolume(20), fields: ['height', 'width'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
CandleVolume: { make: () => new wickra.CandleVolume(20), fields: ['body', 'width'], step: (ind, i) => ind.update(open[i], close[i], volume[i]), batch: (ind) => ind.batch(open, close, volume) },
|
||||
HighLowVolumeNodes: { make: () => new wickra.HighLowVolumeNodes(20, 24), fields: ['hvn', 'lvn'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
CompositeProfile: { make: () => new wickra.CompositeProfile(20, 24, 0.7), fields: ['poc', 'vah', 'val'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(multi)) {
|
||||
@@ -537,6 +679,9 @@ const pairFactories = {
|
||||
BetaNeutralSpread: () => new wickra.BetaNeutralSpread(20),
|
||||
VarianceRatio: () => new wickra.VarianceRatio(60, 2),
|
||||
GrangerCausality: () => new wickra.GrangerCausality(60, 1),
|
||||
SpreadAr1Coefficient: () => new wickra.SpreadAr1Coefficient(40),
|
||||
KendallTau: () => new wickra.KendallTau(20),
|
||||
HasbrouckInformationShare: () => new wickra.HasbrouckInformationShare(2),
|
||||
};
|
||||
|
||||
for (const [name, make] of Object.entries(pairFactories)) {
|
||||
@@ -1100,6 +1245,67 @@ test('trade-flow rejects bad input', () => {
|
||||
assert.throws(() => new wickra.SignedVolume().update(100, -1, true));
|
||||
});
|
||||
|
||||
test('order-flow imbalance reference + streaming matches batch', () => {
|
||||
// Rising bid (px up, size 6) with an unchanged ask -> +6 flow.
|
||||
const ofi = new wickra.OrderFlowImbalance(1);
|
||||
assert.equal(ofi.update([100], [5], [101], [4]), null); // seeds the reference
|
||||
assert.ok(Math.abs(ofi.update([100.5], [6], [101], [4]) - 6.0) < 1e-12);
|
||||
const snaps = Array.from({ length: 30 }, (_, i) => ({
|
||||
bidPx: [100 + Math.sin(i * 0.3)],
|
||||
bidSz: [5 + Math.abs(Math.cos(i * 0.5))],
|
||||
askPx: [101 + Math.sin(i * 0.3)],
|
||||
askSz: [4 + Math.abs(Math.sin(i * 0.4))],
|
||||
}));
|
||||
const batch = new wickra.OrderFlowImbalance(10).batch(snaps);
|
||||
const streamer = new wickra.OrderFlowImbalance(10);
|
||||
assert.equal(batch.length, snaps.length);
|
||||
for (let i = 0; i < snaps.length; i++) {
|
||||
const s = streamer.update(snaps[i].bidPx, snaps[i].bidSz, snaps[i].askPx, snaps[i].askSz);
|
||||
assert.ok((Number.isNaN(batch[i]) && s === null) || Math.abs(s - batch[i]) < 1e-9, `mismatch at ${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('vpin / amihud / roll reference + streaming matches batch', () => {
|
||||
// VPIN: two pure-buy buckets of size 10 -> imbalance == size -> 1.
|
||||
const v = new wickra.Vpin(10, 2);
|
||||
let last;
|
||||
for (let i = 0; i < 4; i++) last = v.update(100, 5, true);
|
||||
assert.equal(last, 1.0);
|
||||
// Amihud(1): |ln(101/100)| / (101 * 10).
|
||||
const a = new wickra.AmihudIlliquidity(1);
|
||||
assert.equal(a.update(100, 10, true), null);
|
||||
assert.ok(Math.abs(a.update(101, 10, true) - Math.abs(Math.log(101 / 100)) / (101 * 10)) < 1e-15);
|
||||
// Roll(6): a clean bid-ask bounce of ±1 implies a spread of 2.
|
||||
const r = new wickra.RollMeasure(6);
|
||||
let roll = null;
|
||||
for (let i = 0; i < 20; i++) roll = r.update(i % 2 === 0 ? 100 : 101, 1, true);
|
||||
assert.ok(Math.abs(roll - 2.0) < 1e-12);
|
||||
// Streaming-vs-batch for the three trade-input indicators.
|
||||
const n = 40;
|
||||
const price = Array.from({ length: n }, (_, i) => 100 + Math.sin(i * 0.25) * 4);
|
||||
const size = Array.from({ length: n }, (_, i) => 1 + (i % 5));
|
||||
const isBuy = Array.from({ length: n }, (_, i) => i % 2 === 0);
|
||||
for (const make of [() => new wickra.Vpin(8, 5), () => new wickra.AmihudIlliquidity(14), () => new wickra.RollMeasure(14), () => new wickra.TradeSignAutocorrelation(10), () => new wickra.Pin(10)]) {
|
||||
const batch = make().batch(price, size, isBuy);
|
||||
const streamer = make();
|
||||
assert.equal(batch.length, n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const s = streamer.update(price[i], size[i], isBuy[i]);
|
||||
assert.ok((Number.isNaN(batch[i]) && s === null) || Math.abs(s - batch[i]) < 1e-9, `mismatch at ${i}`);
|
||||
}
|
||||
}
|
||||
// Trade-sign autocorrelation: alternating signs -> -1, all buys -> +1.
|
||||
let tsac = null;
|
||||
const tsacInd = new wickra.TradeSignAutocorrelation(10);
|
||||
for (let i = 0; i < 20; i++) tsac = tsacInd.update(100, 1, i % 2 === 0);
|
||||
assert.ok(Math.abs(tsac - -1.0) < 1e-12);
|
||||
// PIN: one-sided flow -> 1, balanced flow -> 0.
|
||||
let pin = null;
|
||||
const pinInd = new wickra.Pin(10);
|
||||
for (let i = 0; i < 20; i++) pin = pinInd.update(100, 1, true);
|
||||
assert.ok(Math.abs(pin - 1.0) < 1e-12);
|
||||
});
|
||||
|
||||
test('price-impact indicators reference values', () => {
|
||||
// Buy at 100.05 vs mid 100.0: 2 * (100.05 - 100) / 100 * 10000 = 10 bps.
|
||||
assert.ok(Math.abs(new wickra.EffectiveSpread().update(100.05, 1, true, 100.0) - 10.0) < 1e-9);
|
||||
@@ -1251,6 +1457,56 @@ test('derivatives reject bad input', () => {
|
||||
assert.throws(() => new wickra.FundingBasis().update(100, 0));
|
||||
});
|
||||
|
||||
test('B16 derivatives reference values', () => {
|
||||
// Estimated leverage: oi / (long + short) = 200 / 100 = 2.
|
||||
assert.ok(Math.abs(new wickra.EstimatedLeverageRatio().update(200, 60, 40) - 2.0) < 1e-12);
|
||||
// OI-to-volume: oi / (buy + sell) = 100 / 50 = 2.
|
||||
assert.ok(Math.abs(new wickra.OiToVolumeRatio().update(100, 30, 20) - 2.0) < 1e-12);
|
||||
// Perpetual premium: (mark - index) / index = 0.5 / 100 = 0.005.
|
||||
assert.ok(Math.abs(new wickra.PerpetualPremiumIndex().update(100.5, 100.0) - 0.005) < 1e-12);
|
||||
// Funding-implied APR: rate * intervals = 0.0001 * 1095 = 0.1095.
|
||||
assert.ok(Math.abs(new wickra.FundingImpliedApr(1095).update(0.0001) - 0.1095) < 1e-12);
|
||||
// Open-interest momentum (period 2): warmup then ROC% = 100*(120 - 100)/100 = 20.
|
||||
const oim = new wickra.OpenInterestMomentum(2);
|
||||
assert.equal(oim.update(100), null);
|
||||
assert.equal(oim.update(110), null);
|
||||
assert.ok(Math.abs(oim.update(120) - 20.0) < 1e-12);
|
||||
});
|
||||
|
||||
test('B16 derivatives streaming matches batch', () => {
|
||||
const n = 30;
|
||||
const oi = Array.from({ length: n }, (_, i) => 1000 + 50 * Math.sin(i * 0.3));
|
||||
const longSz = Array.from({ length: n }, (_, i) => 600 + 20 * Math.cos(i * 0.2));
|
||||
const shortSz = Array.from({ length: n }, (_, i) => 400 + 15 * Math.sin(i * 0.4));
|
||||
const buy = Array.from({ length: n }, (_, i) => 300 + 10 * Math.sin(i * 0.5));
|
||||
const sell = Array.from({ length: n }, (_, i) => 250 + 12 * Math.cos(i * 0.35));
|
||||
const index = Array.from({ length: n }, (_, i) => 100 + Math.sin(i * 0.2));
|
||||
const mark = Array.from({ length: n }, (_, i) => index[i] + 0.05 * Math.cos(i * 0.3));
|
||||
const rate = Array.from({ length: n }, (_, i) => 0.0001 * Math.sin(i * 0.3));
|
||||
const cmp = (batch, s, i) =>
|
||||
assert.ok((s === null && Number.isNaN(batch[i])) || Math.abs(s - batch[i]) < 1e-12, `mismatch at ${i}`);
|
||||
|
||||
let b = new wickra.EstimatedLeverageRatio().batch(oi, longSz, shortSz);
|
||||
let st = new wickra.EstimatedLeverageRatio();
|
||||
for (let i = 0; i < n; i++) cmp(b, st.update(oi[i], longSz[i], shortSz[i]), i);
|
||||
|
||||
b = new wickra.OiToVolumeRatio().batch(oi, buy, sell);
|
||||
st = new wickra.OiToVolumeRatio();
|
||||
for (let i = 0; i < n; i++) cmp(b, st.update(oi[i], buy[i], sell[i]), i);
|
||||
|
||||
b = new wickra.PerpetualPremiumIndex().batch(mark, index);
|
||||
st = new wickra.PerpetualPremiumIndex();
|
||||
for (let i = 0; i < n; i++) cmp(b, st.update(mark[i], index[i]), i);
|
||||
|
||||
b = new wickra.FundingImpliedApr(1095).batch(rate);
|
||||
st = new wickra.FundingImpliedApr(1095);
|
||||
for (let i = 0; i < n; i++) cmp(b, st.update(rate[i]), i);
|
||||
|
||||
b = new wickra.OpenInterestMomentum(10).batch(oi);
|
||||
st = new wickra.OpenInterestMomentum(10);
|
||||
for (let i = 0; i < n; i++) cmp(b, st.update(oi[i]), i);
|
||||
});
|
||||
|
||||
test('market breadth: AdvanceDecline reference values', () => {
|
||||
// A breadth tick is the universe as parallel arrays; the sign of `change`
|
||||
// classifies each symbol as advancing / declining / unchanged.
|
||||
@@ -1513,3 +1769,72 @@ test('PointAndFigureBars closes a column on a 3-box reversal', () => {
|
||||
assert.equal(col[0].direction, 1);
|
||||
assert.ok(Math.abs(col[0].high - 15) < 1e-9 && Math.abs(col[0].low - 10) < 1e-9);
|
||||
});
|
||||
|
||||
test('RangeBars prints aligned bars on an up move', () => {
|
||||
const rb = new wickra.RangeBars(1.0);
|
||||
assert.deepEqual(rb.update(10), []); // seed
|
||||
const up = rb.update(13);
|
||||
assert.equal(up.length, 3);
|
||||
assert.ok(Math.abs(up[0].open - 10) < 1e-9 && Math.abs(up[2].close - 13) < 1e-9);
|
||||
assert.ok(up.every((b) => b.direction === 1));
|
||||
});
|
||||
|
||||
test('TickBars groups a fixed number of candles', () => {
|
||||
const tb = new wickra.TickBars(2);
|
||||
assert.deepEqual(tb.update(10, 11, 9, 10.5, 100), []);
|
||||
const out = tb.update(10.5, 12, 10, 11, 150);
|
||||
assert.equal(out.length, 1);
|
||||
assert.ok(Math.abs(out[0].open - 10) < 1e-9);
|
||||
assert.ok(Math.abs(out[0].high - 12) < 1e-9);
|
||||
assert.ok(Math.abs(out[0].low - 9) < 1e-9);
|
||||
assert.ok(Math.abs(out[0].close - 11) < 1e-9);
|
||||
assert.ok(Math.abs(out[0].volume - 250) < 1e-9);
|
||||
});
|
||||
|
||||
test('VolumeBars closes when accumulated volume crosses the threshold', () => {
|
||||
const vb = new wickra.VolumeBars(100);
|
||||
assert.deepEqual(vb.update(10, 10, 10, 10, 60), []);
|
||||
const out = vb.update(10.5, 10.5, 10.5, 10.5, 60);
|
||||
assert.equal(out.length, 1);
|
||||
assert.ok(Math.abs(out[0].volume - 120) < 1e-9);
|
||||
});
|
||||
|
||||
test('DollarBars closes when traded value crosses the threshold', () => {
|
||||
const db = new wickra.DollarBars(1000);
|
||||
assert.deepEqual(db.update(10, 10, 10, 10, 60), []);
|
||||
const out = db.update(10, 10, 10, 10, 60);
|
||||
assert.equal(out.length, 1);
|
||||
assert.ok(Math.abs(out[0].dollar - 1200) < 1e-9);
|
||||
assert.ok(Math.abs(out[0].volume - 120) < 1e-9);
|
||||
});
|
||||
|
||||
test('ImbalanceBars closes a buy bar at the threshold', () => {
|
||||
const ib = new wickra.ImbalanceBars(3.0);
|
||||
ib.update(10, 10, 10, 10);
|
||||
ib.update(11, 11, 11, 11);
|
||||
ib.update(12, 12, 12, 12);
|
||||
const out = ib.update(13, 13, 13, 13);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].direction, 1);
|
||||
assert.ok(Math.abs(out[0].imbalance - 3) < 1e-9);
|
||||
});
|
||||
|
||||
test('RunBars closes a buy run at the run length', () => {
|
||||
const rb = new wickra.RunBars(3);
|
||||
rb.update(10, 10, 10, 10);
|
||||
rb.update(11, 11, 11, 11);
|
||||
rb.update(12, 12, 12, 12);
|
||||
const out = rb.update(13, 13, 13, 13);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].direction, 1);
|
||||
assert.equal(out[0].length, 3);
|
||||
});
|
||||
|
||||
test('ThreeLineBreakBars draws a rising line', () => {
|
||||
const tlb = new wickra.ThreeLineBreakBars(3);
|
||||
assert.deepEqual(tlb.update(10), []); // seed
|
||||
const out = tlb.update(11);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].direction, 1);
|
||||
assert.ok(Math.abs(out[0].open - 10) < 1e-9 && Math.abs(out[0].close - 11) < 1e-9);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Streaming-vs-batch equivalence and reference values for the Seasonality &
|
||||
// Session family. These indicators consume the full candle (open, high, low,
|
||||
// close, volume, timestamp), so they have a dedicated suite.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const wickra = require('..');
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
const N = 240;
|
||||
const close = Array.from({ length: N }, (_, i) => 100 + Math.sin(i * 0.3) * 5 + Math.cos(i * 0.1) * 3);
|
||||
const open = close.map((c, i) => c + Math.sin(i * 0.5) * 0.5);
|
||||
const high = close.map((c, i) => Math.max(open[i], c) + 1);
|
||||
const low = close.map((c, i) => Math.min(open[i], c) - 1);
|
||||
const volume = Array.from({ length: N }, (_, i) => 1000 + (i % 24) * 50);
|
||||
const ts = Array.from({ length: N }, (_, i) => i * HOUR);
|
||||
|
||||
function eq(a, b) {
|
||||
if (Number.isNaN(a)) return Number.isNaN(b);
|
||||
return Math.abs(a - b) < 1e-9;
|
||||
}
|
||||
|
||||
function streamScalar(ind, i) {
|
||||
const v = ind.update(open[i], high[i], low[i], close[i], volume[i], ts[i]);
|
||||
return v === null || v === undefined ? NaN : v;
|
||||
}
|
||||
|
||||
function checkScalar(name, make) {
|
||||
test(`${name} streaming equals batch`, () => {
|
||||
const a = make();
|
||||
const b = make();
|
||||
const batch = b.batch(open, high, low, close, volume, ts);
|
||||
for (let i = 0; i < N; i += 1) {
|
||||
assert.ok(eq(streamScalar(a, i), batch[i]), `${name} row ${i}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function checkMatrix(name, make, k, pick) {
|
||||
test(`${name} streaming equals batch`, () => {
|
||||
const a = make();
|
||||
const b = make();
|
||||
const batch = b.batch(open, high, low, close, volume, ts);
|
||||
for (let i = 0; i < N; i += 1) {
|
||||
const out = a.update(open[i], high[i], low[i], close[i], volume[i], ts[i]);
|
||||
for (let j = 0; j < k; j += 1) {
|
||||
const s = out === null || out === undefined ? NaN : pick(out, j);
|
||||
assert.ok(eq(s, batch[i * k + j]), `${name} row ${i} col ${j}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
checkScalar('SessionVwap', () => new wickra.SessionVwap(0));
|
||||
checkScalar('OvernightGap', () => new wickra.OvernightGap(0));
|
||||
checkScalar('SeasonalZScore', () => new wickra.SeasonalZScore(0));
|
||||
checkScalar('AverageDailyRange', () => new wickra.AverageDailyRange(3, 0));
|
||||
checkScalar('TurnOfMonth', () => new wickra.TurnOfMonth(3, 1, 0));
|
||||
|
||||
checkMatrix('SessionHighLow', () => new wickra.SessionHighLow(0), 2, (o, j) => (j === 0 ? o.high : o.low));
|
||||
checkMatrix('SessionRange', () => new wickra.SessionRange(0), 3, (o, j) => [o.asia, o.eu, o.us][j]);
|
||||
checkMatrix(
|
||||
'OvernightIntradayReturn',
|
||||
() => new wickra.OvernightIntradayReturn(0),
|
||||
2,
|
||||
(o, j) => (j === 0 ? o.overnight : o.intraday),
|
||||
);
|
||||
checkMatrix('TimeOfDayReturnProfile', () => new wickra.TimeOfDayReturnProfile(24, 0), 24, (o, j) => o[j]);
|
||||
checkMatrix('IntradayVolatilityProfile', () => new wickra.IntradayVolatilityProfile(12, 0), 12, (o, j) => o[j]);
|
||||
checkMatrix('VolumeByTimeProfile', () => new wickra.VolumeByTimeProfile(24, 0), 24, (o, j) => o[j]);
|
||||
checkMatrix('DayOfWeekProfile', () => new wickra.DayOfWeekProfile(0), 7, (o, j) => o[j]);
|
||||
|
||||
test('SessionVwap reference value', () => {
|
||||
const vwap = new wickra.SessionVwap(0);
|
||||
assert.ok(eq(vwap.update(100, 100, 100, 100, 10, 0), 100));
|
||||
assert.ok(eq(vwap.update(110, 110, 110, 110, 30, HOUR), 107.5));
|
||||
assert.ok(eq(vwap.update(200, 200, 200, 200, 5, 24 * HOUR), 200));
|
||||
});
|
||||
|
||||
test('OvernightGap reference value', () => {
|
||||
const gap = new wickra.OvernightGap(0);
|
||||
assert.equal(gap.update(99, 101, 98, 100, 1, 0), null);
|
||||
assert.ok(eq(gap.update(105, 106, 104, 105.5, 1, 24 * HOUR), 0.05));
|
||||
});
|
||||
|
||||
test('SessionHighLow reference object', () => {
|
||||
const shl = new wickra.SessionHighLow(0);
|
||||
shl.update(100, 105, 99, 101, 1, 0);
|
||||
const out = shl.update(101, 108, 100, 107, 1, HOUR);
|
||||
assert.ok(eq(out.high, 108));
|
||||
assert.ok(eq(out.low, 99));
|
||||
});
|
||||
|
||||
test('AverageDailyRange rejects zero period', () => {
|
||||
assert.throws(() => new wickra.AverageDailyRange(0, 0));
|
||||
});
|
||||
Vendored
+1859
File diff suppressed because it is too large
Load Diff
+176
-1
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-arm64",
|
||||
"version": "0.5.0",
|
||||
"version": "0.7.7",
|
||||
"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.0",
|
||||
"version": "0.7.7",
|
||||
"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.0",
|
||||
"version": "0.7.7",
|
||||
"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.0",
|
||||
"version": "0.7.7",
|
||||
"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.0",
|
||||
"version": "0.7.7",
|
||||
"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.0",
|
||||
"version": "0.7.7",
|
||||
"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.0",
|
||||
"version": "0.7.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra",
|
||||
"version": "0.5.0",
|
||||
"version": "0.7.7",
|
||||
"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.0",
|
||||
"wickra-darwin-x64": "0.5.0",
|
||||
"wickra-linux-arm64-gnu": "0.5.0",
|
||||
"wickra-linux-x64-gnu": "0.5.0",
|
||||
"wickra-win32-arm64-msvc": "0.5.0",
|
||||
"wickra-win32-x64-msvc": "0.5.0"
|
||||
"wickra-darwin-arm64": "0.7.7",
|
||||
"wickra-darwin-x64": "0.7.7",
|
||||
"wickra-linux-arm64-gnu": "0.7.7",
|
||||
"wickra-linux-x64-gnu": "0.7.7",
|
||||
"wickra-win32-arm64-msvc": "0.7.7",
|
||||
"wickra-win32-x64-msvc": "0.7.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/cli": {
|
||||
@@ -41,8 +41,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-arm64": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.0.tgz",
|
||||
"version": "0.7.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.7.7.tgz",
|
||||
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -57,8 +57,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-x64": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.0.tgz",
|
||||
"version": "0.7.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.7.7.tgz",
|
||||
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -73,8 +73,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-arm64-gnu": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.0.tgz",
|
||||
"version": "0.7.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.7.7.tgz",
|
||||
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -89,8 +89,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-x64-gnu": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.0.tgz",
|
||||
"version": "0.7.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.7.7.tgz",
|
||||
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -105,8 +105,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-arm64-msvc": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.0.tgz",
|
||||
"version": "0.7.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.7.7.tgz",
|
||||
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -121,8 +121,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-x64-msvc": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.0.tgz",
|
||||
"version": "0.7.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.7.7.tgz",
|
||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.5.0",
|
||||
"version": "0.7.7",
|
||||
"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.0",
|
||||
"wickra-linux-arm64-gnu": "0.5.0",
|
||||
"wickra-darwin-x64": "0.5.0",
|
||||
"wickra-darwin-arm64": "0.5.0",
|
||||
"wickra-win32-x64-msvc": "0.5.0",
|
||||
"wickra-win32-arm64-msvc": "0.5.0"
|
||||
"wickra-linux-x64-gnu": "0.7.7",
|
||||
"wickra-linux-arm64-gnu": "0.7.7",
|
||||
"wickra-darwin-x64": "0.7.7",
|
||||
"wickra-darwin-arm64": "0.7.7",
|
||||
"wickra-win32-x64-msvc": "0.7.7",
|
||||
"wickra-win32-arm64-msvc": "0.7.7"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "napi build --platform --release",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,8 @@
|
||||
system dependencies, no C build tooling.**
|
||||
|
||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
|
||||
bindings for Python, Node.js and WebAssembly, plus a C ABI for C/C++, C#, Go and any
|
||||
other C-capable language. Every indicator is an O(1)
|
||||
streaming state machine, so live trading bots and historical backtests share
|
||||
the exact same implementation. This package is the Python binding (PyO3); it
|
||||
exposes 200+ streaming-first indicators across sixteen families.
|
||||
@@ -54,8 +55,9 @@ the main repository and documentation site:
|
||||
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||
- **Runnable examples:** [`examples/python/`](https://github.com/wickra-lib/wickra/tree/main/examples/python)
|
||||
|
||||
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
|
||||
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
Wickra ships native bindings for Python, Node.js, WebAssembly and Rust, plus a
|
||||
C ABI hub that any C-capable language (C, C++, Go, C#, Java, R) links against —
|
||||
all exposing the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -71,13 +72,23 @@ class Sample:
|
||||
return (self.seconds / self.iterations) * 1_000_000
|
||||
|
||||
|
||||
def time_call(fn: Callable[[], None], iterations: int) -> float:
|
||||
"""Time ``fn`` over ``iterations`` calls, returning total wall seconds."""
|
||||
def time_call(fn: Callable[[], None], iterations: int, rounds: int = 5) -> float:
|
||||
"""Time ``fn`` over ``iterations`` calls per round, across ``rounds`` rounds.
|
||||
|
||||
Returns the *median* round's wall seconds for one round of ``iterations``
|
||||
calls. Taking the median across several rounds damps the OS scheduling and
|
||||
GC jitter that a single timing pass would otherwise bake into the result,
|
||||
so the per-iteration figure is stable run-to-run. Callers keep dividing the
|
||||
return value by ``iterations``.
|
||||
"""
|
||||
fn() # one warmup call to populate caches
|
||||
start = time.perf_counter()
|
||||
for _ in range(iterations):
|
||||
fn()
|
||||
return time.perf_counter() - start
|
||||
rounds_s: List[float] = []
|
||||
for _ in range(rounds):
|
||||
start = time.perf_counter()
|
||||
for _ in range(iterations):
|
||||
fn()
|
||||
rounds_s.append(time.perf_counter() - start)
|
||||
return statistics.median(rounds_s)
|
||||
|
||||
|
||||
def gen_prices(n: int, seed: int = 0xC0FFEE) -> np.ndarray:
|
||||
@@ -275,6 +286,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 +368,260 @@ 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
|
||||
|
||||
|
||||
# Recompute streaming peers: batch-only libraries have no incremental API, so
|
||||
# the only honest way to drive them tick-by-tick is to re-run the full batch
|
||||
# over the grown history on every new price. These runners expose exactly that
|
||||
# cost — the gap Wickra's O(1) update closes.
|
||||
|
||||
|
||||
def _talib_recompute_streaming(seed, live, fn):
|
||||
def run() -> None:
|
||||
history = list(seed)
|
||||
for p in live:
|
||||
history.append(float(p))
|
||||
fn(np.asarray(history))
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def _pandas_ta_recompute_streaming(seed, live, fn):
|
||||
def run() -> None:
|
||||
history = list(seed)
|
||||
for p in live:
|
||||
history.append(float(p))
|
||||
fn(PD.Series(history))
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def _tulipy_recompute_streaming(seed, live, fn):
|
||||
def run() -> None:
|
||||
history = list(seed)
|
||||
for p in live:
|
||||
history.append(float(p))
|
||||
fn(np.asarray(history, dtype=np.float64))
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def _finta_recompute_streaming(seed, live, fn):
|
||||
def run() -> None:
|
||||
history = list(seed)
|
||||
for p in live:
|
||||
history.append(float(p))
|
||||
arr = np.asarray(history)
|
||||
fn(PD.DataFrame({"open": arr, "high": arr, "low": arr, "close": arr, "volume": np.ones_like(arr)}))
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def talib_sma_streaming(seed, live):
|
||||
if TALIB is None:
|
||||
return None
|
||||
return _talib_recompute_streaming(seed, live, lambda a: TALIB.SMA(a, timeperiod=20))
|
||||
|
||||
|
||||
def pandas_ta_sma_streaming(seed, live):
|
||||
if PANDAS_TA is None or PD is None:
|
||||
return None
|
||||
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.sma(s, length=20))
|
||||
|
||||
|
||||
def tulipy_sma_streaming(seed, live):
|
||||
if TULIPY is None:
|
||||
return None
|
||||
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.sma(a, 20))
|
||||
|
||||
|
||||
def finta_sma_streaming(seed, live):
|
||||
if FINTA is None or PD is None:
|
||||
return None
|
||||
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.SMA(df, period=20))
|
||||
|
||||
|
||||
def talib_ema_streaming(seed, live):
|
||||
if TALIB is None:
|
||||
return None
|
||||
return _talib_recompute_streaming(seed, live, lambda a: TALIB.EMA(a, timeperiod=20))
|
||||
|
||||
|
||||
def pandas_ta_ema_streaming(seed, live):
|
||||
if PANDAS_TA is None or PD is None:
|
||||
return None
|
||||
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.ema(s, length=20))
|
||||
|
||||
|
||||
def tulipy_ema_streaming(seed, live):
|
||||
if TULIPY is None:
|
||||
return None
|
||||
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.ema(a, 20))
|
||||
|
||||
|
||||
def finta_ema_streaming(seed, live):
|
||||
if FINTA is None or PD is None:
|
||||
return None
|
||||
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.EMA(df, period=20))
|
||||
|
||||
|
||||
def tulipy_rsi_streaming(seed, live):
|
||||
if TULIPY is None:
|
||||
return None
|
||||
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.rsi(a, 14))
|
||||
|
||||
|
||||
def finta_rsi_streaming(seed, live):
|
||||
if FINTA is None or PD is None:
|
||||
return None
|
||||
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.RSI(df, period=14))
|
||||
|
||||
|
||||
def talib_macd_streaming(seed, live):
|
||||
if TALIB is None:
|
||||
return None
|
||||
return _talib_recompute_streaming(seed, live, lambda a: TALIB.MACD(a))
|
||||
|
||||
|
||||
def pandas_ta_macd_streaming(seed, live):
|
||||
if PANDAS_TA is None or PD is None:
|
||||
return None
|
||||
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.macd(s))
|
||||
|
||||
|
||||
def tulipy_macd_streaming(seed, live):
|
||||
if TULIPY is None:
|
||||
return None
|
||||
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.macd(a, 12, 26, 9))
|
||||
|
||||
|
||||
def finta_macd_streaming(seed, live):
|
||||
if FINTA is None or PD is None:
|
||||
return None
|
||||
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.MACD(df))
|
||||
|
||||
|
||||
def talib_bollinger_streaming(seed, live):
|
||||
if TALIB is None:
|
||||
return None
|
||||
return _talib_recompute_streaming(seed, live, lambda a: TALIB.BBANDS(a, timeperiod=20, nbdevup=2, nbdevdn=2))
|
||||
|
||||
|
||||
def pandas_ta_bollinger_streaming(seed, live):
|
||||
if PANDAS_TA is None or PD is None:
|
||||
return None
|
||||
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.bbands(s, length=20, std=2.0))
|
||||
|
||||
|
||||
def tulipy_bollinger_streaming(seed, live):
|
||||
if TULIPY is None:
|
||||
return None
|
||||
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.bbands(a, 20, 2.0))
|
||||
|
||||
|
||||
def finta_bollinger_streaming(seed, live):
|
||||
if FINTA is None or PD is None:
|
||||
return None
|
||||
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.BBANDS(df, period=20, std_multiplier=2.0))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Runner
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -339,6 +632,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 +640,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 +648,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 +656,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 +664,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,29 +674,64 @@ 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),
|
||||
("TA-Lib", talib_sma_streaming),
|
||||
("pandas-ta", pandas_ta_sma_streaming),
|
||||
("tulipy", tulipy_sma_streaming),
|
||||
("finta", finta_sma_streaming),
|
||||
]),
|
||||
("EMA(20)", [
|
||||
("Wickra", wickra_ema_streaming),
|
||||
("talipp", talipp_ema_streaming),
|
||||
("TA-Lib", talib_ema_streaming),
|
||||
("pandas-ta", pandas_ta_ema_streaming),
|
||||
("tulipy", tulipy_ema_streaming),
|
||||
("finta", finta_ema_streaming),
|
||||
]),
|
||||
("RSI(14)", [
|
||||
("Wickra", wickra_rsi_streaming),
|
||||
("talipp", talipp_rsi_streaming),
|
||||
("TA-Lib", talib_rsi_streaming),
|
||||
("pandas-ta", pandas_ta_rsi_streaming),
|
||||
("talipp", talipp_rsi_streaming),
|
||||
("tulipy", tulipy_rsi_streaming),
|
||||
("finta", finta_rsi_streaming),
|
||||
]),
|
||||
("MACD(12, 26, 9)", [
|
||||
("Wickra", wickra_macd_streaming),
|
||||
("talipp", talipp_macd_streaming),
|
||||
("TA-Lib", talib_macd_streaming),
|
||||
("pandas-ta", pandas_ta_macd_streaming),
|
||||
("tulipy", tulipy_macd_streaming),
|
||||
("finta", finta_macd_streaming),
|
||||
]),
|
||||
("Bollinger(20, 2.0)", [
|
||||
("Wickra", wickra_bollinger_streaming),
|
||||
("talipp", talipp_bollinger_streaming),
|
||||
("TA-Lib", talib_bollinger_streaming),
|
||||
("pandas-ta", pandas_ta_bollinger_streaming),
|
||||
("tulipy", tulipy_bollinger_streaming),
|
||||
("finta", finta_bollinger_streaming),
|
||||
]),
|
||||
]
|
||||
|
||||
|
||||
def run_batch(prices: np.ndarray, iterations: int) -> List[Sample]:
|
||||
def run_batch(prices: np.ndarray, iterations: int, rounds: int) -> List[Sample]:
|
||||
out: List[Sample] = []
|
||||
for indicator_name, libs in BATCH_INDICATORS:
|
||||
for lib_name, factory in libs:
|
||||
runner = factory(prices)
|
||||
if runner is None:
|
||||
continue
|
||||
secs = time_call(runner, iterations)
|
||||
secs = time_call(runner, iterations, rounds)
|
||||
out.append(Sample(lib_name, indicator_name, "batch", secs, iterations))
|
||||
return out
|
||||
|
||||
@@ -408,6 +741,7 @@ def run_ohlc(
|
||||
low: np.ndarray,
|
||||
close: np.ndarray,
|
||||
iterations: int,
|
||||
rounds: int,
|
||||
) -> List[Sample]:
|
||||
out: List[Sample] = []
|
||||
for indicator_name, libs in OHLC_INDICATORS:
|
||||
@@ -415,12 +749,12 @@ def run_ohlc(
|
||||
runner = factory(high, low, close)
|
||||
if runner is None:
|
||||
continue
|
||||
secs = time_call(runner, iterations)
|
||||
secs = time_call(runner, iterations, rounds)
|
||||
out.append(Sample(lib_name, indicator_name, "batch", secs, iterations))
|
||||
return out
|
||||
|
||||
|
||||
def run_streaming(prices: np.ndarray, streaming_window: int, iterations: int) -> List[Sample]:
|
||||
def run_streaming(prices: np.ndarray, streaming_window: int, iterations: int, rounds: int) -> List[Sample]:
|
||||
out: List[Sample] = []
|
||||
seed = prices[:streaming_window]
|
||||
live = prices[streaming_window:]
|
||||
@@ -431,7 +765,7 @@ def run_streaming(prices: np.ndarray, streaming_window: int, iterations: int) ->
|
||||
runner = factory(seed, live)
|
||||
if runner is None:
|
||||
continue
|
||||
secs = time_call(runner, iterations)
|
||||
secs = time_call(runner, iterations, rounds)
|
||||
sample = Sample(lib_name, indicator_name, "streaming", secs, iterations)
|
||||
sample.iterations = iterations * len(live) # per-tick normalization
|
||||
out.append(sample)
|
||||
@@ -479,6 +813,12 @@ def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None)
|
||||
parser.add_argument("--size", type=int, default=20_000, help="number of prices")
|
||||
parser.add_argument("--iterations", type=int, default=20, help="batch repetitions per timing")
|
||||
parser.add_argument(
|
||||
"--rounds",
|
||||
type=int,
|
||||
default=5,
|
||||
help="batch timing rounds; the median round is reported to damp jitter",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--streaming-window",
|
||||
type=int,
|
||||
@@ -491,6 +831,14 @@ def parse_args() -> argparse.Namespace:
|
||||
default=3,
|
||||
help="repetitions of the streaming workload (each iteration replays all live ticks)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--streaming-rounds",
|
||||
type=int,
|
||||
default=2,
|
||||
help="streaming timing rounds; the median round is reported",
|
||||
)
|
||||
parser.add_argument("--skip-batch", action="store_true", help="skip the batch tables")
|
||||
parser.add_argument("--skip-streaming", action="store_true", help="skip the streaming tables")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -501,6 +849,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__}")
|
||||
@@ -509,11 +858,14 @@ def main() -> None:
|
||||
print(f"Streaming window: {args.streaming_window} seed, {args.size - args.streaming_window} live")
|
||||
|
||||
high, low, close, _ = gen_ohlc(args.size)
|
||||
batch_rows = run_batch(prices, args.iterations)
|
||||
ohlc_rows = run_ohlc(high, low, close, args.iterations)
|
||||
streaming_rows = run_streaming(prices, args.streaming_window, args.streaming_iterations)
|
||||
rows: List[Sample] = []
|
||||
if not args.skip_batch:
|
||||
rows += run_batch(prices, args.iterations, args.rounds)
|
||||
rows += run_ohlc(high, low, close, args.iterations, args.rounds)
|
||||
if not args.skip_streaming:
|
||||
rows += run_streaming(prices, args.streaming_window, args.streaming_iterations, args.streaming_rounds)
|
||||
|
||||
print(render_table(batch_rows + ohlc_rows + streaming_rows))
|
||||
print(render_table(rows))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wickra"
|
||||
version = "0.5.0"
|
||||
version = "0.7.7"
|
||||
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,78 @@ from __future__ import annotations
|
||||
|
||||
from ._wickra import (
|
||||
__version__,
|
||||
M2Measure,
|
||||
UpsidePotentialRatio,
|
||||
GainToPainRatio,
|
||||
CommonSenseRatio,
|
||||
KRatio,
|
||||
TailRatio,
|
||||
MartinRatio,
|
||||
BurkeRatio,
|
||||
SterlingRatio,
|
||||
AUTOCORRPGRAM,
|
||||
EVENBETTERSINE,
|
||||
BANDPASS,
|
||||
ADAPTIVECCI,
|
||||
UNIVERSALOSC,
|
||||
ADAPTIVERSI,
|
||||
CTI,
|
||||
TRENDFLEX,
|
||||
REFLEX,
|
||||
HIGHPASS,
|
||||
SAMPLEENT,
|
||||
SHANNONENT,
|
||||
ROLLINGMINMAX,
|
||||
JARQUEBERA,
|
||||
TimeBasedStop,
|
||||
ProjectionOscillator,
|
||||
VolatilityCone,
|
||||
VolatilityRatio,
|
||||
BipowerVariation,
|
||||
VolatilityOfVolatility,
|
||||
Garch11,
|
||||
EwmaVolatility,
|
||||
PpoHistogram,
|
||||
MacdHistogram,
|
||||
TsfOscillator,
|
||||
Qstick,
|
||||
GatorOscillator,
|
||||
KasePermissionStochastic,
|
||||
WAVE_PM,
|
||||
POLARIZED_FRACTAL_EFFICIENCY,
|
||||
TREND_STRENGTH_INDEX,
|
||||
TTM_TREND,
|
||||
QQE,
|
||||
IMI,
|
||||
ElderRay,
|
||||
DerivativeOscillator,
|
||||
RMI,
|
||||
StochasticCCI,
|
||||
DynamicMomentumIndex,
|
||||
RSX,
|
||||
FisherRSI,
|
||||
DisparityIndex,
|
||||
HoltWinters,
|
||||
GD,
|
||||
AdaptiveLaguerre,
|
||||
MedianMA,
|
||||
EHMA,
|
||||
GMA,
|
||||
SWMA,
|
||||
Expectancy,
|
||||
WinRate,
|
||||
RegimeLabel,
|
||||
JumpIndicator,
|
||||
TrendLabel,
|
||||
HighLowRange,
|
||||
WickRatio,
|
||||
BodySizePct,
|
||||
CloseVsOpen,
|
||||
RollingQuantile,
|
||||
RollingPercentileRank,
|
||||
RollingIqr,
|
||||
RealizedVolatility,
|
||||
LogReturn,
|
||||
TSF,
|
||||
LINEARREG_INTERCEPT,
|
||||
ROCR100,
|
||||
@@ -120,6 +192,11 @@ from ._wickra import (
|
||||
HistoricalVolatility,
|
||||
BollingerBandwidth,
|
||||
PercentB,
|
||||
# Trailing Stops
|
||||
ModifiedMaStop,
|
||||
Nrtr,
|
||||
AtrRatchet,
|
||||
ElderSafeZone,
|
||||
SuperTrend,
|
||||
ChandelierExit,
|
||||
ChandeKrollStop,
|
||||
@@ -131,6 +208,7 @@ from ._wickra import (
|
||||
PercentageTrailingStop,
|
||||
StepTrailingStop,
|
||||
RenkoTrailingStop,
|
||||
KaseDevStop,
|
||||
TrueRange,
|
||||
ChaikinVolatility,
|
||||
RVIVolatility,
|
||||
@@ -139,6 +217,13 @@ from ._wickra import (
|
||||
RogersSatchellVolatility,
|
||||
YangZhangVolatility,
|
||||
# Volume
|
||||
VolumeWeightedMacd,
|
||||
BetterVolume,
|
||||
IntradayIntensity,
|
||||
TradeVolumeIndex,
|
||||
TwiggsMoneyFlow,
|
||||
Wad,
|
||||
VolumeRsi,
|
||||
OBV,
|
||||
VWAP,
|
||||
RollingVWAP,
|
||||
@@ -159,6 +244,7 @@ from ._wickra import (
|
||||
MarketFacilitationIndex,
|
||||
EaseOfMovement,
|
||||
# Statistics
|
||||
KendallTau,
|
||||
SpreadBollingerBands,
|
||||
KalmanHedgeRatio,
|
||||
GrangerCausality,
|
||||
@@ -189,6 +275,7 @@ from ._wickra import (
|
||||
PearsonCorrelation,
|
||||
Beta,
|
||||
PairwiseBeta,
|
||||
SpreadAr1Coefficient,
|
||||
PairSpreadZScore,
|
||||
LeadLagCrossCorrelation,
|
||||
Cointegration,
|
||||
@@ -215,6 +302,10 @@ from ._wickra import (
|
||||
MAMA,
|
||||
FAMA,
|
||||
# Bands & Channels
|
||||
ProjectionBands,
|
||||
MedianChannel,
|
||||
BomarBands,
|
||||
QuartileBands,
|
||||
MaEnvelope,
|
||||
AccelerationBands,
|
||||
StarcBands,
|
||||
@@ -227,6 +318,11 @@ from ._wickra import (
|
||||
FractalChaosBands,
|
||||
VwapStdDevBands,
|
||||
# Pivots & S/R
|
||||
PivotReversal,
|
||||
VolumeWeightedSr,
|
||||
AndrewsPitchfork,
|
||||
MurreyMathLines,
|
||||
CentralPivotRange,
|
||||
ClassicPivots,
|
||||
FibonacciPivots,
|
||||
Camarilla,
|
||||
@@ -235,6 +331,13 @@ from ._wickra import (
|
||||
WilliamsFractals,
|
||||
ZigZag,
|
||||
# DeMark
|
||||
TDMovingAverage,
|
||||
TDDWave,
|
||||
TDTrap,
|
||||
TDPropulsion,
|
||||
TDClopwin,
|
||||
TDClop,
|
||||
TDCamouflage,
|
||||
TDSetup,
|
||||
TDSequential,
|
||||
TDDeMarker,
|
||||
@@ -250,17 +353,40 @@ from ._wickra import (
|
||||
# Ichimoku & alternative charts
|
||||
Ichimoku,
|
||||
HeikinAshi,
|
||||
SmoothedHeikinAshi,
|
||||
HeikinAshiOscillator,
|
||||
ThreeLineBreak,
|
||||
Equivolume,
|
||||
CandleVolume,
|
||||
# Market Profile
|
||||
CompositeProfile,
|
||||
HighLowVolumeNodes,
|
||||
ProfileShape,
|
||||
SinglePrints,
|
||||
NakedPoc,
|
||||
ValueArea,
|
||||
VolumeProfile,
|
||||
TpoProfile,
|
||||
InitialBalance,
|
||||
OpeningRange,
|
||||
# Alt-Chart Bars
|
||||
ThreeLineBreakBars,
|
||||
RunBars,
|
||||
ImbalanceBars,
|
||||
DollarBars,
|
||||
VolumeBars,
|
||||
TickBars,
|
||||
RangeBars,
|
||||
RenkoBars,
|
||||
KagiBars,
|
||||
PointAndFigureBars,
|
||||
# Candlestick patterns
|
||||
TowerTopBottom,
|
||||
HaramiCross,
|
||||
Tristar,
|
||||
FryPanBottom,
|
||||
DumplingTop,
|
||||
NewPriceLines,
|
||||
Doji,
|
||||
Hammer,
|
||||
InvertedHammer,
|
||||
@@ -321,7 +447,37 @@ from ._wickra import (
|
||||
TasukiGap,
|
||||
UniqueThreeRiver,
|
||||
ConcealingBabySwallow,
|
||||
# Chart patterns
|
||||
CupAndHandle,
|
||||
RectangleRange,
|
||||
FlagPennant,
|
||||
Wedge,
|
||||
Triangle,
|
||||
HeadAndShoulders,
|
||||
TripleTopBottom,
|
||||
DoubleTopBottom,
|
||||
# Harmonic patterns
|
||||
ThreeDrives,
|
||||
Cypher,
|
||||
Shark,
|
||||
Crab,
|
||||
Bat,
|
||||
Butterfly,
|
||||
Gartley,
|
||||
Abcd,
|
||||
# Fibonacci
|
||||
FibTimeZones,
|
||||
FibChannel,
|
||||
FibArcs,
|
||||
FibFan,
|
||||
FibConfluence,
|
||||
GoldenPocket,
|
||||
AutoFib,
|
||||
FibProjection,
|
||||
FibExtension,
|
||||
FibRetracement,
|
||||
# Microstructure: order book
|
||||
OrderFlowImbalance,
|
||||
OrderBookImbalanceTop1,
|
||||
OrderBookImbalanceTopN,
|
||||
OrderBookImbalanceFull,
|
||||
@@ -329,16 +485,27 @@ from ._wickra import (
|
||||
QuotedSpread,
|
||||
DepthSlope,
|
||||
# Microstructure: trade flow
|
||||
Pin,
|
||||
TradeSignAutocorrelation,
|
||||
RollMeasure,
|
||||
AmihudIlliquidity,
|
||||
Vpin,
|
||||
SignedVolume,
|
||||
CumulativeVolumeDelta,
|
||||
TradeImbalance,
|
||||
# Microstructure: price impact
|
||||
HasbrouckInformationShare,
|
||||
EffectiveSpread,
|
||||
RealizedSpread,
|
||||
KylesLambda,
|
||||
# Microstructure: footprint
|
||||
Footprint,
|
||||
# Derivatives
|
||||
OpenInterestMomentum,
|
||||
FundingImpliedApr,
|
||||
PerpetualPremiumIndex,
|
||||
OiToVolumeRatio,
|
||||
EstimatedLeverageRatio,
|
||||
FundingRate,
|
||||
FundingRateMean,
|
||||
FundingRateZScore,
|
||||
@@ -385,9 +552,94 @@ from ._wickra import (
|
||||
TreynorRatio,
|
||||
InformationRatio,
|
||||
Alpha,
|
||||
# Seasonality & Session
|
||||
SessionVwap,
|
||||
SessionHighLow,
|
||||
SessionRange,
|
||||
AverageDailyRange,
|
||||
OvernightGap,
|
||||
OvernightIntradayReturn,
|
||||
TurnOfMonth,
|
||||
SeasonalZScore,
|
||||
TimeOfDayReturnProfile,
|
||||
DayOfWeekProfile,
|
||||
IntradayVolatilityProfile,
|
||||
VolumeByTimeProfile,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"M2Measure",
|
||||
"UpsidePotentialRatio",
|
||||
"GainToPainRatio",
|
||||
"CommonSenseRatio",
|
||||
"KRatio",
|
||||
"TailRatio",
|
||||
"MartinRatio",
|
||||
"BurkeRatio",
|
||||
"SterlingRatio",
|
||||
"AUTOCORRPGRAM",
|
||||
"EVENBETTERSINE",
|
||||
"BANDPASS",
|
||||
"ADAPTIVECCI",
|
||||
"UNIVERSALOSC",
|
||||
"ADAPTIVERSI",
|
||||
"CTI",
|
||||
"TRENDFLEX",
|
||||
"REFLEX",
|
||||
"HIGHPASS",
|
||||
"SAMPLEENT",
|
||||
"SHANNONENT",
|
||||
"ROLLINGMINMAX",
|
||||
"JARQUEBERA",
|
||||
"TimeBasedStop",
|
||||
"ProjectionOscillator",
|
||||
"VolatilityCone",
|
||||
"VolatilityRatio",
|
||||
"BipowerVariation",
|
||||
"VolatilityOfVolatility",
|
||||
"Garch11",
|
||||
"EwmaVolatility",
|
||||
"PpoHistogram",
|
||||
"MacdHistogram",
|
||||
"TsfOscillator",
|
||||
"Qstick",
|
||||
"GatorOscillator",
|
||||
"KasePermissionStochastic",
|
||||
"WAVE_PM",
|
||||
"POLARIZED_FRACTAL_EFFICIENCY",
|
||||
"TREND_STRENGTH_INDEX",
|
||||
"TTM_TREND",
|
||||
"QQE",
|
||||
"IMI",
|
||||
"ElderRay",
|
||||
"DerivativeOscillator",
|
||||
"RMI",
|
||||
"StochasticCCI",
|
||||
"DynamicMomentumIndex",
|
||||
"RSX",
|
||||
"FisherRSI",
|
||||
"DisparityIndex",
|
||||
"HoltWinters",
|
||||
"GD",
|
||||
"AdaptiveLaguerre",
|
||||
"MedianMA",
|
||||
"EHMA",
|
||||
"GMA",
|
||||
"SWMA",
|
||||
"Expectancy",
|
||||
"WinRate",
|
||||
"RegimeLabel",
|
||||
"JumpIndicator",
|
||||
"TrendLabel",
|
||||
"HighLowRange",
|
||||
"WickRatio",
|
||||
"BodySizePct",
|
||||
"CloseVsOpen",
|
||||
"RollingQuantile",
|
||||
"RollingPercentileRank",
|
||||
"RollingIqr",
|
||||
"RealizedVolatility",
|
||||
"LogReturn",
|
||||
"TSF",
|
||||
"LINEARREG_INTERCEPT",
|
||||
"ROCR100",
|
||||
@@ -484,6 +736,11 @@ __all__ = [
|
||||
"HistoricalVolatility",
|
||||
"BollingerBandwidth",
|
||||
"PercentB",
|
||||
# Trailing Stops
|
||||
"ModifiedMaStop",
|
||||
"Nrtr",
|
||||
"AtrRatchet",
|
||||
"ElderSafeZone",
|
||||
"SuperTrend",
|
||||
"ChandelierExit",
|
||||
"ChandeKrollStop",
|
||||
@@ -495,6 +752,7 @@ __all__ = [
|
||||
"PercentageTrailingStop",
|
||||
"StepTrailingStop",
|
||||
"RenkoTrailingStop",
|
||||
"KaseDevStop",
|
||||
"TrueRange",
|
||||
"ChaikinVolatility",
|
||||
"RVIVolatility",
|
||||
@@ -503,6 +761,13 @@ __all__ = [
|
||||
"RogersSatchellVolatility",
|
||||
"YangZhangVolatility",
|
||||
# Volume
|
||||
"VolumeWeightedMacd",
|
||||
"BetterVolume",
|
||||
"IntradayIntensity",
|
||||
"TradeVolumeIndex",
|
||||
"TwiggsMoneyFlow",
|
||||
"Wad",
|
||||
"VolumeRsi",
|
||||
"OBV",
|
||||
"VWAP",
|
||||
"RollingVWAP",
|
||||
@@ -523,6 +788,7 @@ __all__ = [
|
||||
"MarketFacilitationIndex",
|
||||
"EaseOfMovement",
|
||||
# Statistics
|
||||
"KendallTau",
|
||||
"SpreadBollingerBands",
|
||||
"KalmanHedgeRatio",
|
||||
"GrangerCausality",
|
||||
@@ -553,6 +819,7 @@ __all__ = [
|
||||
"PearsonCorrelation",
|
||||
"Beta",
|
||||
"PairwiseBeta",
|
||||
"SpreadAr1Coefficient",
|
||||
"PairSpreadZScore",
|
||||
"LeadLagCrossCorrelation",
|
||||
"Cointegration",
|
||||
@@ -579,6 +846,10 @@ __all__ = [
|
||||
"MAMA",
|
||||
"FAMA",
|
||||
# Bands & Channels
|
||||
"ProjectionBands",
|
||||
"MedianChannel",
|
||||
"BomarBands",
|
||||
"QuartileBands",
|
||||
"MaEnvelope",
|
||||
"AccelerationBands",
|
||||
"StarcBands",
|
||||
@@ -591,6 +862,11 @@ __all__ = [
|
||||
"FractalChaosBands",
|
||||
"VwapStdDevBands",
|
||||
# Pivots & S/R
|
||||
"PivotReversal",
|
||||
"VolumeWeightedSr",
|
||||
"AndrewsPitchfork",
|
||||
"MurreyMathLines",
|
||||
"CentralPivotRange",
|
||||
"ClassicPivots",
|
||||
"FibonacciPivots",
|
||||
"Camarilla",
|
||||
@@ -599,6 +875,13 @@ __all__ = [
|
||||
"WilliamsFractals",
|
||||
"ZigZag",
|
||||
# DeMark
|
||||
"TDMovingAverage",
|
||||
"TDDWave",
|
||||
"TDTrap",
|
||||
"TDPropulsion",
|
||||
"TDClopwin",
|
||||
"TDClop",
|
||||
"TDCamouflage",
|
||||
"TDSetup",
|
||||
"TDSequential",
|
||||
"TDDeMarker",
|
||||
@@ -614,17 +897,40 @@ __all__ = [
|
||||
# Ichimoku & alternative charts
|
||||
"Ichimoku",
|
||||
"HeikinAshi",
|
||||
"SmoothedHeikinAshi",
|
||||
"HeikinAshiOscillator",
|
||||
"ThreeLineBreak",
|
||||
"Equivolume",
|
||||
"CandleVolume",
|
||||
# Market Profile
|
||||
"CompositeProfile",
|
||||
"HighLowVolumeNodes",
|
||||
"ProfileShape",
|
||||
"SinglePrints",
|
||||
"NakedPoc",
|
||||
"ValueArea",
|
||||
"VolumeProfile",
|
||||
"TpoProfile",
|
||||
"InitialBalance",
|
||||
"OpeningRange",
|
||||
# Alt-Chart Bars
|
||||
"ThreeLineBreakBars",
|
||||
"RunBars",
|
||||
"ImbalanceBars",
|
||||
"DollarBars",
|
||||
"VolumeBars",
|
||||
"TickBars",
|
||||
"RangeBars",
|
||||
"RenkoBars",
|
||||
"KagiBars",
|
||||
"PointAndFigureBars",
|
||||
# Candlestick patterns
|
||||
"TowerTopBottom",
|
||||
"HaramiCross",
|
||||
"Tristar",
|
||||
"FryPanBottom",
|
||||
"DumplingTop",
|
||||
"NewPriceLines",
|
||||
"Doji",
|
||||
"Hammer",
|
||||
"InvertedHammer",
|
||||
@@ -685,7 +991,37 @@ __all__ = [
|
||||
"TasukiGap",
|
||||
"UniqueThreeRiver",
|
||||
"ConcealingBabySwallow",
|
||||
# Chart patterns
|
||||
"CupAndHandle",
|
||||
"RectangleRange",
|
||||
"FlagPennant",
|
||||
"Wedge",
|
||||
"Triangle",
|
||||
"HeadAndShoulders",
|
||||
"TripleTopBottom",
|
||||
"DoubleTopBottom",
|
||||
# Harmonic patterns
|
||||
"ThreeDrives",
|
||||
"Cypher",
|
||||
"Shark",
|
||||
"Crab",
|
||||
"Bat",
|
||||
"Butterfly",
|
||||
"Gartley",
|
||||
"Abcd",
|
||||
# Fibonacci
|
||||
"FibTimeZones",
|
||||
"FibChannel",
|
||||
"FibArcs",
|
||||
"FibFan",
|
||||
"FibConfluence",
|
||||
"GoldenPocket",
|
||||
"AutoFib",
|
||||
"FibProjection",
|
||||
"FibExtension",
|
||||
"FibRetracement",
|
||||
# Microstructure: order book
|
||||
"OrderFlowImbalance",
|
||||
"OrderBookImbalanceTop1",
|
||||
"OrderBookImbalanceTopN",
|
||||
"OrderBookImbalanceFull",
|
||||
@@ -693,16 +1029,27 @@ __all__ = [
|
||||
"QuotedSpread",
|
||||
"DepthSlope",
|
||||
# Microstructure: trade flow
|
||||
"Pin",
|
||||
"TradeSignAutocorrelation",
|
||||
"RollMeasure",
|
||||
"AmihudIlliquidity",
|
||||
"Vpin",
|
||||
"SignedVolume",
|
||||
"CumulativeVolumeDelta",
|
||||
"TradeImbalance",
|
||||
# Microstructure: price impact
|
||||
"HasbrouckInformationShare",
|
||||
"EffectiveSpread",
|
||||
"RealizedSpread",
|
||||
"KylesLambda",
|
||||
# Microstructure: footprint
|
||||
"Footprint",
|
||||
# Derivatives
|
||||
"OpenInterestMomentum",
|
||||
"FundingImpliedApr",
|
||||
"PerpetualPremiumIndex",
|
||||
"OiToVolumeRatio",
|
||||
"EstimatedLeverageRatio",
|
||||
"FundingRate",
|
||||
"FundingRateMean",
|
||||
"FundingRateZScore",
|
||||
@@ -749,4 +1096,17 @@ __all__ = [
|
||||
"TreynorRatio",
|
||||
"InformationRatio",
|
||||
"Alpha",
|
||||
# Seasonality & Session
|
||||
"SessionVwap",
|
||||
"SessionHighLow",
|
||||
"SessionRange",
|
||||
"AverageDailyRange",
|
||||
"OvernightGap",
|
||||
"OvernightIntradayReturn",
|
||||
"TurnOfMonth",
|
||||
"SeasonalZScore",
|
||||
"TimeOfDayReturnProfile",
|
||||
"DayOfWeekProfile",
|
||||
"IntradayVolatilityProfile",
|
||||
"VolumeByTimeProfile",
|
||||
]
|
||||
|
||||
+9420
-117
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
||||
"""Streaming-vs-batch equivalence and reference values for the Seasonality &
|
||||
Session family.
|
||||
|
||||
These indicators read the full candle (including ``timestamp``), so they have a
|
||||
dedicated test rather than joining the timestamp-less parametrize harness in
|
||||
``test_new_indicators.py``.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import wickra as ta
|
||||
|
||||
HOUR_MS = 3_600_000
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def candle_columns():
|
||||
"""240 hourly candles (10 days) with valid OHLCV and epoch-ms timestamps."""
|
||||
n = 240
|
||||
t = np.arange(n, dtype=np.float64)
|
||||
close = 100.0 + np.sin(t * 0.3) * 5.0 + np.cos(t * 0.1) * 3.0
|
||||
open_ = close + np.sin(t * 0.5) * 0.5
|
||||
high = np.maximum(open_, close) + 1.0
|
||||
low = np.minimum(open_, close) - 1.0
|
||||
volume = 1000.0 + (t % 24) * 50.0
|
||||
timestamp = (np.arange(n, dtype=np.int64)) * HOUR_MS
|
||||
return open_, high, low, close, volume, timestamp
|
||||
|
||||
|
||||
def _candles(cols):
|
||||
open_, high, low, close, volume, timestamp = cols
|
||||
return [
|
||||
(open_[i], high[i], low[i], close[i], volume[i], int(timestamp[i]))
|
||||
for i in range(len(close))
|
||||
]
|
||||
|
||||
|
||||
def _check_scalar(make, cols):
|
||||
candles = _candles(cols)
|
||||
a, b = make(), make()
|
||||
stream = np.array(
|
||||
[np.nan if (v := a.update(c)) is None else v for c in candles],
|
||||
dtype=np.float64,
|
||||
)
|
||||
batch = np.asarray(b.batch(*cols))
|
||||
np.testing.assert_allclose(stream, batch, equal_nan=True, rtol=1e-9, atol=1e-9)
|
||||
|
||||
|
||||
def _check_matrix(make, k, cols):
|
||||
candles = _candles(cols)
|
||||
a, b = make(), make()
|
||||
rows = []
|
||||
for c in candles:
|
||||
out = a.update(c)
|
||||
rows.append(np.full(k, np.nan) if out is None else np.asarray(out, dtype=float))
|
||||
stream = np.vstack(rows)
|
||||
batch = np.asarray(b.batch(*cols))
|
||||
assert batch.shape == (len(candles), k)
|
||||
np.testing.assert_allclose(stream, batch, equal_nan=True, rtol=1e-9, atol=1e-9)
|
||||
|
||||
|
||||
SCALAR = [
|
||||
lambda: ta.SessionVwap(0),
|
||||
lambda: ta.OvernightGap(0),
|
||||
lambda: ta.SeasonalZScore(0),
|
||||
lambda: ta.AverageDailyRange(3, 0),
|
||||
lambda: ta.TurnOfMonth(3, 1, 0),
|
||||
]
|
||||
|
||||
MATRIX = [
|
||||
(lambda: ta.SessionHighLow(0), 2),
|
||||
(lambda: ta.SessionRange(0), 3),
|
||||
(lambda: ta.OvernightIntradayReturn(0), 2),
|
||||
(lambda: ta.TimeOfDayReturnProfile(24, 0), 24),
|
||||
(lambda: ta.IntradayVolatilityProfile(12, 0), 12),
|
||||
(lambda: ta.VolumeByTimeProfile(24, 0), 24),
|
||||
(lambda: ta.DayOfWeekProfile(0), 7),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("make", SCALAR)
|
||||
def test_scalar_streaming_equals_batch(make, candle_columns):
|
||||
_check_scalar(make, candle_columns)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("make,k", MATRIX)
|
||||
def test_matrix_streaming_equals_batch(make, k, candle_columns):
|
||||
_check_matrix(make, k, candle_columns)
|
||||
|
||||
|
||||
def test_session_vwap_reference():
|
||||
vwap = ta.SessionVwap(0)
|
||||
# typical = close for a flat candle; volume-weighted within the day.
|
||||
v1 = vwap.update((100.0, 100.0, 100.0, 100.0, 10.0, 0))
|
||||
assert v1 == pytest.approx(100.0)
|
||||
v2 = vwap.update((110.0, 110.0, 110.0, 110.0, 30.0, HOUR_MS))
|
||||
assert v2 == pytest.approx(107.5)
|
||||
# New day re-anchors.
|
||||
v3 = vwap.update((200.0, 200.0, 200.0, 200.0, 5.0, 24 * HOUR_MS))
|
||||
assert v3 == pytest.approx(200.0)
|
||||
|
||||
|
||||
def test_overnight_gap_reference():
|
||||
gap = ta.OvernightGap(0)
|
||||
assert gap.update((99.0, 101.0, 98.0, 100.0, 1.0, 0)) is None
|
||||
g = gap.update((105.0, 106.0, 104.0, 105.5, 1.0, 24 * HOUR_MS))
|
||||
assert g == pytest.approx(0.05)
|
||||
|
||||
|
||||
def test_session_high_low_reference():
|
||||
shl = ta.SessionHighLow(0)
|
||||
shl.update((100.0, 105.0, 99.0, 101.0, 1.0, 0))
|
||||
out = shl.update((101.0, 108.0, 100.0, 107.0, 1.0, HOUR_MS))
|
||||
assert out == (108.0, 99.0)
|
||||
|
||||
|
||||
def test_volume_by_time_profile_reference():
|
||||
prof = ta.VolumeByTimeProfile(24, 0)
|
||||
out = prof.update((100.0, 100.0, 100.0, 100.0, 500.0, HOUR_MS)) # 01:00 -> bucket 1
|
||||
assert out[1] == pytest.approx(500.0)
|
||||
assert out[0] == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_rejects_zero_buckets():
|
||||
with pytest.raises(ValueError):
|
||||
ta.TimeOfDayReturnProfile(0, 0)
|
||||
|
||||
|
||||
def test_average_daily_range_rejects_zero_period():
|
||||
with pytest.raises(ValueError):
|
||||
ta.AverageDailyRange(0, 0)
|
||||
@@ -9,7 +9,8 @@
|
||||
wickra-wasm` — pure WebAssembly, runs anywhere a modern JS engine does.**
|
||||
|
||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
|
||||
bindings for Python, Node.js and WebAssembly, plus a C ABI for C/C++, C#, Go and any
|
||||
other C-capable language. Every indicator is an O(1)
|
||||
streaming state machine, so live trading dashboards and historical backtests
|
||||
share the exact same implementation. This package is the WebAssembly binding
|
||||
(wasm-bindgen, built for the `web` target); it exposes 200+ streaming-first
|
||||
@@ -54,8 +55,9 @@ the main repository and documentation site:
|
||||
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||
- **Runnable browser examples:** [`examples/wasm/`](https://github.com/wickra-lib/wickra/tree/main/examples/wasm)
|
||||
|
||||
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
|
||||
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
Wickra ships native bindings for Python, Node.js, WebAssembly and Rust, plus a
|
||||
C ABI hub that any C-capable language (C, C++, Go, C#, Java, R) links against —
|
||||
all exposing the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
|
||||
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,699 @@
|
||||
//! 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, 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_nan(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_nan(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_nan(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_macd(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_bands(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| {
|
||||
// Column extraction is outside the timed loop, mirroring kand's arm.
|
||||
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 ind = Atr::new(ATR_PERIOD).unwrap();
|
||||
black_box(ind.batch_atr(&high, &low, &close));
|
||||
});
|
||||
},
|
||||
);
|
||||
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.
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Pure calendar arithmetic for the timestamp-driven seasonality indicators.
|
||||
//!
|
||||
//! Every indicator in the *Seasonality & Session* family keys off the wall-clock
|
||||
//! fields of [`Candle::timestamp`](crate::Candle) (epoch milliseconds), shifted
|
||||
//! by a caller-supplied `utc_offset_minutes` so the buckets line up with the
|
||||
//! relevant exchange session rather than UTC. This module turns an epoch
|
||||
//! millisecond instant into its civil fields using Howard Hinnant's
|
||||
//! branch-light `civil_from_days` algorithm (the same one libc++ ships).
|
||||
//!
|
||||
//! All arithmetic is floor-based (`div_euclid`/`rem_euclid`) so instants before
|
||||
//! the Unix epoch decompose correctly without a dedicated negative-input branch.
|
||||
|
||||
/// Civil (wall-clock) decomposition of an epoch-millisecond instant.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct CivilTime {
|
||||
/// Proleptic Gregorian year (can be negative for instants before year 1).
|
||||
pub(crate) year: i64,
|
||||
/// Month of year, `1..=12`.
|
||||
pub(crate) month: u32,
|
||||
/// Day of month, `1..=31`.
|
||||
pub(crate) day: u32,
|
||||
/// Hour of day, `0..=23`.
|
||||
pub(crate) hour: u32,
|
||||
/// Minute of hour, `0..=59`.
|
||||
pub(crate) minute: u32,
|
||||
/// Day of week with Monday as `0` through Sunday as `6`.
|
||||
pub(crate) weekday: u32,
|
||||
}
|
||||
|
||||
impl CivilTime {
|
||||
/// Minute of day, `0..=1439`.
|
||||
pub(crate) const fn minute_of_day(&self) -> u32 {
|
||||
self.hour * 60 + self.minute
|
||||
}
|
||||
}
|
||||
|
||||
/// Decompose an epoch-millisecond instant into local civil fields.
|
||||
///
|
||||
/// `utc_offset_minutes` shifts the instant before decomposition: `0` yields
|
||||
/// UTC, `-300` U.S. Eastern standard time, `60` Central European time, etc.
|
||||
pub(crate) fn civil_from_timestamp(millis: i64, utc_offset_minutes: i32) -> CivilTime {
|
||||
let local_secs = millis.div_euclid(1000) + i64::from(utc_offset_minutes) * 60;
|
||||
let days = local_secs.div_euclid(86_400);
|
||||
let secs_of_day = local_secs.rem_euclid(86_400);
|
||||
let hour = (secs_of_day / 3600) as u32;
|
||||
let minute = ((secs_of_day % 3600) / 60) as u32;
|
||||
let (year, month, day) = civil_from_days(days);
|
||||
// 1970-01-01 was a Thursday; Monday-based weekday is `(z + 3) mod 7`.
|
||||
let weekday = (days + 3).rem_euclid(7) as u32;
|
||||
CivilTime {
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
weekday,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gregorian `(year, month, day)` for a day count `z` relative to 1970-01-01.
|
||||
///
|
||||
/// Howard Hinnant, "chrono-Compatible Low-Level Date Algorithms".
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097; // [0, 146096]
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
|
||||
let year = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
||||
let mp = (5 * doy + 2) / 153; // [0, 11]
|
||||
let day = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
|
||||
(if month <= 2 { year + 1 } else { year }, month, day)
|
||||
}
|
||||
|
||||
/// Whether `year` is a Gregorian leap year.
|
||||
pub(crate) const fn is_leap(year: i64) -> bool {
|
||||
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
}
|
||||
|
||||
/// Number of days in `month` (`1..=12`) of `year`.
|
||||
pub(crate) const fn days_in_month(year: i64, month: u32) -> u32 {
|
||||
match month {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
_ => {
|
||||
if is_leap(year) {
|
||||
29
|
||||
} else {
|
||||
28
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn epoch_zero_is_thursday_midnight() {
|
||||
let t = civil_from_timestamp(0, 0);
|
||||
assert_eq!(
|
||||
t,
|
||||
CivilTime {
|
||||
year: 1970,
|
||||
month: 1,
|
||||
day: 1,
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
weekday: 3, // Thursday
|
||||
}
|
||||
);
|
||||
assert_eq!(t.minute_of_day(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_utc_instant_mid_year() {
|
||||
// 2021-06-15 13:45:00 UTC = 1623764700 s.
|
||||
let t = civil_from_timestamp(1_623_764_700_000, 0);
|
||||
assert_eq!(t.year, 2021);
|
||||
assert_eq!(t.month, 6);
|
||||
assert_eq!(t.day, 15);
|
||||
assert_eq!(t.hour, 13);
|
||||
assert_eq!(t.minute, 45);
|
||||
assert_eq!(t.weekday, 1); // Tuesday
|
||||
assert_eq!(t.minute_of_day(), 13 * 60 + 45);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_year_2021_is_friday() {
|
||||
// 2021-01-01 00:00:00 UTC = 1609459200 s — exercises the m<=2 year bump.
|
||||
let t = civil_from_timestamp(1_609_459_200_000, 0);
|
||||
assert_eq!((t.year, t.month, t.day), (2021, 1, 1));
|
||||
assert_eq!(t.weekday, 4); // Friday
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positive_offset_rolls_to_next_day() {
|
||||
// 2021-01-01 23:30 UTC shifted +60 min -> 2021-01-02 00:30 local.
|
||||
let base = 1_609_459_200_000 + (23 * 3600 + 30 * 60) * 1000;
|
||||
let t = civil_from_timestamp(base, 60);
|
||||
assert_eq!((t.year, t.month, t.day), (2021, 1, 2));
|
||||
assert_eq!((t.hour, t.minute), (0, 30));
|
||||
assert_eq!(t.weekday, 5); // Saturday
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_offset_rolls_to_previous_day() {
|
||||
// 2021-01-01 00:30 UTC shifted -60 min -> 2020-12-31 23:30 local.
|
||||
let base = 1_609_459_200_000 + 30 * 60 * 1000;
|
||||
let t = civil_from_timestamp(base, -60);
|
||||
assert_eq!((t.year, t.month, t.day), (2020, 12, 31));
|
||||
assert_eq!((t.hour, t.minute), (23, 30));
|
||||
assert_eq!(t.weekday, 3); // Thursday
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_epoch_millis_floor_correctly() {
|
||||
// -1 ms -> 1969-12-31 23:59:59.999, a Wednesday.
|
||||
let t = civil_from_timestamp(-1, 0);
|
||||
assert_eq!((t.year, t.month, t.day), (1969, 12, 31));
|
||||
assert_eq!((t.hour, t.minute), (23, 59));
|
||||
assert_eq!(t.weekday, 2); // Wednesday
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn far_negative_day_count_hits_pre_era_branch() {
|
||||
// A day count below -719468 drives `z + 719468` negative, exercising the
|
||||
// `z - 146096` era branch in civil_from_days (year < 1).
|
||||
let (year, month, day) = civil_from_days(-1_000_000);
|
||||
// -1_000_000 days before 1970-01-01 is 0768-02-04 BCE (proleptic
|
||||
// Gregorian, astronomical year numbering where year 0 exists).
|
||||
assert_eq!((year, month, day), (-768, 2, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leap_year_rules() {
|
||||
assert!(is_leap(2000));
|
||||
assert!(!is_leap(1900));
|
||||
assert!(is_leap(2024));
|
||||
assert!(!is_leap(2023));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn days_in_month_all_cases() {
|
||||
assert_eq!(days_in_month(2023, 1), 31);
|
||||
assert_eq!(days_in_month(2023, 4), 30);
|
||||
assert_eq!(days_in_month(2023, 2), 28);
|
||||
assert_eq!(days_in_month(2024, 2), 29);
|
||||
assert_eq!(days_in_month(2023, 12), 31);
|
||||
assert_eq!(days_in_month(2023, 11), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leap_day_decodes() {
|
||||
// 2024-02-29 12:00 UTC.
|
||||
let secs = 1_709_208_000; // 2024-02-29T12:00:00Z
|
||||
let t = civil_from_timestamp(secs * 1000, 0);
|
||||
assert_eq!((t.year, t.month, t.day), (2024, 2, 29));
|
||||
assert_eq!(t.hour, 12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! AB=CD harmonic pattern.
|
||||
|
||||
use crate::indicators::pattern_swing::{approx_equal, ratios_in, SwingTracker, SWING_THRESHOLD};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// AB=CD — the simplest four-point harmonic pattern: an A→B leg, a B→C
|
||||
/// retracement, and a C→D leg that mirrors A→B in length:
|
||||
///
|
||||
/// ```text
|
||||
/// BC / AB ∈ [0.382, 0.886] (C retraces AB)
|
||||
/// CD / BC ∈ [1.13, 2.618] (D extends BC)
|
||||
/// AB ≈ CD (within 10%) (the two legs are equal — the defining symmetry)
|
||||
/// ```
|
||||
///
|
||||
/// Read from the last four confirmed pivots `A-B-C-D`. Output is `+1.0`
|
||||
/// (bullish, D a swing low), `-1.0` (bearish, D a swing high), or `0.0`; never
|
||||
/// `None`. See `crates/wickra-core/src/indicators/abcd.rs`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Abcd {
|
||||
swing: SwingTracker,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Abcd {
|
||||
/// Construct a new AB=CD detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 4),
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Abcd {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Abcd {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
if !self.swing.update(candle) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 4 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let len = pivots.len();
|
||||
let pa = pivots[len - 4];
|
||||
let pb = pivots[len - 3];
|
||||
let pc = pivots[len - 2];
|
||||
let pd = pivots[len - 1];
|
||||
let ab = (pb.price - pa.price).abs();
|
||||
let bc = (pc.price - pb.price).abs();
|
||||
let cd = (pd.price - pc.price).abs();
|
||||
let ratios_ok = ratios_in(&[(bc / ab, 0.382, 0.886), (cd / bc, 1.13, 2.618)]);
|
||||
let legs_equal = approx_equal(ab, cd, 0.10);
|
||||
if ratios_ok && legs_equal {
|
||||
return Some(if pd.direction < 0.0 { 1.0 } else { -1.0 });
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
5
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Abcd"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn run(pivots: &[f64]) -> Vec<f64> {
|
||||
let mut indicator = Abcd::new();
|
||||
candles_for_pivots(pivots)
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = Abcd::new();
|
||||
assert_eq!(indicator.name(), "Abcd");
|
||||
assert_eq!(indicator.warmup_period(), 5);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!Abcd::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_abcd_is_plus_one() {
|
||||
// AB = 40 down, BC = 24.7 up (0.618), CD = 40 down → AB = CD.
|
||||
let out = run(&[140.0, 100.0, 124.7, 84.7]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_abcd_is_minus_one() {
|
||||
let out = run(&[150.0, 100.0, 140.0, 115.3, 155.3]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unequal_legs_do_not_trigger() {
|
||||
// CD (82) far longer than AB (40) → not an AB=CD.
|
||||
let out = run(&[150.0, 100.0, 140.0, 118.0, 200.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = Abcd::new();
|
||||
for c in candles_for_pivots(&[140.0, 100.0, 124.7]) {
|
||||
let _ = indicator.update(c);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert_eq!(indicator.update(c), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[140.0, 100.0, 124.7, 84.7]);
|
||||
let mut a = Abcd::new();
|
||||
let mut b = Abcd::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Adaptive CCI — a CCI whose centre line adapts to the efficiency ratio.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Adaptive CCI — Lambert's Commodity Channel Index whose centre line is an
|
||||
/// **efficiency-ratio-adaptive** moving average of typical price instead of a
|
||||
/// plain SMA, so it leads in trends and stays calm in chop.
|
||||
///
|
||||
/// ```text
|
||||
/// TP = (high + low + close) / 3
|
||||
/// ER = |TP_t − TP_oldest| / Σ |ΔTP| over the window (0..1)
|
||||
/// sc = ( ER·(2/3 − 2/31) + 2/31 )²
|
||||
/// mean += sc·(TP_t − mean) (adaptive centre, seeded with SMA)
|
||||
/// MD = mean(|TP_i − mean|) over the window (mean deviation)
|
||||
/// CCI = (TP_t − mean) / (0.015 · MD)
|
||||
/// ```
|
||||
///
|
||||
/// The classic [`Cci`](crate::Cci) centres typical price on its simple moving
|
||||
/// average; the lag of that SMA delays the oscillator in fast moves. Replacing it
|
||||
/// with a KAMA-style adaptive average — driven by Kaufman's efficiency ratio —
|
||||
/// lets the centre line accelerate toward price in a clean trend (so the CCI
|
||||
/// reaches its `±100` bands sooner) and slow down in noise (fewer false pokes).
|
||||
/// The `0.015` scaling keeps Lambert's convention that roughly 70–80% of readings
|
||||
/// fall in `[−100, +100]`.
|
||||
///
|
||||
/// The output is unbounded around `0`; a flat window (zero mean deviation) returns
|
||||
/// `0`. The first value lands after `period` inputs; each `update` is O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AdaptiveCci};
|
||||
///
|
||||
/// let mut indicator = AdaptiveCci::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdaptiveCci {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
mean: Option<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl AdaptiveCci {
|
||||
/// Construct an adaptive CCI with the given `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
||||
/// [`Error::InvalidPeriod`] if `period < 2` (the efficiency ratio needs a
|
||||
/// path of at least one step).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "adaptive CCI needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
mean: 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 AdaptiveCci {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let tp = candle.typical_price();
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(tp);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
|
||||
// Efficiency ratio over the window.
|
||||
let oldest = self.window[0];
|
||||
let direction = (tp - oldest).abs();
|
||||
let mut path = 0.0;
|
||||
for pair in self.window.iter().collect::<Vec<_>>().windows(2) {
|
||||
path += (pair[1] - pair[0]).abs();
|
||||
}
|
||||
let er = if path > 0.0 {
|
||||
(direction / path).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let fast = 2.0 / 3.0;
|
||||
let slow = 2.0 / 31.0;
|
||||
let sc = (er * (fast - slow) + slow).powi(2);
|
||||
|
||||
let mean = match self.mean {
|
||||
None => self.window.iter().sum::<f64>() / n,
|
||||
Some(prev) => prev + sc * (tp - prev),
|
||||
};
|
||||
self.mean = Some(mean);
|
||||
|
||||
let md = self.window.iter().map(|&v| (v - mean).abs()).sum::<f64>() / n;
|
||||
let cci = if md > 0.0 {
|
||||
(tp - mean) / (0.015 * md)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.last = Some(cci);
|
||||
Some(cci)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.mean = None;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AdaptiveCci"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(tp: f64) -> Candle {
|
||||
// open=high=low=close=tp -> typical price == tp.
|
||||
Candle::new_unchecked(tp, tp, tp, tp, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_period() {
|
||||
assert!(matches!(AdaptiveCci::new(0), Err(Error::PeriodZero)));
|
||||
assert!(matches!(
|
||||
AdaptiveCci::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let c = AdaptiveCci::new(20).unwrap();
|
||||
assert_eq!(c.period(), 20);
|
||||
assert_eq!(c.warmup_period(), 20);
|
||||
assert_eq!(c.name(), "AdaptiveCci");
|
||||
assert!(!c.is_ready());
|
||||
assert_eq!(c.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut c = AdaptiveCci::new(4).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|i| candle(100.0 + f64::from(i))).collect();
|
||||
let out = c.batch(&candles);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_is_positive() {
|
||||
let mut c = AdaptiveCci::new(10).unwrap();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(100.0 + f64::from(i))).collect();
|
||||
let last = c.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(last > 0.0, "uptrend should give positive CCI, got {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downtrend_is_negative() {
|
||||
let mut c = AdaptiveCci::new(10).unwrap();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(200.0 - f64::from(i))).collect();
|
||||
let last = c.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(last < 0.0, "downtrend should give negative CCI, got {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_is_zero() {
|
||||
let mut c = AdaptiveCci::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..10).map(|_| candle(100.0)).collect();
|
||||
for v in c.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut c = AdaptiveCci::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..20).map(|i| candle(100.0 + f64::from(i))).collect();
|
||||
c.batch(&candles);
|
||||
assert!(c.is_ready());
|
||||
c.reset();
|
||||
assert!(!c.is_ready());
|
||||
assert_eq!(c.value(), None);
|
||||
assert_eq!(c.update(candle(100.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))
|
||||
.collect();
|
||||
let batch = AdaptiveCci::new(20).unwrap().batch(&candles);
|
||||
let mut b = AdaptiveCci::new(20).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
//! Ehlers' Adaptive Laguerre Filter.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// John Ehlers' Adaptive Laguerre Filter — a four-stage Laguerre polynomial
|
||||
/// smoother whose damping factor `gamma` is recomputed every bar from how well
|
||||
/// the filter is currently tracking price.
|
||||
///
|
||||
/// The Laguerre cascade is the same one used by [`LaguerreRsi`](crate::LaguerreRsi),
|
||||
/// but instead of a fixed `gamma` the filter adapts: it measures the recent
|
||||
/// absolute error `|price − filter|`, normalises those errors across a window of
|
||||
/// `period` bars to `[0, 1]`, and takes their **median** as `gamma`. When price
|
||||
/// is tracking smoothly the errors are small and uniform (low `gamma`, fast
|
||||
/// response); when price jumps, the spread of errors widens and `gamma` rises,
|
||||
/// slowing the filter to reject the noise.
|
||||
///
|
||||
/// ```text
|
||||
/// diff_t = |price_t − filter_{t-1}|
|
||||
/// over the last `period` diffs:
|
||||
/// HH = max(diff), LL = min(diff)
|
||||
/// norm_i = (diff_i − LL) / (HH − LL) (0 if HH == LL)
|
||||
/// gamma = median(norm)
|
||||
/// alpha = 1 − gamma
|
||||
/// L0_t = alpha·price_t + gamma·L0_{t-1}
|
||||
/// L1_t = −gamma·L0_t + L0_{t-1} + gamma·L1_{t-1}
|
||||
/// L2_t = −gamma·L1_t + L1_{t-1} + gamma·L2_{t-1}
|
||||
/// L3_t = −gamma·L2_t + L2_{t-1} + gamma·L3_{t-1}
|
||||
/// filter_t = (L0_t + 2·L1_t + 2·L2_t + L3_t) / 6
|
||||
/// ```
|
||||
///
|
||||
/// The output is a smoothed price on the same scale as the input. The first
|
||||
/// emission lands once the error window holds `period` values.
|
||||
///
|
||||
/// Reference: John F. Ehlers, *"Adaptive Laguerre Filter"*, Technical Analysis
|
||||
/// of Stocks & Commodities, 2007.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, AdaptiveLaguerreFilter};
|
||||
///
|
||||
/// let mut indicator = AdaptiveLaguerreFilter::new(13).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdaptiveLaguerreFilter {
|
||||
period: usize,
|
||||
l0: f64,
|
||||
l1: f64,
|
||||
l2: f64,
|
||||
l3: f64,
|
||||
/// Previous filter output, or `None` before the first bar.
|
||||
filter: Option<f64>,
|
||||
/// The last `period` absolute errors `|price − filter|`.
|
||||
diffs: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl AdaptiveLaguerreFilter {
|
||||
/// Construct a new adaptive Laguerre filter with the given error-window
|
||||
/// length.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
l0: 0.0,
|
||||
l1: 0.0,
|
||||
l2: 0.0,
|
||||
l3: 0.0,
|
||||
filter: None,
|
||||
diffs: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured error-window length.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if the error window is full.
|
||||
pub fn value(&self) -> Option<f64> {
|
||||
if self.diffs.len() == self.period {
|
||||
self.filter
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Median of the normalised errors currently in the window. Returns `0.0`
|
||||
/// when every error is equal (e.g. during a constant warmup), which makes
|
||||
/// the filter maximally fast.
|
||||
fn adaptive_gamma(&self) -> f64 {
|
||||
let mut hh = f64::MIN;
|
||||
let mut ll = f64::MAX;
|
||||
for &d in &self.diffs {
|
||||
if d > hh {
|
||||
hh = d;
|
||||
}
|
||||
if d < ll {
|
||||
ll = d;
|
||||
}
|
||||
}
|
||||
let range = hh - ll;
|
||||
if range <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut norm: Vec<f64> = self.diffs.iter().map(|&d| (d - ll) / range).collect();
|
||||
// `total_cmp` never panics — under pathological (e.g. overflowing) fuzz
|
||||
// inputs a normalised error can be non-finite; a total order keeps the
|
||||
// sort sound where `partial_cmp` would return `None`.
|
||||
norm.sort_by(f64::total_cmp);
|
||||
let mid = norm.len() / 2;
|
||||
if norm.len() % 2 == 1 {
|
||||
norm[mid]
|
||||
} else {
|
||||
f64::midpoint(norm[mid - 1], norm[mid])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AdaptiveLaguerreFilter {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.value();
|
||||
}
|
||||
// Absolute tracking error against the previous filter (0 on the first
|
||||
// bar, where there is no prior filter value).
|
||||
let diff = self.filter.map_or(0.0, |f| (price - f).abs());
|
||||
if self.diffs.len() == self.period {
|
||||
self.diffs.pop_front();
|
||||
}
|
||||
self.diffs.push_back(diff);
|
||||
|
||||
let gamma = self.adaptive_gamma();
|
||||
let alpha = 1.0 - gamma;
|
||||
|
||||
let l0 = alpha * price + gamma * self.l0;
|
||||
let l1 = -gamma * l0 + self.l0 + gamma * self.l1;
|
||||
let l2 = -gamma * l1 + self.l1 + gamma * self.l2;
|
||||
let l3 = -gamma * l2 + self.l2 + gamma * self.l3;
|
||||
self.l0 = l0;
|
||||
self.l1 = l1;
|
||||
self.l2 = l2;
|
||||
self.l3 = l3;
|
||||
|
||||
let filter = (l0 + 2.0 * l1 + 2.0 * l2 + l3) / 6.0;
|
||||
self.filter = Some(filter);
|
||||
self.value()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.l0 = 0.0;
|
||||
self.l1 = 0.0;
|
||||
self.l2 = 0.0;
|
||||
self.l3 = 0.0;
|
||||
self.filter = None;
|
||||
self.diffs.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.diffs.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AdaptiveLaguerre"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
/// Independent reference: replays the exact recurrence from scratch.
|
||||
fn naive(prices: &[f64], period: usize) -> Vec<Option<f64>> {
|
||||
let (mut l0, mut l1, mut l2, mut l3) = (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64);
|
||||
let mut filter: Option<f64> = None;
|
||||
let mut diffs: Vec<f64> = Vec::new();
|
||||
let mut out = Vec::with_capacity(prices.len());
|
||||
for &price in prices {
|
||||
let diff = filter.map_or(0.0, |f: f64| (price - f).abs());
|
||||
diffs.push(diff);
|
||||
if diffs.len() > period {
|
||||
diffs.remove(0);
|
||||
}
|
||||
let hh = diffs.iter().copied().fold(f64::MIN, f64::max);
|
||||
let ll = diffs.iter().copied().fold(f64::MAX, f64::min);
|
||||
let range = hh - ll;
|
||||
let gamma = if range <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
let mut norm: Vec<f64> = diffs.iter().map(|&d| (d - ll) / range).collect();
|
||||
norm.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let mid = norm.len() / 2;
|
||||
if norm.len() % 2 == 1 {
|
||||
norm[mid]
|
||||
} else {
|
||||
f64::midpoint(norm[mid - 1], norm[mid])
|
||||
}
|
||||
};
|
||||
let alpha = 1.0 - gamma;
|
||||
let n0 = alpha * price + gamma * l0;
|
||||
let n1 = -gamma * n0 + l0 + gamma * l1;
|
||||
let n2 = -gamma * n1 + l1 + gamma * l2;
|
||||
let n3 = -gamma * n2 + l2 + gamma * l3;
|
||||
l0 = n0;
|
||||
l1 = n1;
|
||||
l2 = n2;
|
||||
l3 = n3;
|
||||
let f = (n0 + 2.0 * n1 + 2.0 * n2 + n3) / 6.0;
|
||||
filter = Some(f);
|
||||
out.push(if diffs.len() == period { Some(f) } else { None });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
AdaptiveLaguerreFilter::new(0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
|
||||
/// + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let alf = AdaptiveLaguerreFilter::new(13).unwrap();
|
||||
assert_eq!(alf.period(), 13);
|
||||
assert_eq!(alf.warmup_period(), 13);
|
||||
assert_eq!(alf.name(), "AdaptiveLaguerre");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_returns_none_until_window_full() {
|
||||
let mut alf = AdaptiveLaguerreFilter::new(3).unwrap();
|
||||
assert_eq!(alf.update(10.0), None);
|
||||
assert_eq!(alf.update(11.0), None);
|
||||
assert!(alf.update(12.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_converges_to_constant() {
|
||||
// Errors are all zero -> gamma 0 -> the 4-stage delay line fills with
|
||||
// the constant and the filter settles on it.
|
||||
let mut alf = AdaptiveLaguerreFilter::new(5).unwrap();
|
||||
let out = alf.batch(&[42.0_f64; 40]);
|
||||
let last = out.iter().rev().flatten().next().unwrap();
|
||||
assert_relative_eq!(*last, 42.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converged_output_stays_within_price_range() {
|
||||
// Once the Laguerre cascade has filled (it cold-starts from zero, so the
|
||||
// first few post-warmup values ramp up toward price), the filter is a
|
||||
// convex blend of recent prices and must stay inside the data range.
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.4).sin() * 10.0)
|
||||
.collect();
|
||||
let lo = prices.iter().copied().fold(f64::MAX, f64::min);
|
||||
let hi = prices.iter().copied().fold(f64::MIN, f64::max);
|
||||
let period = 8;
|
||||
let mut alf = AdaptiveLaguerreFilter::new(period).unwrap();
|
||||
for (i, v) in alf.batch(&prices).into_iter().enumerate() {
|
||||
// Skip the cold-start transient (a few multiples of the window).
|
||||
if i < 4 * period {
|
||||
continue;
|
||||
}
|
||||
let v = v.expect("filter is ready well past warmup");
|
||||
assert!(
|
||||
v >= lo - 1e-6 && v <= hi + 1e-6,
|
||||
"filter out of range at {i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_naive_recurrence() {
|
||||
let prices: Vec<f64> = (0..80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 8.0 + f64::from(i) * 0.1)
|
||||
.collect();
|
||||
let mut alf = AdaptiveLaguerreFilter::new(10).unwrap();
|
||||
let got = alf.batch(&prices);
|
||||
let want = naive(&prices, 10);
|
||||
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
|
||||
assert_eq!(g.is_some(), w.is_some(), "readiness mismatch at {i}");
|
||||
if let (Some(a), Some(b)) = (g, w) {
|
||||
assert_relative_eq!(*a, *b, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut alf = AdaptiveLaguerreFilter::new(5).unwrap();
|
||||
alf.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(alf.is_ready());
|
||||
alf.reset();
|
||||
assert!(!alf.is_ready());
|
||||
assert_eq!(alf.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=50).map(|i| f64::from(i) * 0.7).collect();
|
||||
let mut a = AdaptiveLaguerreFilter::new(7).unwrap();
|
||||
let mut b = AdaptiveLaguerreFilter::new(7).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut alf = AdaptiveLaguerreFilter::new(3).unwrap();
|
||||
alf.update(10.0);
|
||||
alf.update(11.0);
|
||||
let ready = alf.update(12.0).expect("ready after three inputs");
|
||||
assert_eq!(alf.update(f64::NAN), Some(ready));
|
||||
assert_eq!(alf.update(f64::INFINITY), Some(ready));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Adaptive RSI — an RSI whose up/down averaging adapts to the efficiency ratio.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Adaptive RSI — Wilder's RSI in which the smoothing of the average gain and
|
||||
/// average loss **adapts to trendiness** via Kaufman's efficiency ratio, so the
|
||||
/// oscillator reacts fast in a clean move and smooths through chop.
|
||||
///
|
||||
/// ```text
|
||||
/// ER = |price_t − price_{t−period}| / Σ |Δprice| over the window (efficiency ratio, 0..1)
|
||||
/// sc = ( ER·(2/3 − 2/31) + 2/31 )² (KAMA smoothing constant)
|
||||
/// avg_gain += sc·(gain − avg_gain), avg_loss += sc·(loss − avg_loss)
|
||||
/// RSI = 100 · avg_gain / (avg_gain + avg_loss)
|
||||
/// ```
|
||||
///
|
||||
/// A fixed-period [`Rsi`](crate::Rsi) is a compromise: short periods whip in
|
||||
/// ranges, long ones lag in trends. This adaptive form borrows Kaufman's
|
||||
/// efficiency ratio (`directional move / total path`) to set the smoothing each
|
||||
/// bar — near `1` (a clean trend) the averages track gains and losses almost
|
||||
/// immediately; near `0` (noise) they barely move, filtering the chop. The result
|
||||
/// is an RSI that is responsive when it should be and quiet when it should be. It
|
||||
/// is the efficiency-ratio cousin of Ehlers' cycle-adaptive RSI, which instead
|
||||
/// sets the lookback from the measured dominant cycle.
|
||||
///
|
||||
/// Output is bounded in `[0, 100]`; a flat market returns the neutral `50`. The
|
||||
/// first value lands after `period + 1` inputs. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, AdaptiveRsi};
|
||||
///
|
||||
/// let mut indicator = AdaptiveRsi::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdaptiveRsi {
|
||||
period: usize,
|
||||
prices: VecDeque<f64>,
|
||||
abs_changes: VecDeque<f64>,
|
||||
abs_sum: f64,
|
||||
prev: Option<f64>,
|
||||
seed_gain: f64,
|
||||
seed_loss: f64,
|
||||
seed_count: usize,
|
||||
avg_gain: Option<f64>,
|
||||
avg_loss: Option<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl AdaptiveRsi {
|
||||
/// Construct an adaptive RSI with the given efficiency-ratio `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,
|
||||
prices: VecDeque::with_capacity(period + 1),
|
||||
abs_changes: VecDeque::with_capacity(period),
|
||||
abs_sum: 0.0,
|
||||
prev: None,
|
||||
seed_gain: 0.0,
|
||||
seed_loss: 0.0,
|
||||
seed_count: 0,
|
||||
avg_gain: None,
|
||||
avg_loss: None,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured efficiency-ratio 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)
|
||||
}
|
||||
}
|
||||
|
||||
fn efficiency_ratio(&self, price: f64) -> f64 {
|
||||
let oldest = *self.prices.front().expect("window non-empty");
|
||||
let direction = (price - oldest).abs();
|
||||
if self.abs_sum == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(direction / self.abs_sum).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AdaptiveRsi {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let Some(prev) = self.prev else {
|
||||
self.prev = Some(price);
|
||||
self.prices.push_back(price);
|
||||
return None;
|
||||
};
|
||||
let change = price - prev;
|
||||
self.prev = Some(price);
|
||||
let gain = if change > 0.0 { change } else { 0.0 };
|
||||
let loss = if change < 0.0 { -change } else { 0.0 };
|
||||
|
||||
// Maintain the price window (period + 1) and the |Δ| window (period).
|
||||
self.prices.push_back(price);
|
||||
if self.prices.len() > self.period + 1 {
|
||||
self.prices.pop_front();
|
||||
}
|
||||
if self.abs_changes.len() == self.period {
|
||||
self.abs_sum -= self.abs_changes.pop_front().expect("non-empty");
|
||||
}
|
||||
self.abs_changes.push_back(change.abs());
|
||||
self.abs_sum += change.abs();
|
||||
|
||||
if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
|
||||
let er = self.efficiency_ratio(price);
|
||||
let fast = 2.0 / 3.0;
|
||||
let slow = 2.0 / 31.0;
|
||||
let sc = (er * (fast - slow) + slow).powi(2);
|
||||
let new_ag = ag + sc * (gain - ag);
|
||||
let new_al = al + sc * (loss - al);
|
||||
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_gain += gain;
|
||||
self.seed_loss += loss;
|
||||
self.seed_count += 1;
|
||||
if self.seed_count == self.period {
|
||||
let ag = self.seed_gain / self.period as f64;
|
||||
let al = self.seed_loss / self.period as f64;
|
||||
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.prices.clear();
|
||||
self.abs_changes.clear();
|
||||
self.abs_sum = 0.0;
|
||||
self.prev = None;
|
||||
self.seed_gain = 0.0;
|
||||
self.seed_loss = 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 {
|
||||
"AdaptiveRsi"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(AdaptiveRsi::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let r = AdaptiveRsi::new(14).unwrap();
|
||||
assert_eq!(r.period(), 14);
|
||||
assert_eq!(r.warmup_period(), 15);
|
||||
assert_eq!(r.name(), "AdaptiveRsi");
|
||||
assert!(!r.is_ready());
|
||||
assert_eq!(r.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut r = AdaptiveRsi::new(4).unwrap();
|
||||
let out = r.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
|
||||
for v in out.iter().take(4) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_is_one_hundred() {
|
||||
let mut r = AdaptiveRsi::new(5).unwrap();
|
||||
let last = r
|
||||
.batch(&(1..=40).map(f64::from).collect::<Vec<_>>())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_is_neutral() {
|
||||
let mut r = AdaptiveRsi::new(4).unwrap();
|
||||
let last = r.batch(&[7.0; 20]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 50.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut r = AdaptiveRsi::new(14).unwrap();
|
||||
for v in r
|
||||
.batch(
|
||||
&(0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!((0.0..=100.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut r = AdaptiveRsi::new(4).unwrap();
|
||||
let ready = r
|
||||
.batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(r.update(f64::NAN), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut r = AdaptiveRsi::new(4).unwrap();
|
||||
r.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(r.is_ready());
|
||||
r.reset();
|
||||
assert!(!r.is_ready());
|
||||
assert_eq!(r.value(), None);
|
||||
assert_eq!(r.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = AdaptiveRsi::new(14).unwrap().batch(&xs);
|
||||
let mut b = AdaptiveRsi::new(14).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! Amihud Illiquidity — average price impact per unit traded value.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::microstructure::Trade;
|
||||
use crate::traits::Indicator;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Amihud Illiquidity — the average absolute log return per unit of traded
|
||||
/// value over the last `period` trades (Amihud, 2002).
|
||||
///
|
||||
/// ```text
|
||||
/// rₜ = ln(priceₜ / priceₜ₋₁)
|
||||
/// ILLIQₜ = |rₜ| / (priceₜ · sizeₜ) (return per dollar of volume)
|
||||
/// Amihud = mean of ILLIQ over the last `period` trades
|
||||
/// ```
|
||||
///
|
||||
/// Amihud's measure captures how much the price moves for a given amount of
|
||||
/// traded value: a **high** reading means small volume already shifts the price
|
||||
/// a lot (an illiquid, easily-moved market), a **low** reading means it takes
|
||||
/// large volume to move the price (a deep, liquid market). It is the workhorse
|
||||
/// cross-sectional liquidity proxy in market-microstructure research.
|
||||
///
|
||||
/// `Input = Trade`. Trades with zero size carry no traded value and are skipped
|
||||
/// (the ratio is undefined); the last value is returned and state is untouched.
|
||||
/// The first valid trade only seeds the reference price.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Side, Trade, AmihudIlliquidity};
|
||||
///
|
||||
/// let mut amihud = AmihudIlliquidity::new(20).unwrap();
|
||||
/// assert_eq!(amihud.update(Trade::new(100.0, 5.0, Side::Buy, 0).unwrap()), None);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AmihudIlliquidity {
|
||||
period: usize,
|
||||
prev_price: Option<f64>,
|
||||
window: VecDeque<f64>,
|
||||
sum: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl AmihudIlliquidity {
|
||||
/// Construct a new Amihud Illiquidity over the given trade window.
|
||||
///
|
||||
/// # 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_price: None,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AmihudIlliquidity {
|
||||
type Input = Trade;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, trade: Trade) -> Option<f64> {
|
||||
// A zero-size trade has no traded value: the ratio is undefined, so the
|
||||
// trade is skipped without touching the reference price.
|
||||
if trade.size == 0.0 {
|
||||
return self.last;
|
||||
}
|
||||
let Some(prev) = self.prev_price else {
|
||||
self.prev_price = Some(trade.price);
|
||||
return None;
|
||||
};
|
||||
self.prev_price = Some(trade.price);
|
||||
// `prev` and `trade.price` are both finite and strictly positive
|
||||
// (enforced by `Trade::new`), so the log return is well-defined and the
|
||||
// traded value is strictly positive.
|
||||
let ret = (trade.price / prev).ln().abs();
|
||||
let illiq = ret / (trade.price * trade.size);
|
||||
if self.window.len() == self.period {
|
||||
let old = self.window.pop_front().expect("window is non-empty");
|
||||
self.sum -= old;
|
||||
}
|
||||
self.window.push_back(illiq);
|
||||
self.sum += illiq;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let value = self.sum / self.period as f64;
|
||||
self.last = Some(value);
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_price = None;
|
||||
self.window.clear();
|
||||
self.sum = 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 {
|
||||
"AmihudIlliquidity"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::microstructure::Side;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn trade(price: f64, size: f64) -> Trade {
|
||||
Trade::new(price, size, Side::Buy, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(AmihudIlliquidity::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let a = AmihudIlliquidity::new(20).unwrap();
|
||||
assert_eq!(a.period(), 20);
|
||||
assert_eq!(a.warmup_period(), 21);
|
||||
assert_eq!(a.name(), "AmihudIlliquidity");
|
||||
assert!(!a.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_value() {
|
||||
// period 1. Seed at 100, then 101 with size 10:
|
||||
// |ln(101/100)| / (101 * 10).
|
||||
let mut a = AmihudIlliquidity::new(1).unwrap();
|
||||
assert_eq!(a.update(trade(100.0, 10.0)), None);
|
||||
let out = a.update(trade(101.0, 10.0)).unwrap();
|
||||
let expected = (101.0_f64 / 100.0).ln().abs() / (101.0 * 10.0);
|
||||
assert_relative_eq!(out, expected, epsilon = 1e-15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_for_thinner_volume() {
|
||||
// Same price move on smaller volume => larger illiquidity reading.
|
||||
let thin = {
|
||||
let mut a = AmihudIlliquidity::new(1).unwrap();
|
||||
a.update(trade(100.0, 1.0));
|
||||
a.update(trade(101.0, 1.0)).unwrap()
|
||||
};
|
||||
let thick = {
|
||||
let mut a = AmihudIlliquidity::new(1).unwrap();
|
||||
a.update(trade(100.0, 1000.0));
|
||||
a.update(trade(101.0, 1000.0)).unwrap()
|
||||
};
|
||||
assert!(thin > thick, "thin {thin} should exceed thick {thick}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_price_is_zero() {
|
||||
let mut a = AmihudIlliquidity::new(5).unwrap();
|
||||
for v in a.batch(&[trade(100.0, 3.0); 20]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-15);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_zero_size_trades() {
|
||||
let mut a = AmihudIlliquidity::new(1).unwrap();
|
||||
a.update(trade(100.0, 10.0));
|
||||
let baseline = a.update(trade(101.0, 10.0)).unwrap();
|
||||
// A zero-size trade is ignored; the previous reference price is kept.
|
||||
assert_eq!(a.update(trade(200.0, 0.0)), Some(baseline));
|
||||
// The next real trade still references price 101, not 200.
|
||||
let mut control = a.clone();
|
||||
let after = a.update(trade(102.0, 10.0)).unwrap();
|
||||
assert_eq!(control.update(trade(102.0, 10.0)).unwrap(), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_non_negative() {
|
||||
let mut a = AmihudIlliquidity::new(10).unwrap();
|
||||
let trades: Vec<Trade> = (0..100)
|
||||
.map(|i| {
|
||||
trade(
|
||||
100.0 + (f64::from(i) * 0.3).sin() * 5.0,
|
||||
1.0 + f64::from(i % 7),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for v in a.batch(&trades).into_iter().flatten() {
|
||||
assert!(v >= 0.0, "illiquidity must be non-negative, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut a = AmihudIlliquidity::new(5).unwrap();
|
||||
for i in 0..20 {
|
||||
a.update(trade(100.0 + f64::from(i), 2.0));
|
||||
}
|
||||
assert!(a.is_ready());
|
||||
a.reset();
|
||||
assert!(!a.is_ready());
|
||||
assert_eq!(a.update(trade(100.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let trades: Vec<Trade> = (0..80)
|
||||
.map(|i| {
|
||||
trade(
|
||||
100.0 + (f64::from(i) * 0.25).sin() * 4.0,
|
||||
1.0 + f64::from(i % 5),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let batch = AmihudIlliquidity::new(14).unwrap().batch(&trades);
|
||||
let mut b = AmihudIlliquidity::new(14).unwrap();
|
||||
let streamed: Vec<_> = trades.iter().map(|t| b.update(*t)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
//! Andrews Pitchfork — median line and parallels off the last three swing pivots.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`AndrewsPitchfork`]: the three pitchfork lines projected to the
|
||||
/// current bar.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AndrewsPitchforkOutput {
|
||||
/// The median line — from the handle pivot through the midpoint of the other two.
|
||||
pub median: f64,
|
||||
/// The upper parallel (through the higher of the two anchor pivots).
|
||||
pub upper: f64,
|
||||
/// The lower parallel (through the lower of the two anchor pivots).
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
/// A confirmed swing pivot: its bar index and price.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Pivot {
|
||||
index: f64,
|
||||
price: f64,
|
||||
is_high: bool,
|
||||
}
|
||||
|
||||
/// Andrews Pitchfork — Alan Andrews' median-line tool drawn from the three most
|
||||
/// recent **swing pivots**, projected forward to the current bar.
|
||||
///
|
||||
/// ```text
|
||||
/// detect alternating swing highs/lows with a `strength`-bar fractal
|
||||
/// P0 = handle (oldest of the last three), P1, P2 = the next two
|
||||
/// M = midpoint of P1 and P2
|
||||
/// median(t) = P0 + slope·(t − t0) slope = (M − P0) / (M_t − t0)
|
||||
/// upper / lower = median(t) offset by the vertical gap to the higher / lower anchor
|
||||
/// ```
|
||||
///
|
||||
/// The pitchfork projects a "fork" of three parallel lines: a central **median
|
||||
/// line** drawn from a starting pivot through the midpoint of a later swing, plus
|
||||
/// two parallels passing through that swing's high and low. Price tends to
|
||||
/// oscillate around the median line and find support/resistance at the parallels.
|
||||
/// This streaming version detects the pivots automatically with a symmetric
|
||||
/// fractal of half-width `strength` (so each pivot is confirmed `strength` bars
|
||||
/// late) and keeps the three most recent alternating swings.
|
||||
///
|
||||
/// Because it depends on swing structure, readiness is **data-dependent**: the
|
||||
/// first output appears once three alternating pivots have been confirmed.
|
||||
/// `warmup_period` returns the minimum bars to confirm a single pivot. Each
|
||||
/// `update` is O(`strength`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AndrewsPitchfork};
|
||||
///
|
||||
/// let mut indicator = AndrewsPitchfork::new(2).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// let base = 100.0 + (f64::from(i) * 0.4).sin() * 10.0;
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// // A swinging series eventually establishes a pitchfork.
|
||||
/// let _ = last;
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AndrewsPitchfork {
|
||||
strength: usize,
|
||||
window: VecDeque<Candle>,
|
||||
pivots: Vec<Pivot>,
|
||||
count: usize,
|
||||
last: Option<AndrewsPitchforkOutput>,
|
||||
}
|
||||
|
||||
impl AndrewsPitchfork {
|
||||
/// Construct an Andrews Pitchfork with the given fractal `strength` (bars on
|
||||
/// each side of a pivot).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `strength == 0`.
|
||||
pub fn new(strength: usize) -> Result<Self> {
|
||||
if strength == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
strength,
|
||||
window: VecDeque::with_capacity(2 * strength + 1),
|
||||
pivots: Vec::new(),
|
||||
count: 0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured fractal strength.
|
||||
pub const fn strength(&self) -> usize {
|
||||
self.strength
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<AndrewsPitchforkOutput> {
|
||||
self.last
|
||||
}
|
||||
|
||||
/// Record a freshly confirmed pivot, keeping the last three alternating swings.
|
||||
fn record_pivot(&mut self, pivot: Pivot) {
|
||||
if let Some(last) = self.pivots.last_mut() {
|
||||
if last.is_high == pivot.is_high {
|
||||
// Same kind: keep the more extreme one (and its index).
|
||||
let more_extreme = if pivot.is_high {
|
||||
pivot.price > last.price
|
||||
} else {
|
||||
pivot.price < last.price
|
||||
};
|
||||
if more_extreme {
|
||||
*last = pivot;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.pivots.push(pivot);
|
||||
if self.pivots.len() > 3 {
|
||||
self.pivots.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
fn project(&self, tc: f64) -> Option<AndrewsPitchforkOutput> {
|
||||
let [p0, p1, p2] = self.pivots.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
let mid_t = f64::midpoint(p1.index, p2.index);
|
||||
let mid_p = f64::midpoint(p1.price, p2.price);
|
||||
let slope = (mid_p - p0.price) / (mid_t - p0.index);
|
||||
let median = p0.price + slope * (tc - p0.index);
|
||||
let off1 = p1.price - (p0.price + slope * (p1.index - p0.index));
|
||||
let off2 = p2.price - (p0.price + slope * (p2.index - p0.index));
|
||||
Some(AndrewsPitchforkOutput {
|
||||
median,
|
||||
upper: median + off1.max(off2),
|
||||
lower: median + off1.min(off2),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AndrewsPitchfork {
|
||||
type Input = Candle;
|
||||
type Output = AndrewsPitchforkOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<AndrewsPitchforkOutput> {
|
||||
self.count += 1;
|
||||
let span = 2 * self.strength + 1;
|
||||
if self.window.len() == span {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(candle);
|
||||
if self.window.len() == span {
|
||||
let center = self.window[self.strength];
|
||||
let is_high = self
|
||||
.window
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(i, c)| i == self.strength || c.high < center.high);
|
||||
let is_low = self
|
||||
.window
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(i, c)| i == self.strength || c.low > center.low);
|
||||
// Absolute index of the center bar (1-based count minus the right span).
|
||||
let center_index = (self.count - 1 - self.strength) as f64;
|
||||
if is_high && !is_low {
|
||||
self.record_pivot(Pivot {
|
||||
index: center_index,
|
||||
price: center.high,
|
||||
is_high: true,
|
||||
});
|
||||
} else if is_low && !is_high {
|
||||
self.record_pivot(Pivot {
|
||||
index: center_index,
|
||||
price: center.low,
|
||||
is_high: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
let tc = (self.count - 1) as f64;
|
||||
if let Some(out) = self.project(tc) {
|
||||
self.last = Some(out);
|
||||
return Some(out);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.pivots.clear();
|
||||
self.count = 0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2 * self.strength + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AndrewsPitchfork"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(high: f64, low: f64) -> Candle {
|
||||
Candle::new_unchecked(
|
||||
f64::midpoint(high, low),
|
||||
high,
|
||||
low,
|
||||
f64::midpoint(high, low),
|
||||
1_000.0,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
/// A clean zig-zag that prints alternating swing highs and lows.
|
||||
fn zigzag() -> Vec<Candle> {
|
||||
let mut out = Vec::new();
|
||||
for i in 0..120 {
|
||||
let base = 100.0 + (f64::from(i) * 0.5).sin() * 10.0;
|
||||
out.push(c(base + 1.0, base - 1.0));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_strength() {
|
||||
assert!(matches!(AndrewsPitchfork::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let p = AndrewsPitchfork::new(2).unwrap();
|
||||
assert_eq!(p.strength(), 2);
|
||||
assert_eq!(p.warmup_period(), 5);
|
||||
assert_eq!(p.name(), "AndrewsPitchfork");
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_before_three_pivots() {
|
||||
let mut p = AndrewsPitchfork::new(2).unwrap();
|
||||
// Too few bars to ever confirm three alternating pivots.
|
||||
let out = p.batch(&[c(101.0, 99.0), c(102.0, 100.0), c(101.0, 99.0)]);
|
||||
assert!(out.iter().all(Option::is_none));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eventually_emits_on_swings() {
|
||||
let mut p = AndrewsPitchfork::new(2).unwrap();
|
||||
let out = p.batch(&zigzag());
|
||||
assert!(
|
||||
out.iter().any(Option::is_some),
|
||||
"a swinging series should form a pitchfork"
|
||||
);
|
||||
assert!(p.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upper_at_or_above_lower() {
|
||||
let mut p = AndrewsPitchfork::new(2).unwrap();
|
||||
for o in p.batch(&zigzag()).into_iter().flatten() {
|
||||
assert!(
|
||||
o.upper >= o.lower,
|
||||
"upper {} below lower {}",
|
||||
o.upper,
|
||||
o.lower
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut p = AndrewsPitchfork::new(2).unwrap();
|
||||
p.batch(&zigzag());
|
||||
assert!(p.is_ready());
|
||||
p.reset();
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
assert_eq!(p.strength(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_pivot_keeps_more_extreme_same_kind() {
|
||||
let mut p = AndrewsPitchfork::new(2).unwrap();
|
||||
p.record_pivot(Pivot {
|
||||
index: 0.0,
|
||||
price: 100.0,
|
||||
is_high: true,
|
||||
});
|
||||
// A higher high of the same kind replaces the stored one.
|
||||
p.record_pivot(Pivot {
|
||||
index: 1.0,
|
||||
price: 105.0,
|
||||
is_high: true,
|
||||
});
|
||||
assert_eq!(p.pivots.len(), 1);
|
||||
assert_eq!(p.pivots[0].price, 105.0);
|
||||
// A lower high of the same kind is ignored.
|
||||
p.record_pivot(Pivot {
|
||||
index: 2.0,
|
||||
price: 102.0,
|
||||
is_high: true,
|
||||
});
|
||||
assert_eq!(p.pivots.len(), 1);
|
||||
assert_eq!(p.pivots[0].price, 105.0);
|
||||
// A low pivot of the other kind is appended.
|
||||
p.record_pivot(Pivot {
|
||||
index: 3.0,
|
||||
price: 90.0,
|
||||
is_high: false,
|
||||
});
|
||||
assert_eq!(p.pivots.len(), 2);
|
||||
// A lower low of the same kind replaces the stored low.
|
||||
p.record_pivot(Pivot {
|
||||
index: 4.0,
|
||||
price: 85.0,
|
||||
is_high: false,
|
||||
});
|
||||
assert_eq!(p.pivots[1].price, 85.0);
|
||||
// A higher low of the same kind is ignored.
|
||||
p.record_pivot(Pivot {
|
||||
index: 5.0,
|
||||
price: 88.0,
|
||||
is_high: false,
|
||||
});
|
||||
assert_eq!(p.pivots[1].price, 85.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = zigzag();
|
||||
let batch = AndrewsPitchfork::new(2).unwrap().batch(&candles);
|
||||
let mut b = AndrewsPitchfork::new(2).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -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,72 @@ impl Atr {
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.avg
|
||||
if self.seeded {
|
||||
Some(self.avg)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Vectorized batch over raw high/low/close columns: one `f64` per bar
|
||||
/// (`NaN` during warmup). The caller guarantees the three slices are equal
|
||||
/// length and finite with valid OHLC ordering (the binding validates once up
|
||||
/// front); ATR only reads high, low and the previous close.
|
||||
///
|
||||
/// For a fresh indicator long enough to seed (`n >= period`) it runs the
|
||||
/// true-range seed once and then the bare Wilder recurrence in a tight loop —
|
||||
/// no per-bar `Candle` construction/validation, no `Option`, identical
|
||||
/// division at the seed and `mul_add` afterwards, so the result is
|
||||
/// *bit-for-bit* equal to replaying `update` over the same candles. Shorter
|
||||
/// or non-fresh inputs defer to an exact `update` replay.
|
||||
pub fn batch_atr(&mut self, high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
|
||||
let p = self.period;
|
||||
let n = high.len();
|
||||
if self.seeded || !self.seed_buf.is_empty() || self.prev_close.is_some() || n < p {
|
||||
let mut out = vec![f64::NAN; n];
|
||||
for i in 0..n {
|
||||
let candle = Candle::new_unchecked(close[i], high[i], low[i], close[i], 0.0, 0);
|
||||
if let Some(v) = self.update(candle) {
|
||||
out[i] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Warmup `[0, p-1)` is `NaN`; the first ATR is emitted at index `p - 1`.
|
||||
let mut out = vec![f64::NAN; p - 1];
|
||||
out.reserve(n - (p - 1));
|
||||
// Seed: mean of the first `period` true ranges. TR₀ has no previous close.
|
||||
let mut prev_close = close[0];
|
||||
let mut sum_tr = high[0] - low[0];
|
||||
self.seed_buf.push(sum_tr);
|
||||
for i in 1..p {
|
||||
let (h, l) = (high[i], low[i]);
|
||||
let tr = (h - l)
|
||||
.max((h - prev_close).abs())
|
||||
.max((l - prev_close).abs());
|
||||
prev_close = close[i];
|
||||
self.seed_buf.push(tr);
|
||||
sum_tr += tr;
|
||||
}
|
||||
let mut avg = sum_tr / p as f64;
|
||||
out.push(avg);
|
||||
// Steady state: Wilder smoothing, reciprocal hoisted out of the loop.
|
||||
for i in p..n {
|
||||
let (h, l) = (high[i], low[i]);
|
||||
let tr = (h - l)
|
||||
.max((h - prev_close).abs())
|
||||
.max((l - prev_close).abs());
|
||||
prev_close = close[i];
|
||||
avg = avg.mul_add(self.n_minus_1, tr) * self.inv_period;
|
||||
out.push(avg);
|
||||
}
|
||||
|
||||
// Leave state where a full `update` replay would (seeded; seed_buf retained).
|
||||
self.prev_close = Some(prev_close);
|
||||
self.avg = avg;
|
||||
self.seeded = true;
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,17 +146,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 +166,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 +175,7 @@ impl Indicator for Atr {
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.avg.is_some()
|
||||
self.seeded
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
@@ -249,6 +327,81 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn bits_eq(a: &[f64], b: &[f64]) -> bool {
|
||||
a.len() == b.len()
|
||||
&& a.iter()
|
||||
.zip(b)
|
||||
.all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
|
||||
}
|
||||
|
||||
fn atr_replay(period: usize, high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
|
||||
let mut a = Atr::new(period).unwrap();
|
||||
(0..high.len())
|
||||
.map(|i| {
|
||||
let candle = Candle::new_unchecked(close[i], high[i], low[i], close[i], 0.0, 0);
|
||||
a.update(candle).unwrap_or(f64::NAN)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Valid OHLC columns from a wandering base price.
|
||||
fn columns(n: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
|
||||
let base: Vec<f64> = (0..n)
|
||||
.map(|i| (f64::from(u32::try_from(i).unwrap()) * 0.3).sin() * 5.0 + 100.0)
|
||||
.collect();
|
||||
let high = base.iter().map(|b| b + 1.0).collect();
|
||||
let low = base.iter().map(|b| b - 1.0).collect();
|
||||
(high, low, base)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_atr_fast_path_is_bit_identical() {
|
||||
let (high, low, close) = columns(300);
|
||||
let mut atr = Atr::new(14).unwrap();
|
||||
let got = atr.batch_atr(&high, &low, &close);
|
||||
assert!(bits_eq(&got, &atr_replay(14, &high, &low, &close)));
|
||||
let mut ref_atr = Atr::new(14).unwrap();
|
||||
for i in 0..high.len() {
|
||||
ref_atr.update(Candle::new_unchecked(
|
||||
close[i], high[i], low[i], close[i], 0.0, 0,
|
||||
));
|
||||
}
|
||||
let next = Candle::new_unchecked(101.0, 102.0, 100.0, 101.0, 0.0, 0);
|
||||
assert_eq!(atr.update(next), ref_atr.update(next));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_atr_falls_back_when_not_fresh() {
|
||||
let (high, low, close) = columns(40);
|
||||
let mut atr = Atr::new(14).unwrap();
|
||||
atr.update(Candle::new_unchecked(
|
||||
close[0], high[0], low[0], close[0], 0.0, 0,
|
||||
));
|
||||
let mut ref_atr = Atr::new(14).unwrap();
|
||||
ref_atr.update(Candle::new_unchecked(
|
||||
close[0], high[0], low[0], close[0], 0.0, 0,
|
||||
));
|
||||
let want: Vec<f64> = (0..high.len())
|
||||
.map(|i| {
|
||||
ref_atr
|
||||
.update(Candle::new_unchecked(
|
||||
close[i], high[i], low[i], close[i], 0.0, 0,
|
||||
))
|
||||
.unwrap_or(f64::NAN)
|
||||
})
|
||||
.collect();
|
||||
assert!(bits_eq(&atr.batch_atr(&high, &low, &close), &want));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_atr_sub_period_slice_falls_back() {
|
||||
let (high, low, close) = columns(5);
|
||||
let mut atr = Atr::new(14).unwrap();
|
||||
let got = atr.batch_atr(&high, &low, &close);
|
||||
assert!(bits_eq(&got, &atr_replay(14, &high, &low, &close)));
|
||||
assert!(got.iter().all(|x| x.is_nan()));
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
|
||||
#[test]
|
||||
|
||||
@@ -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,176 @@
|
||||
//! Auto-Fibonacci — retracement of the most significant recent swing leg.
|
||||
|
||||
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// How many recent pivots to consider when picking the dominant leg.
|
||||
const PIVOT_HISTORY: usize = 6;
|
||||
|
||||
/// The seven canonical retracement ratios, in ascending order.
|
||||
const RATIOS: [f64; 7] = [0.0, 0.236, 0.382, 0.5, 0.618, 0.786, 1.0];
|
||||
|
||||
/// Auto-Fibonacci retracement levels for the dominant recent swing leg.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AutoFibOutput {
|
||||
/// 0.0% — the dominant leg's end.
|
||||
pub level_0: f64,
|
||||
/// 23.6% retracement.
|
||||
pub level_236: f64,
|
||||
/// 38.2% retracement.
|
||||
pub level_382: f64,
|
||||
/// 50% retracement.
|
||||
pub level_500: f64,
|
||||
/// 61.8% retracement.
|
||||
pub level_618: f64,
|
||||
/// 78.6% retracement.
|
||||
pub level_786: f64,
|
||||
/// 100% — the dominant leg's start.
|
||||
pub level_1000: f64,
|
||||
}
|
||||
|
||||
/// Auto-Fibonacci (`AutoFib`).
|
||||
///
|
||||
/// Like [`crate::indicators::FibRetracement`], but instead of always using the
|
||||
/// immediate last leg it scans the last six confirmed pivots and anchors the
|
||||
/// retracement on the single largest-magnitude leg among them — the dominant
|
||||
/// swing the market is most likely respecting.
|
||||
///
|
||||
/// Parameter-free; construction is infallible. Returns `None` until two pivots
|
||||
/// have confirmed.
|
||||
///
|
||||
/// See `crates/wickra-core/src/indicators/auto_fib.rs`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutoFib {
|
||||
swing: SwingTracker,
|
||||
}
|
||||
|
||||
impl AutoFib {
|
||||
/// Construct a new Auto-Fibonacci tracker.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, PIVOT_HISTORY),
|
||||
}
|
||||
}
|
||||
|
||||
fn levels(&self) -> Option<AutoFibOutput> {
|
||||
let dominant = self.swing.pivots().windows(2).max_by(|x, y| {
|
||||
(x[0].price - x[1].price)
|
||||
.abs()
|
||||
.total_cmp(&(y[0].price - y[1].price).abs())
|
||||
})?;
|
||||
let (start, end) = (dominant[0].price, dominant[1].price);
|
||||
let level = |r: f64| end + r * (start - end);
|
||||
Some(AutoFibOutput {
|
||||
level_0: level(RATIOS[0]),
|
||||
level_236: level(RATIOS[1]),
|
||||
level_382: level(RATIOS[2]),
|
||||
level_500: level(RATIOS[3]),
|
||||
level_618: level(RATIOS[4]),
|
||||
level_786: level(RATIOS[5]),
|
||||
level_1000: level(RATIOS[6]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AutoFib {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AutoFib {
|
||||
type Input = Candle;
|
||||
type Output = AutoFibOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<AutoFibOutput> {
|
||||
self.swing.update(candle);
|
||||
self.levels()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.swing.pivots().len() >= 2
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AutoFib"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = AutoFib::new();
|
||||
assert_eq!(indicator.name(), "AutoFib");
|
||||
assert_eq!(indicator.warmup_period(), 2);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!AutoFib::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_output_before_two_pivots() {
|
||||
let mut indicator = AutoFib::new();
|
||||
let outputs: Vec<_> = candles_for_pivots(&[120.0])
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c))
|
||||
.collect();
|
||||
assert!(outputs.iter().all(Option::is_none));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchors_on_the_largest_leg() {
|
||||
// Pivots: 130 -> 120 (small, 10) -> 220 (large, 100) -> 200 (small, 20).
|
||||
// The dominant leg is 120 -> 220; its retracement spans [120, 220].
|
||||
let mut indicator = AutoFib::new();
|
||||
let mut last = None;
|
||||
for candle in candles_for_pivots(&[130.0, 120.0, 220.0, 200.0]) {
|
||||
last = indicator.update(candle);
|
||||
}
|
||||
let v = last.unwrap();
|
||||
assert!(indicator.is_ready());
|
||||
// Largest leg 120 -> 220: 0% on 220 (end), 100% on 120 (start).
|
||||
assert_relative_eq!(v.level_0, 220.0);
|
||||
assert_relative_eq!(v.level_1000, 120.0);
|
||||
assert_relative_eq!(v.level_500, 170.0);
|
||||
assert_relative_eq!(v.level_618, 220.0 + 0.618 * (120.0 - 220.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = AutoFib::new();
|
||||
for candle in candles_for_pivots(&[200.0, 100.0]) {
|
||||
let _ = indicator.update(candle);
|
||||
}
|
||||
assert!(indicator.is_ready());
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert!(indicator.update(c).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[130.0, 120.0, 220.0, 200.0]);
|
||||
let mut a = AutoFib::new();
|
||||
let mut b = AutoFib::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//! Ehlers Autocorrelation Periodogram — estimates the dominant market cycle.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::roofing_filter::RoofingFilter;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Number of bars averaged into each lagged correlation (Ehlers' `AvgLength`).
|
||||
const AVG_LENGTH: usize = 3;
|
||||
|
||||
/// Ehlers' **Autocorrelation Periodogram** — measures the **dominant cycle
|
||||
/// period** of the market by correlating a roofing-filtered price with lagged
|
||||
/// copies of itself and reading off the spectral peak.
|
||||
///
|
||||
/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 8):
|
||||
///
|
||||
/// ```text
|
||||
/// Filt = RoofingFilter(price) (detrend + denoise)
|
||||
/// Corr[lag] = Pearson( Filt[0..AvgLength], Filt[lag..lag+AvgLength] ) for lag = 0..max_period
|
||||
/// for each candidate period:
|
||||
/// power[period] = (Σ Corr[N]·cos(2πN/period))² + (Σ Corr[N]·sin(2πN/period))²
|
||||
/// R[period] = 0.2·power[period] + 0.8·R[period]_{t−1} (EMA across time)
|
||||
/// normalise by a decaying max, then
|
||||
/// DominantCycle = centre-of-gravity of periods whose normalised power ≥ 0.5
|
||||
/// ```
|
||||
///
|
||||
/// The autocorrelation function emphasises whatever cycle is actually present and
|
||||
/// suppresses noise; transforming it into a periodogram and taking the
|
||||
/// power-weighted centre of gravity gives a smooth, robust estimate of the
|
||||
/// dominant cycle length. That cycle is the key input for every *adaptive*
|
||||
/// indicator (adaptive RSI/CCI/stochastic) — set their lookback from it. The
|
||||
/// output is a period in bars within `[min_period, max_period]`.
|
||||
///
|
||||
/// The first value lands after `max_period + AvgLength` inputs. Each `update` is
|
||||
/// O(`max_period²`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, AutocorrelationPeriodogram};
|
||||
/// use std::f64::consts::TAU;
|
||||
///
|
||||
/// let mut indicator = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..200 {
|
||||
/// last = indicator.update(100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutocorrelationPeriodogram {
|
||||
min_period: usize,
|
||||
max_period: usize,
|
||||
roof: RoofingFilter,
|
||||
buffer: VecDeque<f64>,
|
||||
r: Vec<f64>,
|
||||
max_pwr: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl AutocorrelationPeriodogram {
|
||||
/// Construct an autocorrelation periodogram searching cycles in
|
||||
/// `[min_period, max_period]`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if either period is `0`, or
|
||||
/// [`Error::InvalidPeriod`] if `min_period < AvgLength + 1` or
|
||||
/// `max_period <= min_period`.
|
||||
pub fn new(min_period: usize, max_period: usize) -> Result<Self> {
|
||||
if min_period == 0 || max_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if min_period < AVG_LENGTH + 1 || max_period <= min_period {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "autocorrelation periodogram needs AvgLength < min_period < max_period",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
min_period,
|
||||
max_period,
|
||||
roof: RoofingFilter::new(10, max_period)?,
|
||||
buffer: VecDeque::with_capacity(max_period + AVG_LENGTH),
|
||||
r: vec![0.0; max_period + 1],
|
||||
max_pwr: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(min_period, max_period)`.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.min_period, self.max_period)
|
||||
}
|
||||
|
||||
/// Current dominant-cycle estimate if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
/// Pearson correlation of the `AvgLength`-deep slices offset by `lag`.
|
||||
/// `buffer` is newest-last; `filt(k)` is the value `k` bars back.
|
||||
fn correlation(&self, lag: usize) -> f64 {
|
||||
let len = self.buffer.len();
|
||||
let filt = |k: usize| self.buffer[len - 1 - k];
|
||||
let m = AVG_LENGTH as f64;
|
||||
let (mut sx, mut sy, mut sxx, mut syy, mut sxy) = (0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
for count in 0..AVG_LENGTH {
|
||||
let x = filt(count);
|
||||
let y = filt(lag + count);
|
||||
sx += x;
|
||||
sy += y;
|
||||
sxx += x * x;
|
||||
syy += y * y;
|
||||
sxy += x * y;
|
||||
}
|
||||
let denom = (m * sxx - sx * sx) * (m * syy - sy * sy);
|
||||
if denom > 0.0 {
|
||||
(m * sxy - sx * sy) / denom.sqrt()
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AutocorrelationPeriodogram {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let filt = self.roof.update(price)?;
|
||||
if self.buffer.len() == self.max_period + AVG_LENGTH {
|
||||
self.buffer.pop_front();
|
||||
}
|
||||
self.buffer.push_back(filt);
|
||||
if self.buffer.len() < self.max_period + AVG_LENGTH {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Autocorrelation across lags.
|
||||
let mut corr = vec![0.0; self.max_period + 1];
|
||||
for (lag, c) in corr.iter_mut().enumerate() {
|
||||
*c = self.correlation(lag);
|
||||
}
|
||||
|
||||
// Periodogram: spectral power for each candidate period, EMA'd over time.
|
||||
self.max_pwr *= 0.995;
|
||||
for period in self.min_period..=self.max_period {
|
||||
let mut cosine = 0.0;
|
||||
let mut sine = 0.0;
|
||||
for (n, &cn) in corr
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take(self.max_period + 1)
|
||||
.skip(AVG_LENGTH)
|
||||
{
|
||||
let angle = TAU * n as f64 / period as f64;
|
||||
cosine += cn * angle.cos();
|
||||
sine += cn * angle.sin();
|
||||
}
|
||||
let power = cosine * cosine + sine * sine;
|
||||
self.r[period] = 0.2 * power + 0.8 * self.r[period];
|
||||
if self.r[period] > self.max_pwr {
|
||||
self.max_pwr = self.r[period];
|
||||
}
|
||||
}
|
||||
|
||||
// Power-weighted centre of gravity of the strong periods.
|
||||
let mut spx = 0.0;
|
||||
let mut sp = 0.0;
|
||||
for period in self.min_period..=self.max_period {
|
||||
let pwr = if self.max_pwr > 0.0 {
|
||||
self.r[period] / self.max_pwr
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if pwr >= 0.5 {
|
||||
spx += period as f64 * pwr;
|
||||
sp += pwr;
|
||||
}
|
||||
}
|
||||
let dominant = if sp > 0.0 {
|
||||
(spx / sp).clamp(self.min_period as f64, self.max_period as f64)
|
||||
} else {
|
||||
self.min_period as f64
|
||||
};
|
||||
self.last = Some(dominant);
|
||||
Some(dominant)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.roof.reset();
|
||||
self.buffer.clear();
|
||||
self.r.iter_mut().for_each(|x| *x = 0.0);
|
||||
self.max_pwr = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.max_period + AVG_LENGTH
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AutocorrelationPeriodogram"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_periods() {
|
||||
assert!(matches!(
|
||||
AutocorrelationPeriodogram::new(0, 48),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
AutocorrelationPeriodogram::new(3, 48),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
AutocorrelationPeriodogram::new(48, 10),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let p = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
assert_eq!(p.periods(), (10, 48));
|
||||
assert_eq!(p.warmup_period(), 51);
|
||||
assert_eq!(p.name(), "AutocorrelationPeriodogram");
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut p = AutocorrelationPeriodogram::new(8, 20).unwrap();
|
||||
let xs: Vec<f64> = (0..40)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 12.0).sin() * 5.0)
|
||||
.collect();
|
||||
let out = p.batch(&xs);
|
||||
let warmup = p.warmup_period(); // 23
|
||||
assert_eq!(warmup, 23);
|
||||
for v in out.iter().take(warmup - 1) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[warmup - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_within_period_band() {
|
||||
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
let xs: Vec<f64> = (0..400)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
for v in p.batch(&xs).into_iter().flatten() {
|
||||
assert!((10.0..=48.0).contains(&v), "cycle out of band: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_injected_cycle() {
|
||||
// A clean 20-bar sine: the dominant cycle estimate should settle near 20.
|
||||
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
let xs: Vec<f64> = (0..600)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
let last = p.batch(&xs).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
(last - 20.0).abs() < 6.0,
|
||||
"expected ~20-bar cycle, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
p.batch(
|
||||
&(0..80)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
let before = p.value();
|
||||
assert_eq!(p.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
p.batch(
|
||||
&(0..120)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(p.is_ready());
|
||||
p.reset();
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
let batch = AutocorrelationPeriodogram::new(10, 48).unwrap().batch(&xs);
|
||||
let mut b = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_input_falls_back_to_min_period() {
|
||||
// Constant input has zero variance, so every lag correlation is
|
||||
// degenerate (denom <= 0), the max power is zero and no period clears
|
||||
// the 0.5 threshold -> the dominant cycle defaults to `min_period`.
|
||||
let flat = [100.0_f64; 200];
|
||||
let last = AutocorrelationPeriodogram::new(10, 48)
|
||||
.unwrap()
|
||||
.batch(&flat)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(last, 10.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Average Daily Range (ADR) — the mean high-minus-low range of the last `period`
|
||||
//! completed calendar-day sessions.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::calendar::civil_from_timestamp;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Average Daily Range over the last `period` completed sessions.
|
||||
///
|
||||
/// The indicator tracks the running high / low of the current session (the
|
||||
/// wall-clock day of [`Candle::timestamp`](crate::Candle) shifted by
|
||||
/// `utc_offset_minutes`). When a new day begins, the just-finished session's
|
||||
/// range (`high - low`) joins a rolling window of the last `period` completed
|
||||
/// days, and the reported value is their mean. The current, still-forming day is
|
||||
/// excluded until it closes. No value is produced until the first session
|
||||
/// completes.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AverageDailyRange};
|
||||
///
|
||||
/// let hour = 3_600_000;
|
||||
/// let mut adr = AverageDailyRange::new(2, 0).unwrap();
|
||||
/// // Day 1 range 10 (high 110, low 100) — still forming, so None.
|
||||
/// assert!(adr.update(Candle::new(105.0, 110.0, 100.0, 108.0, 1.0, 0).unwrap()).is_none());
|
||||
/// // First bar of day 2 closes day 1: ADR = 10.
|
||||
/// let v = adr.update(Candle::new(108.0, 112.0, 106.0, 109.0, 1.0, 24 * hour).unwrap()).unwrap();
|
||||
/// assert!((v - 10.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AverageDailyRange {
|
||||
period: usize,
|
||||
utc_offset_minutes: i32,
|
||||
day_key: Option<(i64, u32, u32)>,
|
||||
cur_high: f64,
|
||||
cur_low: f64,
|
||||
completed: VecDeque<f64>,
|
||||
sum: f64,
|
||||
}
|
||||
|
||||
impl AverageDailyRange {
|
||||
/// Construct an ADR indicator over `period` completed days.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize, utc_offset_minutes: i32) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
utc_offset_minutes,
|
||||
day_key: None,
|
||||
cur_high: f64::NEG_INFINITY,
|
||||
cur_low: f64::INFINITY,
|
||||
completed: VecDeque::with_capacity(period),
|
||||
sum: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, utc_offset_minutes)`.
|
||||
pub const fn params(&self) -> (usize, i32) {
|
||||
(self.period, self.utc_offset_minutes)
|
||||
}
|
||||
|
||||
/// Most recent ADR if at least one session has completed.
|
||||
pub fn value(&self) -> Option<f64> {
|
||||
if self.completed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.sum / self.completed.len() as f64)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AverageDailyRange {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
|
||||
let key = (civil.year, civil.month, civil.day);
|
||||
match self.day_key {
|
||||
Some(prev) if prev == key => {
|
||||
if candle.high > self.cur_high {
|
||||
self.cur_high = candle.high;
|
||||
}
|
||||
if candle.low < self.cur_low {
|
||||
self.cur_low = candle.low;
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
let range = self.cur_high - self.cur_low;
|
||||
self.completed.push_back(range);
|
||||
self.sum += range;
|
||||
if self.completed.len() > self.period {
|
||||
self.sum -= self
|
||||
.completed
|
||||
.pop_front()
|
||||
.expect("len > period implies a front element");
|
||||
}
|
||||
self.day_key = Some(key);
|
||||
self.cur_high = candle.high;
|
||||
self.cur_low = candle.low;
|
||||
}
|
||||
None => {
|
||||
self.day_key = Some(key);
|
||||
self.cur_high = candle.high;
|
||||
self.cur_low = candle.low;
|
||||
}
|
||||
}
|
||||
self.value()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.day_key = None;
|
||||
self.cur_high = f64::NEG_INFINITY;
|
||||
self.cur_low = f64::INFINITY;
|
||||
self.completed.clear();
|
||||
self.sum = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
!self.completed.is_empty()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AverageDailyRange"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
const HOUR: i64 = 3_600_000;
|
||||
const DAY: i64 = 24 * HOUR;
|
||||
|
||||
fn c(high: f64, low: f64, ts: i64) -> Candle {
|
||||
let mid = f64::midpoint(high, low);
|
||||
Candle::new(mid, high, low, mid, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
AverageDailyRange::new(0, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_and_accessors() {
|
||||
let adr = AverageDailyRange::new(5, -60).unwrap();
|
||||
assert_eq!(adr.params(), (5, -60));
|
||||
assert_eq!(adr.name(), "AverageDailyRange");
|
||||
assert_eq!(adr.warmup_period(), 5);
|
||||
assert!(!adr.is_ready());
|
||||
assert!(adr.value().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn averages_completed_day_ranges() {
|
||||
let mut adr = AverageDailyRange::new(3, 0).unwrap();
|
||||
// Day 1: range 10.
|
||||
assert!(adr.update(c(110.0, 100.0, 0)).is_none());
|
||||
assert!(adr.update(c(108.0, 104.0, HOUR)).is_none());
|
||||
// Day 2 opens -> day 1 (range 10) completes.
|
||||
let v = adr.update(c(120.0, 110.0, DAY)).unwrap();
|
||||
assert_relative_eq!(v, 10.0);
|
||||
assert!(adr.is_ready());
|
||||
// Day 3 opens -> day 2 (range 10) completes: mean of [10, 10] = 10.
|
||||
let v = adr.update(c(130.0, 100.0, 2 * DAY)).unwrap();
|
||||
assert_relative_eq!(v, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolls_off_oldest_day_beyond_period() {
|
||||
let mut adr = AverageDailyRange::new(2, 0).unwrap();
|
||||
adr.update(c(110.0, 100.0, 0)); // day 1 range 10
|
||||
let v = adr.update(c(125.0, 110.0, DAY)).unwrap(); // close day 1 -> [10]
|
||||
assert_relative_eq!(v, 10.0);
|
||||
// Close day 2 (range 125-110=15) -> window [10, 15], mean 12.5.
|
||||
let v = adr.update(c(130.0, 110.0, 2 * DAY)).unwrap();
|
||||
assert_relative_eq!(v, 12.5);
|
||||
// Close day 3 (range 130-110=20) -> window [15, 20], oldest (10) rolled off.
|
||||
let v = adr.update(c(140.0, 138.0, 3 * DAY)).unwrap();
|
||||
assert_relative_eq!(v, 17.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut adr = AverageDailyRange::new(2, 0).unwrap();
|
||||
adr.update(c(110.0, 100.0, 0));
|
||||
adr.update(c(120.0, 110.0, DAY));
|
||||
adr.reset();
|
||||
assert!(!adr.is_ready());
|
||||
assert!(adr.value().is_none());
|
||||
assert!(adr.update(c(50.0, 40.0, 2 * DAY)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
c(
|
||||
110.0 + f64::from(i % 5),
|
||||
100.0 - f64::from(i % 3),
|
||||
i64::from(i) * 6 * HOUR,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut a = AverageDailyRange::new(4, 0).unwrap();
|
||||
let mut b = AverageDailyRange::new(4, 0).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Ehlers Bandpass Filter — isolates the cyclic component around a target period.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' Bandpass Filter — a two-pole resonator that passes the cyclic content
|
||||
/// around a target `period` and rejects both the trend (low frequencies) and the
|
||||
/// noise (high frequencies).
|
||||
///
|
||||
/// From John Ehlers' *Cycle Analytics for Traders* (2013):
|
||||
///
|
||||
/// ```text
|
||||
/// beta = cos(2π / period)
|
||||
/// gamma = 1 / cos(4π · bandwidth / period)
|
||||
/// alpha = gamma − sqrt(gamma² − 1)
|
||||
/// BP_t = 0.5·(1 − alpha)·(price_t − price_{t−2})
|
||||
/// + beta·(1 + alpha)·BP_{t−1} − alpha·BP_{t−2}
|
||||
/// ```
|
||||
///
|
||||
/// `bandwidth` (a fraction, typically `0.3`) sets how wide a band of periods is
|
||||
/// admitted: narrow bandwidth gives a sharp, ringing resonator tuned tightly to
|
||||
/// `period`; wide bandwidth lets more of the spectrum through. The output is a
|
||||
/// zero-mean oscillator — it swings symmetrically around `0`, peaking when the
|
||||
/// dominant cycle aligns with `period`. It is the building block for cycle-phase
|
||||
/// and cycle-amplitude work.
|
||||
///
|
||||
/// The recursion needs two prior prices and two prior outputs; until then it emits
|
||||
/// `0` (Ehlers' initial condition), so `warmup_period` is `1` and a value is
|
||||
/// produced every bar. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, BandpassFilter};
|
||||
///
|
||||
/// let mut indicator = BandpassFilter::new(20, 0.3).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 BandpassFilter {
|
||||
period: usize,
|
||||
bandwidth: f64,
|
||||
beta: f64,
|
||||
alpha: f64,
|
||||
prev_price_1: Option<f64>,
|
||||
prev_price_2: Option<f64>,
|
||||
bp1: f64,
|
||||
bp2: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl BandpassFilter {
|
||||
/// Construct a bandpass filter tuned to `period` with the given `bandwidth`
|
||||
/// fraction.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
||||
/// [`Error::InvalidParameter`] if `bandwidth` is not finite or outside
|
||||
/// `(0, 1)`.
|
||||
pub fn new(period: usize, bandwidth: f64) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if !bandwidth.is_finite() || bandwidth <= 0.0 || bandwidth >= 1.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "bandpass bandwidth must be in (0, 1)",
|
||||
});
|
||||
}
|
||||
let period_f = period as f64;
|
||||
let beta = (2.0 * PI / period_f).cos();
|
||||
let gamma = 1.0 / (4.0 * PI * bandwidth / period_f).cos();
|
||||
let alpha = gamma - (gamma * gamma - 1.0).sqrt();
|
||||
Ok(Self {
|
||||
period,
|
||||
bandwidth,
|
||||
beta,
|
||||
alpha,
|
||||
prev_price_1: None,
|
||||
prev_price_2: None,
|
||||
bp1: 0.0,
|
||||
bp2: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, bandwidth)`.
|
||||
pub const fn params(&self) -> (usize, f64) {
|
||||
(self.period, self.bandwidth)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for BandpassFilter {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let bp = match self.prev_price_2 {
|
||||
Some(p2) => {
|
||||
0.5 * (1.0 - self.alpha) * (price - p2) + self.beta * (1.0 + self.alpha) * self.bp1
|
||||
- self.alpha * self.bp2
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
self.prev_price_2 = self.prev_price_1;
|
||||
self.prev_price_1 = Some(price);
|
||||
self.bp2 = self.bp1;
|
||||
self.bp1 = bp;
|
||||
self.last = Some(bp);
|
||||
Some(bp)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_price_1 = None;
|
||||
self.prev_price_2 = None;
|
||||
self.bp1 = 0.0;
|
||||
self.bp2 = 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 {
|
||||
"BandpassFilter"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(matches!(
|
||||
BandpassFilter::new(0, 0.3),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
BandpassFilter::new(20, 0.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
BandpassFilter::new(20, 1.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
assert_eq!(bp.params(), (20, 0.3));
|
||||
assert_eq!(bp.warmup_period(), 1);
|
||||
assert_eq!(bp.name(), "BandpassFilter");
|
||||
assert!(!bp.is_ready());
|
||||
assert_eq!(bp.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bars_are_zero() {
|
||||
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
assert_eq!(bp.update(100.0), Some(0.0));
|
||||
assert_eq!(bp.update(101.0), Some(0.0));
|
||||
// From the third bar the recursion is active.
|
||||
assert!(bp.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_input_stays_zero() {
|
||||
// A trend-free flat input has no cyclic content -> output stays 0.
|
||||
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
for v in bp.batch(&[50.0; 200]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyclic_input_oscillates_around_zero() {
|
||||
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
let xs: Vec<f64> = (0..400)
|
||||
.map(|i| 100.0 + (2.0 * PI * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
let out: Vec<f64> = bp.batch(&xs).into_iter().flatten().skip(100).collect();
|
||||
let mean = out.iter().sum::<f64>() / out.len() as f64;
|
||||
assert!(
|
||||
mean.abs() < 1.0,
|
||||
"bandpass output should be ~zero mean, got {mean}"
|
||||
);
|
||||
assert!(out.iter().any(|&v| v > 0.5));
|
||||
assert!(out.iter().any(|&v| v < -0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
bp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
|
||||
let before = bp.value();
|
||||
assert_eq!(bp.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
bp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(bp.is_ready());
|
||||
bp.reset();
|
||||
assert!(!bp.is_ready());
|
||||
assert_eq!(bp.update(100.0), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = BandpassFilter::new(20, 0.3).unwrap().batch(&xs);
|
||||
let mut b = BandpassFilter::new(20, 0.3).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Bat harmonic pattern.
|
||||
|
||||
use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Bat — a 5-point (X-A-B-C-D) harmonic pattern with a shallow B and a deep
|
||||
/// `0.886` D completion:
|
||||
///
|
||||
/// ```text
|
||||
/// AB / XA ∈ [0.382, 0.50]
|
||||
/// BC / AB ∈ [0.382, 0.886]
|
||||
/// CD / BC ∈ [1.618, 2.618]
|
||||
/// AD / XA ∈ [0.84, 0.93] (≈ 0.886 — the defining D completion)
|
||||
/// ```
|
||||
///
|
||||
/// Output is `+1.0` (bullish, D a swing low), `-1.0` (bearish, D a swing high),
|
||||
/// or `0.0`; never `None`. See `crates/wickra-core/src/indicators/bat.rs`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Bat {
|
||||
swing: SwingTracker,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Bat {
|
||||
/// Construct a new Bat detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 5),
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Bat {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Bat {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
if !self.swing.update(candle) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 5 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let p = xabcd(pivots);
|
||||
let xa = (p.a - p.x).abs();
|
||||
let ab = (p.b - p.a).abs();
|
||||
let bc = (p.c - p.b).abs();
|
||||
let cd = (p.d - p.c).abs();
|
||||
let ad = (p.d - p.a).abs();
|
||||
let matched = ratios_in(&[
|
||||
(ab / xa, 0.382, 0.50),
|
||||
(bc / ab, 0.382, 0.886),
|
||||
(cd / bc, 1.618, 2.618),
|
||||
(ad / xa, 0.84, 0.93),
|
||||
]);
|
||||
if matched {
|
||||
return Some(if p.bullish { 1.0 } else { -1.0 });
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
6
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Bat"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn run(pivots: &[f64]) -> Vec<f64> {
|
||||
let mut indicator = Bat::new();
|
||||
candles_for_pivots(pivots)
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = Bat::new();
|
||||
assert_eq!(indicator.name(), "Bat");
|
||||
assert_eq!(indicator.warmup_period(), 6);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!Bat::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_bat_is_plus_one() {
|
||||
let out = run(&[150.0, 100.0, 140.0, 122.0, 137.0, 104.56]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_bat_is_minus_one() {
|
||||
let out = run(&[150.0, 110.0, 128.0, 113.0, 145.44]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_ratio_does_not_trigger() {
|
||||
let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = Bat::new();
|
||||
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
|
||||
let _ = indicator.update(c);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert_eq!(indicator.update(c), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 122.0, 137.0, 104.56]);
|
||||
let mut a = Bat::new();
|
||||
let mut b = Bat::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Body Size Percent — candle body as a fraction of its range.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Body Size Percent — the absolute body as a fraction of the bar's range.
|
||||
///
|
||||
/// ```text
|
||||
/// BodySizePct = |close − open| / (high − low)
|
||||
/// ```
|
||||
///
|
||||
/// The result lives in `[0, 1]`: `1` is a full-bodied marubozu (the bar opened
|
||||
/// at one extreme and closed at the other, no wicks), `0` a doji (open equals
|
||||
/// close, the bar is all wick). It is the *unsigned* magnitude companion to
|
||||
/// [`BalanceOfPower`](crate::BalanceOfPower) — where `BoP` keeps the direction,
|
||||
/// this keeps only the conviction, which is exactly what candlestick body /
|
||||
/// range filters key on. A zero-range bar carries no information and yields `0`.
|
||||
///
|
||||
/// This is a stateless per-bar transform: every candle produces one value.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, BodySizePct};
|
||||
///
|
||||
/// let mut indicator = BodySizePct::new();
|
||||
/// // body |12 - 10| = 2, range 14 - 10 = 4 -> 0.5.
|
||||
/// let c = Candle::new(10.0, 14.0, 10.0, 12.0, 10.0, 0).unwrap();
|
||||
/// assert!((indicator.update(c).unwrap() - 0.5).abs() < 1e-12);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct BodySizePct {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl BodySizePct {
|
||||
/// Construct a new Body Size Percent transform.
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for BodySizePct {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let range = candle.high - candle.low;
|
||||
let out = if range == 0.0 {
|
||||
// A zero-range bar has no body proportion to speak of.
|
||||
0.0
|
||||
} else {
|
||||
(candle.close - candle.open).abs() / range
|
||||
};
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"BodySizePct"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// |12 - 10| / (14 - 10) = 0.5.
|
||||
let mut bsp = BodySizePct::new();
|
||||
assert_relative_eq!(
|
||||
bsp.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap(),
|
||||
0.5,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marubozu_is_one() {
|
||||
// open == low, close == high, no wicks -> full body -> 1.
|
||||
let mut bsp = BodySizePct::new();
|
||||
assert_relative_eq!(
|
||||
bsp.update(candle(9.0, 11.0, 9.0, 11.0, 0)).unwrap(),
|
||||
1.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doji_is_zero() {
|
||||
// open == close with a real range -> body 0.
|
||||
let mut bsp = BodySizePct::new();
|
||||
assert_relative_eq!(
|
||||
bsp.update(candle(10.0, 12.0, 8.0, 10.0, 0)).unwrap(),
|
||||
0.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsigned_regardless_of_direction() {
|
||||
// A red bar with the same body magnitude reads identically to a green one.
|
||||
let mut bsp = BodySizePct::new();
|
||||
let green = bsp.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap();
|
||||
let mut bsp2 = BodySizePct::new();
|
||||
let red = bsp2.update(candle(12.0, 14.0, 10.0, 10.0, 0)).unwrap();
|
||||
assert_relative_eq!(green, red, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_bar_yields_zero() {
|
||||
let mut bsp = BodySizePct::new();
|
||||
assert_relative_eq!(
|
||||
bsp.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(),
|
||||
0.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stays_within_unit_range() {
|
||||
let candles: Vec<Candle> = (0..100)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (f64::from(i) * 0.2).sin() * 8.0;
|
||||
let close = mid + (f64::from(i) * 0.5).cos() * 2.0;
|
||||
candle(mid, mid + 3.0, mid - 3.0, close, i64::from(i))
|
||||
})
|
||||
.collect();
|
||||
let mut bsp = BodySizePct::new();
|
||||
for v in bsp.batch(&candles).into_iter().flatten() {
|
||||
assert!((0.0..=1.0).contains(&v), "BodySizePct {v} outside [0, 1]");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_metadata() {
|
||||
let bsp = BodySizePct::new();
|
||||
assert_eq!(bsp.name(), "BodySizePct");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_from_first_candle() {
|
||||
let mut bsp = BodySizePct::new();
|
||||
assert_eq!(bsp.warmup_period(), 1);
|
||||
assert!(!bsp.is_ready());
|
||||
assert!(bsp.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
|
||||
assert!(bsp.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut bsp = BodySizePct::new();
|
||||
bsp.update(candle(10.0, 11.0, 9.0, 10.0, 0));
|
||||
assert!(bsp.is_ready());
|
||||
bsp.reset();
|
||||
assert!(!bsp.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
candle(base, base + 2.0, base - 2.0, base + 1.0, i64::from(i))
|
||||
})
|
||||
.collect();
|
||||
let mut a = BodySizePct::new();
|
||||
let mut b = BodySizePct::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -102,8 +108,84 @@ impl BollingerBands {
|
||||
self.multiplier
|
||||
}
|
||||
|
||||
/// Vectorized flat batch for bindings: returns `n * 4` values laid out as
|
||||
/// `[upper, middle, lower, stddev]` per input row, warmup rows all `NaN`.
|
||||
///
|
||||
/// For a fresh, all-finite slice it inlines `update`'s rolling `sum`/`sum_sq`
|
||||
/// and drift-reseed, writing the four band values directly instead of an
|
||||
/// `Option<BollingerOutput>` per element. Same add/subtract order, same reseed
|
||||
/// cadence, same variance/`sqrt` math — so it is *bit-for-bit* equal to
|
||||
/// replaying `update`, including the long-stream drift bound. Any other state,
|
||||
/// or a non-finite element, defers to the exact `update` replay.
|
||||
///
|
||||
/// This is a *separate* entry point from the trait [`batch`](crate::BatchExt::batch),
|
||||
/// which returns `Vec<Option<BollingerOutput>>`; only the bindings, which want
|
||||
/// a flat `f64` buffer, call this.
|
||||
pub fn batch_bands(&mut self, inputs: &[f64]) -> Vec<f64> {
|
||||
let p = self.period;
|
||||
let n = inputs.len();
|
||||
if self.count != 0
|
||||
|| self.updates_since_recompute != 0
|
||||
|| !inputs.iter().all(|x| x.is_finite())
|
||||
{
|
||||
// Slow path: exact replay of `update` into the flat layout.
|
||||
let mut out = vec![f64::NAN; n * 4];
|
||||
for (i, &x) in inputs.iter().enumerate() {
|
||||
if let Some(o) = self.update(x) {
|
||||
out[i * 4] = o.upper;
|
||||
out[i * 4 + 1] = o.middle;
|
||||
out[i * 4 + 2] = o.lower;
|
||||
out[i * 4 + 3] = o.stddev;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
let p_f64 = p as f64;
|
||||
let mult = self.multiplier;
|
||||
// Pre-sized output: warmup rows stay NaN, ready rows are written in place
|
||||
// by index — no per-row `push` length/capacity check.
|
||||
let mut out = vec![f64::NAN; n * 4];
|
||||
for (i, &x) in inputs.iter().enumerate() {
|
||||
if self.count == p {
|
||||
let old = self.buf[self.head];
|
||||
self.sum -= old;
|
||||
self.sum_sq -= old * old;
|
||||
self.buf[self.head] = x;
|
||||
self.sum += x;
|
||||
self.sum_sq += x * x;
|
||||
} else {
|
||||
self.buf[self.head] = x;
|
||||
self.sum += x;
|
||||
self.sum_sq += x * x;
|
||||
self.count += 1;
|
||||
}
|
||||
self.head += 1;
|
||||
if self.head == p {
|
||||
self.head = 0;
|
||||
}
|
||||
self.updates_since_recompute += 1;
|
||||
if self.updates_since_recompute >= RECOMPUTE_EVERY * p {
|
||||
let chronological = self.buf[self.head..].iter().chain(&self.buf[..self.head]);
|
||||
self.sum = chronological.clone().copied().sum();
|
||||
self.sum_sq = chronological.map(|&v| v * v).sum();
|
||||
self.updates_since_recompute = 0;
|
||||
}
|
||||
if self.count == p {
|
||||
let mean = self.sum / p_f64;
|
||||
let stddev = (self.sum_sq / p_f64 - mean * mean).max(0.0).sqrt();
|
||||
let band = mult * stddev;
|
||||
out[i * 4] = mean + band;
|
||||
out[i * 4 + 1] = mean;
|
||||
out[i * 4 + 2] = mean - band;
|
||||
out[i * 4 + 3] = stddev;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
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 +211,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 +253,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 +266,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!(
|
||||
@@ -332,6 +428,79 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn bits_eq(a: &[f64], b: &[f64]) -> bool {
|
||||
a.len() == b.len()
|
||||
&& a.iter()
|
||||
.zip(b)
|
||||
.all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
|
||||
}
|
||||
|
||||
/// Flat `n*4` `[upper, middle, lower, stddev]` replay of `update`.
|
||||
fn bb_replay(period: usize, mult: f64, series: &[f64]) -> Vec<f64> {
|
||||
let mut bb = BollingerBands::new(period, mult).unwrap();
|
||||
let mut out = Vec::with_capacity(series.len() * 4);
|
||||
for &x in series {
|
||||
match bb.update(x) {
|
||||
Some(o) => out.extend_from_slice(&[o.upper, o.middle, o.lower, o.stddev]),
|
||||
None => out.extend_from_slice(&[f64::NAN; 4]),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_bands_fast_path_is_bit_identical_with_reseed() {
|
||||
// > 16*period inputs so the drift-reseed branch fires inside batch_bands.
|
||||
let series: Vec<f64> = (0..500)
|
||||
.map(|i| (f64::from(i) * 0.2).sin() * 10.0 + 50.0)
|
||||
.collect();
|
||||
let mut bb = BollingerBands::new(20, 2.0).unwrap();
|
||||
let got = bb.batch_bands(&series);
|
||||
assert!(bits_eq(&got, &bb_replay(20, 2.0, &series)));
|
||||
// State continues identically.
|
||||
let mut ref_bb = BollingerBands::new(20, 2.0).unwrap();
|
||||
for &x in &series {
|
||||
ref_bb.update(x);
|
||||
}
|
||||
assert_eq!(bb.update(55.0), ref_bb.update(55.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_bands_falls_back_on_non_finite() {
|
||||
let series = [1.0, 2.0, 3.0, f64::NAN, 5.0, 6.0, 7.0];
|
||||
let mut bb = BollingerBands::new(3, 2.0).unwrap();
|
||||
assert!(bits_eq(
|
||||
&bb.batch_bands(&series),
|
||||
&bb_replay(3, 2.0, &series)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_bands_falls_back_when_not_fresh() {
|
||||
let mut bb = BollingerBands::new(3, 2.0).unwrap();
|
||||
bb.update(99.0);
|
||||
let series = [1.0, 2.0, 3.0, 4.0];
|
||||
let mut ref_bb = BollingerBands::new(3, 2.0).unwrap();
|
||||
ref_bb.update(99.0);
|
||||
let mut want = Vec::new();
|
||||
for &x in &series {
|
||||
match ref_bb.update(x) {
|
||||
Some(o) => want.extend_from_slice(&[o.upper, o.middle, o.lower, o.stddev]),
|
||||
None => want.extend_from_slice(&[f64::NAN; 4]),
|
||||
}
|
||||
}
|
||||
assert!(bits_eq(&bb.batch_bands(&series), &want));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_bands_sub_period_slice_is_all_nan() {
|
||||
let series = [1.0, 2.0, 3.0];
|
||||
let mut bb = BollingerBands::new(10, 2.0).unwrap();
|
||||
let got = bb.batch_bands(&series);
|
||||
assert!(bits_eq(&got, &bb_replay(10, 2.0, &series)));
|
||||
assert!(got.iter().all(|x| x.is_nan()) && got.len() == 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut bb = BollingerBands::new(5, 2.0).unwrap();
|
||||
|
||||
@@ -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,218 @@
|
||||
//! Burke Ratio — mean return over the square root of the summed squared drawdowns.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Burke Ratio over a trailing window of `period` returns.
|
||||
///
|
||||
/// ```text
|
||||
/// equity_t = Π_{i<=t} (1 + return_i) (compounded curve)
|
||||
/// peak_t = max_{s<=t} equity_s
|
||||
/// dd_t = (peak_t − equity_t) / peak_t (fractional drawdown, >= 0)
|
||||
/// Burke = mean(returns) / sqrt( Σ dd_t² )
|
||||
/// ```
|
||||
///
|
||||
/// The Burke Ratio divides the average per-period return by the **Euclidean norm of
|
||||
/// the drawdowns** — the square root of the *sum* of squared drawdowns. Squaring
|
||||
/// penalises deep drawdowns far more than shallow ones, and summing (rather than
|
||||
/// averaging) means the denominator grows with both the depth and the *number* of
|
||||
/// drawdowns. This makes Burke the most outlier-sensitive of Wickra's three
|
||||
/// drawdown ratios: where the [`SterlingRatio`](crate::SterlingRatio) averages raw
|
||||
/// drawdowns and shrugs off a single crater, Burke makes that crater dominate.
|
||||
/// The [`MartinRatio`](crate::MartinRatio) sits between them with a root-*mean*
|
||||
/// square of percentage drawdowns. A window that never draws down has a zero
|
||||
/// denominator and the indicator reports `0.0`.
|
||||
///
|
||||
/// The first value lands after `period` returns; each `update` rebuilds the equity
|
||||
/// curve over the window (O(period)), which is O(1) in the length of the overall
|
||||
/// series.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, BurkeRatio};
|
||||
///
|
||||
/// let mut indicator = BurkeRatio::new(12).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..24 {
|
||||
/// last = indicator.update((f64::from(i) * 0.5).sin() * 0.05);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BurkeRatio {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl BurkeRatio {
|
||||
/// Construct a Burke Ratio over `period` returns.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "burke ratio needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of returns.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn compute(&self) -> f64 {
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let length = self.window.len() as f64;
|
||||
let mut sum_return = 0.0;
|
||||
let mut sum_drawdown_sq = 0.0;
|
||||
let mut equity = 1.0;
|
||||
let mut peak: f64 = 1.0;
|
||||
for ret in &self.window {
|
||||
sum_return += *ret;
|
||||
equity *= 1.0 + *ret;
|
||||
peak = peak.max(equity);
|
||||
let drawdown = (peak - equity) / peak;
|
||||
sum_drawdown_sq += drawdown * drawdown;
|
||||
}
|
||||
let denom = sum_drawdown_sq.sqrt();
|
||||
if denom > 0.0 {
|
||||
(sum_return / length) / denom
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for BurkeRatio {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||
if !ret.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(ret);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
Some(self.compute())
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"BurkeRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_less_than_two() {
|
||||
assert!(matches!(
|
||||
BurkeRatio::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let br = BurkeRatio::new(12).unwrap();
|
||||
assert_eq!(br.period(), 12);
|
||||
assert_eq!(br.warmup_period(), 12);
|
||||
assert_eq!(br.name(), "BurkeRatio");
|
||||
assert!(!br.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// returns [0.1, -0.1, 0.1]: dd = [0, 0.1, 0.01].
|
||||
// Σ dd² = 0.01 + 0.0001 = 0.0101; denom = sqrt(0.0101).
|
||||
// Burke = (0.1/3) / sqrt(0.0101).
|
||||
let mut br = BurkeRatio::new(3).unwrap();
|
||||
let out = br.batch(&[0.1, -0.1, 0.1]);
|
||||
let expected = (0.1_f64 / 3.0) / (0.0101_f64).sqrt();
|
||||
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_drawdown_is_zero() {
|
||||
let mut br = BurkeRatio::new(3).unwrap();
|
||||
let last = br
|
||||
.batch(&[0.01, 0.02, 0.03])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn losing_window_is_negative() {
|
||||
let mut br = BurkeRatio::new(3).unwrap();
|
||||
let last = br
|
||||
.batch(&[-0.05, -0.02, -0.03])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(last < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut br = BurkeRatio::new(3).unwrap();
|
||||
assert_eq!(br.update(0.1), None);
|
||||
assert_eq!(br.update(f64::NAN), None);
|
||||
assert_eq!(br.update(-0.1), None);
|
||||
assert!(br.update(0.1).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut br = BurkeRatio::new(3).unwrap();
|
||||
br.batch(&[0.1, -0.1, 0.1]);
|
||||
assert!(br.is_ready());
|
||||
br.reset();
|
||||
assert!(!br.is_ready());
|
||||
assert_eq!(br.update(0.1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let rets: Vec<f64> = (0..60)
|
||||
.map(|i| (f64::from(i) * 0.25).sin() * 0.05)
|
||||
.collect();
|
||||
let batch = BurkeRatio::new(12).unwrap().batch(&rets);
|
||||
let mut streamer = BurkeRatio::new(12).unwrap();
|
||||
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user