Compare commits

...

9 Commits

Author SHA1 Message Date
kingchenc fc6f3d80c2 release: bump 0.6.1 -> 0.6.2 (#194)
Version bump for the **v0.6.2** release shipping the **B7 Trailing Stops** family (#193): 434 -> 440 indicators.

Bumps workspace + Python/Node/WASM package versions, lockfiles and CHANGELOG (cuts the `[0.6.2]` section). No code changes.
2026-06-07 01:45:23 +02:00
kingchenc 2991ba411d Add B7 Trailing Stops family (6 indicators) (#193)
Adds the **Trailing Stops** family deepening (B7), six new indicators (434 -> 440):

- **KaseDevStop** — Cynthia Kase's volatility stop on the standard deviation of the two-bar true range.
- **ElderSafeZone** — Alexander Elder's stop offset by a multiple of average market noise.
- **AtrRatchet** — Kaufman ATR ratchet that tightens its multiple by a per-bar increment.
- **Nrtr** — Nick Rypock Trailing Reverse (percentage band).
- **TimeBasedStop** — exits after a fixed number of bars (scalar fraction of elapsed life).
- **ModifiedMaStop** — moving-average based trailing stop.

("Wilder Volatility System" is intentionally skipped — it overlaps the existing VoltyStop/Psar/SarExt.)

Each takes Candle input; the five band/structure stops emit a {value, direction} struct, TimeBasedStop a scalar. Wired across core, Python/Node/WASM bindings, fuzz target and tests. Verified locally: 3560 core lib + 398 doc tests, clippy clean, 515 node tests, 852 pytest, counter 440.
2026-06-07 01:32:15 +02:00
kingchenc 83e34c6f71 release: bump 0.6.0 -> 0.6.1 (#192)
Version bump for the v0.6.1 release shipping the B6 Bands & Channels family (#191): 429 -> 434 indicators.
2026-06-07 00:13:58 +02:00
kingchenc 67feec598a feat(indicators): add B6 Bands & Channels family (429 -> 434) (#191)
Adds the **B6 Bands & Channels** batch — five band/channel indicators, taking the catalogue from 429 to 434.

| Indicator | Input → Output | Summary |
|-----------|----------------|---------|
| `ProjectionBands` | `Candle` → `{upper,middle,lower}` | Widner forward-projected high/low regression envelope |
| `ProjectionOscillator` | `Candle` → `f64` | Close position inside the projection bands, scaled 0..100 |
| `QuartileBands` | `f64` → `{upper,middle,lower}` | Rolling 25th/50th/75th-percentile (Q1/median/Q3) envelope |
| `BomarBands` | `f64` → `{upper,middle,lower}` | Adaptive percentage bands containing a target coverage fraction of recent closes |
| `MedianChannel` | `f64` → `{upper,middle,lower}` | Robust median ± multiplier·MAD envelope |

All five are distinct from existing indicators (verified against the core: `LinRegChannel`, `StandardErrorBands`, `Donchian`, `RollingQuantile`, `HurstChannel`). SKIPped from the roadmap: Price Channel (= `Donchian`) and Moving-Average Channel (≈ `MaEnvelope`/`Keltner`).

Each ships:
- Core indicator with per-branch unit tests (Codecov-strict 100%).
- python / node / wasm bindings (struct outputs are hand-written; `ProjectionOscillator` uses the generated candle→f64 path).
- Fuzz drives, python (`MULTI`/`SCALAR_MULTI`/`CANDLE_SCALAR`) + node test registries, README + CHANGELOG counter bump to 434.

Verified locally: `cargo fmt`, `clippy --workspace --all-targets --all-features -D warnings` (clean), `wickra-core` 3511 lib + 392 doc tests, node 509 tests, pytest 840.
2026-06-07 00:03:02 +02:00
kingchenc 3dfbc415c5 release: bump 0.5.9 -> 0.6.0 (#190)
Version bump for the **v0.6.0** release (ships the B5 Volatility & Bands batch, #189 — 423 -> 429 indicators).

Bumps version strings across Cargo workspace, pyproject, node package.json + 6 platform packages, both package-lock.json files, and Cargo.lock; CHANGELOG `[Unreleased]` -> `[0.6.0]`. No code changes.

Versioning note: patch never reaches two digits — `0.5.9` rolls to the next minor `0.6.0` (not 0.5.10).
2026-06-06 22:48:56 +02:00
kingchenc 6b8c6a0e7f B5 volatility & bands batch (423 -> 429) (#189)
Adds six **Volatility & Bands** indicators (Part B5 of the expansion roadmap), 423 → 429.

| Indicator | Input → Output | Summary |
|-----------|----------------|---------|
| `EwmaVolatility` | `f64` → `f64` | RiskMetrics exponentially-weighted volatility (λ decay) |
| `Garch11` | `f64` → `f64` | GARCH(1,1) conditional volatility with a long-run-variance anchor |
| `BipowerVariation` | `f64` → `f64` | jump-robust realized bipower variation (π/2 · Σ\|rₜ\|\|rₜ₋₁\|) |
| `VolatilityRatio` | `Candle` → `f64` | Schwager's true range over the EMA of prior true ranges (>2 = wide-ranging day) |
| `VolatilityCone` | `Candle` → `VolatilityConeOutput` | current realized volatility within its min/median/max envelope + percentile |
| `VolatilityOfVolatility` | `f64` → `f64` | sample stddev of a rolling realized-volatility series |

### Notes
- Two B5 roadmap items were dropped as duplicates/by-construction: `RealizedVolatility` already ships (v0.5.4); `Downside Semi-Deviation` is internal to Sortino. `Bipower Variation` confirmed distinct from `JumpIndicator` (a ±1 flag, not a variance measure).
- `VolatilityRatio` implements the widely-charted EMA-of-true-range convention (denominator excludes the current bar so the 2.0 threshold means "twice typical"), distinct from the existing pairwise `variance_ratio`.
- `Garch11` mean-reverts to `ω/(1−β)` on a flat series (does not decay to 0 like EWMA) — pinned by a dedicated test.

### Coverage / verification
- Full core + Python/Node/WASM bindings, fuzz drivers (scalar + candle), registries, CHANGELOG, README + docs counter sync.
- 100% unit-test coverage per indicator (every branch).
- Green locally: `cargo clippy --workspace --all-targets --all-features -D warnings`, core lib (3479) + doc (387), node (504), python (830).

Deep-dive docs for all six are staged for `wickra-docs` and pushed after release (gated).
2026-06-06 22:38:34 +02:00
kingchenc db186b18d3 docs(readme): star-history chart + ci(sync-about): sync docs config count (#188)
Add a dark-mode star-history chart under the README footer thank-you line (all existing badges kept), and make sync-about also patch the indicator count into wickra-docs .vitepress/config.ts.
2026-06-06 21:32:35 +02:00
kingchenc 654da5722f release: bump 0.5.8 -> 0.5.9 (#187)
Patch release: streaming/batch perf (SMA, Bollinger, RSI, EMA, ATR; outputs unchanged), cross-library benchmark harness, honest tiered README. No new indicators, no API changes.
2026-06-06 21:09:05 +02:00
kingchenc aacb9280f1 Honest tiered cross-library benchmark + streaming/batch perf (#186)
## Summary

An honest, tiered cross-library benchmark — and the optimization pass it triggered.

### Performance (wickra-core, outputs unchanged)
Profiling against the other Rust TA crates exposed real inefficiencies. Each
benchmarked indicator is now **5–79% faster** in both streaming and batch:

- **SMA, Bollinger**: flat `Box<[f64]>` ring buffers replace `VecDeque` (−69…79%).
- **RSI**: `100·ag/(ag+al)` collapses three divisions into one; Wilder smoothing
  hoists `1/period` out of the hot path (−46%).
- **ATR**: reciprocal hoisted (−42%).
- **EMA/RSI/ATR**: per-tick `Option<f64>` hot state → bare `f64` + ready flag.

Net result vs `kand`: Wickra now wins **RSI, Bollinger and ATR** (streaming), and
ties `ta-rs` on SMA — up from losing every indicator 1.5–6× before.

### Benchmark harness
New `crates/wickra-bench` (publish=false): a Criterion benchmark comparing Wickra
against `kand`, `ta-rs` and `yata` on an identical BTCUSDT candle series, in
streaming and batch modes. Peer APIs were verified against their source, not
guessed. Wired into the nightly `cross-library-bench` workflow as a separate job.

### Honest README
The benchmark section is rewritten into three layered tables (Rust core vs Rust
crates; Python vs the Python ecosystem) that **show the losses as well as the
wins**. The "only library that combines…" claim is gone; the new framing is
breadth + multi-language reach + the deliberate safety trade-off that costs raw
speed. Added an origin/why-slower rationale and a star CTA.

### Python benchmark
Added `tulipy` runners and expanded per-tick streaming coverage to SMA/EMA/RSI/
MACD/Bollinger. `bench.in`/`bench.txt` now lock `TA-Lib` + `tulipy` (hash-pinned);
`pandas-ta` stays out (it requires Python ≥ 3.12, the bench runs on 3.11).

### Notes
- TA-Lib/tulipy numbers in the README Python table are marked ⧗ — they are
  produced by the CI Linux job (C extensions don't build cleanly on every
  desktop), not measured locally.
- The matching `wickra-docs` prose update is committed separately and will be
  pushed with the release, per the docs-don't-lead-the-registries rule.

Verified locally: `cargo fmt`, `cargo test --workspace --all-features` (3413 core
+ bindings), `cargo clippy --workspace --all-targets --all-features -D warnings`,
Node build + 498 tests, and pytest all green.
2026-06-06 20:57:31 +02:00
58 changed files with 9498 additions and 265 deletions
+2
View File
@@ -5,5 +5,7 @@
maturin
numpy
pandas
TA-Lib
tulipy
talipp
finta
+80
View File
@@ -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
+26
View File
@@ -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
+2 -2
View File
@@ -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)."
+48 -1
View File
@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.6.2] - 2026-06-07
- **Modified MA Stop** — Modified MA Stop — SMMA-ratcheted trailing stop with directional flip (`MODIFIED_MA_STOP`).
- **Time-Based Stop** — Time-Based Stop — bar-count timer that fires after a fixed holding period (`TIME_BASED_STOP`).
- **NRTR** — NRTR (Nick Rypock Trailing Reverse) — percentage trailing-reverse stop (`NRTR`).
- **ATR Ratchet** — ATR Ratchet — Kaufman per-bar tightening volatility trailing stop (`ATR_RATCHET`).
- **Elder SafeZone** — Elder SafeZone Stop — average noise-penetration trailing stop with directional flip (`ELDER_SAFE_ZONE`).
- **Kase DevStop** — Kase DevStop volatility trailing stop using standard-deviation of two-bar true range (`KASE_DEV_STOP`).
## [0.6.1] - 2026-06-07
- **Projection Oscillator** — Widner projection oscillator: close position inside the projection bands, scaled 0..100 (`ProjectionOscillator`).
- **Projection Bands** — Widner projection bands: forward-projected high/low regression envelope (`ProjectionBands`).
- **Median Channel** — robust median +/- multiplier*MAD envelope (`MedianChannel`).
- **Bomar Bands** — adaptive percentage bands containing a target coverage fraction of recent closes (`BomarBands`).
- **Quartile Bands** — rolling 25th/50th/75th-percentile (Q1/median/Q3) envelope (`QuartileBands`).
## [0.6.0] - 2026-06-06
- **Volatility Cone** — volatility cone: current realized volatility within its historical min/median/max envelope (`VolatilityCone`).
- **VolatilityRatio** — Schwager's volatility ratio: true range over the EMA of prior true ranges (`VolatilityRatio`).
- **BipowerVariation** — jump-robust realized bipower variation (pi/2 sum of adjacent absolute log-return products) (`BipowerVariation`).
- **VolatilityOfVolatility** — vol-of-vol: sample stddev of a rolling realized-volatility series (`VolatilityOfVolatility`).
- **Garch11** — GARCH(1,1) conditional volatility with a long-run-variance anchor (`Garch11`).
- **EwmaVolatility** — RiskMetrics exponentially-weighted volatility of log returns (lambda decay) (`EwmaVolatility`).
## [0.5.9] - 2026-06-06
### Added
- Internal Rust cross-library benchmark harness (`crates/wickra-bench`, not
published) comparing Wickra against `kand`, `ta-rs` and `yata` on an identical
candle series in both streaming and batch modes; wired into the nightly
`cross-library-bench` workflow.
- `tulipy` runners and expanded per-tick streaming coverage (SMA, EMA, RSI,
MACD, Bollinger) in the Python `compare_libraries` benchmark.
### Changed
- Faster streaming and batch updates for SMA, Bollinger Bands, RSI, EMA and ATR
(flat ring buffers replacing `VecDeque`, hoisted reciprocals in the Wilder
smoothing, leaner hot state) — indicator outputs are unchanged.
- Rewrote the README benchmark section into honest, tiered tables (Rust core vs
the other Rust crates, and Python vs the Python ecosystem) that show where
Wickra wins and where it loses, not only the favourable comparisons.
## [0.5.8] - 2026-06-04
- **TSF Oscillator** — the percentage gap of the close to the one-bar-ahead time-series forecast, a close-relative companion to CFO (`TsfOscillator`).
- **MACD Histogram** — the standalone macd-minus-signal bar of MACD as a scalar series (`MacdHistogram`).
@@ -1273,7 +1316,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
optional Binance live feed.
- Bindings for Python, Node.js, and WebAssembly.
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.5.8...HEAD
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.6.2...HEAD
[0.6.2]: https://github.com/wickra-lib/wickra/compare/v0.6.1...v0.6.2
[0.6.1]: https://github.com/wickra-lib/wickra/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/wickra-lib/wickra/compare/v0.5.9...v0.6.0
[0.5.9]: https://github.com/wickra-lib/wickra/compare/v0.5.8...v0.5.9
[0.5.8]: https://github.com/wickra-lib/wickra/compare/v0.5.7...v0.5.8
[0.5.7]: https://github.com/wickra-lib/wickra/compare/v0.5.6...v0.5.7
[0.5.6]: https://github.com/wickra-lib/wickra/compare/v0.5.5...v0.5.6
Generated
+114 -7
View File
@@ -702,6 +702,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "kand"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af1f41590bd014ef6c3dd815b45f07deb4c3198e355a4319bb7521b6a3a6aeb5"
dependencies = [
"num_enum",
"thiserror",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -911,6 +921,28 @@ dependencies = [
"libm",
]
[[package]]
name = "num_enum"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
dependencies = [
"num_enum_derive",
"rustversion",
]
[[package]]
name = "num_enum_derive"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "numpy"
version = "0.28.0"
@@ -1081,6 +1113,15 @@ dependencies = [
"syn",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -1498,6 +1539,12 @@ dependencies = [
"syn",
]
[[package]]
name = "ta"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "609409d472a0a7d8d4dd9e19891bbdef546b9dce670c3057d0e02192dc541226"
[[package]]
name = "target-lexicon"
version = "0.13.5"
@@ -1607,6 +1654,36 @@ dependencies = [
"tungstenite",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.25.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
dependencies = [
"indexmap",
"toml_datetime",
"toml_parser",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow",
]
[[package]]
name = "tungstenite"
version = "0.29.0"
@@ -1867,7 +1944,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.5.8"
version = "0.6.2"
dependencies = [
"approx",
"criterion",
@@ -1876,9 +1953,21 @@ dependencies = [
"wickra-data",
]
[[package]]
name = "wickra-bench"
version = "0.6.2"
dependencies = [
"criterion",
"kand",
"ta",
"wickra",
"wickra-data",
"yata",
]
[[package]]
name = "wickra-core"
version = "0.5.8"
version = "0.6.2"
dependencies = [
"approx",
"proptest",
@@ -1888,7 +1977,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.5.8"
version = "0.6.2"
dependencies = [
"approx",
"csv",
@@ -1905,7 +1994,7 @@ dependencies = [
[[package]]
name = "wickra-examples"
version = "0.0.0"
version = "0.6.2"
dependencies = [
"serde_json",
"tokio",
@@ -1915,7 +2004,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.5.8"
version = "0.6.2"
dependencies = [
"napi",
"napi-build",
@@ -1925,7 +2014,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.5.8"
version = "0.6.2"
dependencies = [
"numpy",
"pyo3",
@@ -1934,7 +2023,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.5.8"
version = "0.6.2"
dependencies = [
"console_error_panic_hook",
"js-sys",
@@ -1991,6 +2080,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
@@ -2091,6 +2189,15 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "yata"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b4ef8ddfa3ccd93454262c0e60a43a2bbf403d404174e1815f7581d5028229f"
dependencies = [
"serde",
]
[[package]]
name = "yoke"
version = "0.8.2"
+3 -2
View File
@@ -8,11 +8,12 @@ members = [
"bindings/wasm",
"bindings/node",
"examples/rust",
"crates/wickra-bench",
]
exclude = ["fuzz"]
[workspace.package]
version = "0.5.8"
version = "0.6.2"
authors = ["kingchenc <support@wickra.org>"]
edition = "2021"
rust-version = "1.86"
@@ -24,7 +25,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
categories = ["finance", "mathematics", "science"]
[workspace.dependencies]
wickra-core = { path = "crates/wickra-core", version = "0.5.8" }
wickra-core = { path = "crates/wickra-core", version = "0.6.2" }
thiserror = "2"
rayon = "1.10"
+127 -65
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=423" alt="Wickra — streaming-first technical indicators" width="100%"></a>
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=440" alt="Wickra — streaming-first technical indicators" width="100%"></a>
</p>
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
@@ -48,7 +48,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
[Node](https://docs.wickra.org/Quickstart-Node),
[WASM](https://docs.wickra.org/Quickstart-WASM).
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
every one of the 423 indicators; start at the
every one of the 440 indicators; start at the
[indicators overview](https://docs.wickra.org/Indicators-Overview).
- **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods),
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
@@ -60,83 +60,135 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
## Why Wickra exists
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
talipp, tulipy — and every one of them shares the same blind spot:
Wickra started as a personal itch. The existing TA libraries never quite fit the
projects I was building, so I decided to build one from the ground up — partly to
learn, partly because I genuinely enjoy taking something that already exists and
trying to do it differently (and, ideally, better). It's open source because the
useful version of that itch is the one other people can build on too.
| Library | Install pain | Streaming | Multi-language | Active |
|------------------------|-----------------|-----------|----------------|--------|
| **★&nbsp;Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
| TA-Lib (Python) | yes (C deps) | no | no | barely |
| pandas-ta | clean | no | no | slow |
| finta | clean | no | no | stale |
| ta-lib-python | yes (C deps) | no | no | barely |
| talipp | clean | yes | no | yes |
| Tulip Indicators | yes (C deps) | no | partial | stale |
| ooples (C#) | clean | no | C# only | yes |
Plenty of TA libraries are fast. Each one forces a trade-off Wickra does not:
Wickra is the only library that combines all of: clean install, streaming,
multi-language reach, and active maintenance.
| Library | Install | Streaming | Languages | Indicators | Active |
|------------------|-------------|-------------|-----------------------------|-----------:|--------|
| **★&nbsp;Wickra**| **clean** | **yes, O(1)** | **Python · Node · WASM · Rust** | **423** | **yes** |
| kand | clean | yes | Python · WASM · Rust | ~60 | yes |
| ta-rs | clean | yes | Rust only | ~30 | stale |
| yata | clean | partial | Rust only | ~35 | yes |
| TA-Lib | yes (C deps)| no | many bindings | ~150 | barely |
| pandas-ta | clean | no | Python | ~130 | slow |
| finta | clean | no | Python | ~80 | stale |
| talipp | clean | yes | Python | ~40 | yes |
## Benchmark: how much faster is "streaming-first"?
Wickra's edge is **breadth with reach**: 440 indicators that all update in O(1)
per tick and ship natively to Python, Node.js, WebAssembly and Rust from a
single engine.
The numbers below were measured on a single developer workstation and are not
guaranteed to reproduce identically on different hardware — absolute µs values
depend on CPU, memory clock and OS scheduler. Read them as **relative
speedups** between libraries on identical input, not as a universal
performance contract.
**On speed — and why Wickra isn't the fastest.** It deliberately isn't. The
leaner Rust crates (kand, ta-rs) win several of the micro-benchmarks below, and
those losses are shown rather than hidden. The gap is a *choice*, not a ceiling:
every `update` validates its input, runs a real warmup before it emits a value,
and returns an `Option` so a single bad tick can't silently poison the state.
ta-rs, by contrast, hands back a bare `f64` from the first tick with no
validation. If Wickra threw all of that away — raw `f64` out, no checks, no
warmup contract — it would match or beat the leanest crate on every row. It
keeps the guarantees instead, and still wins RSI, Bollinger and ATR against kand.
What no other library matches is the *combination*: catalogue size, native O(1)
streaming, NaN-safety, and four first-class language targets at once.
## Benchmarks
Three comparisons, split by layer and mode. Read them as **relative** speedups
on identical input — absolute µs depend on CPU, memory clock and OS scheduler,
not a universal contract.
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
Python 3.12, Node 20.
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
`python -m benchmarks.compare_libraries`. The script auto-detects every
installed peer library and runs them on the same generated inputs as
Wickra. The CI job `cross-library-bench` runs the same script on every
push and uploads the raw report as a build artefact.
Rust 1.92 (release: `lto = "fat"`, `codegen-units = 1`), Python 3.12.
- **Reproduce yourself:**
- Rust core vs Rust crates: `cargo bench -p wickra-bench`
- Python vs Python libs: `pip install -e bindings/python[bench]` then
`python -m benchmarks.compare_libraries` (auto-detects installed peers).
Lower µs/op = faster. Wickra wins every batch category outright, and the
streaming gap widens linearly with how much history a batch-only library has
to recompute on every tick.
### 1. Rust core vs the other Rust TA crates
### Batch — single full pass over a 20 000-bar series
Like-for-like, no language-binding overhead, over a 50 000-bar series (µs for
the whole series, lower = faster). This is the honest engine comparison —
Wickra wins some and loses some, and both are shown.
Reading the table: each cell shows that library's runtime, plus how many times
slower it is than Wickra in parentheses. **★** marks the winner per row.
**Streaming** (one value fed per `update`):
| Indicator | **★&nbsp;Wickra** | finta | talipp |
|---------------------|---------------------|-----------------------------|-------------------------------|
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) |
| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) |
| Bollinger(20, 2.0) | **105.3 µs** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)|
| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) |
| Indicator | **★&nbsp;Wickra** | kand | ta-rs | yata |
|------------------|------------------:|-----:|------:|-----:|
| SMA(20) | 50 | 38 | 47 | 38 |
| EMA(20) | 154 | 69 | 56 | 69 |
| RSI(14) | 164 | 216 | 74 | — |
| MACD(12, 26, 9) | 275 | 143 | 66 | — |
| Bollinger(20, 2) | **128** | 248 | 168 | — |
| ATR(14) | 152 | 166 | 61 | — |
### Streaming — per-tick latency after seeding with 5 000 historical bars
**Batch** (whole series at once). Only Wickra and kand expose a batch API;
ta-rs and yata are streaming-only.
A batch-only library has to re-run its full indicator over the entire history on
every new tick; Wickra updates state in O(1).
| Indicator | **★&nbsp;Wickra** | kand |
|------------------|------------------:|-----:|
| SMA(20) | 82 | 42 |
| EMA(20) | 159 | 74 |
| RSI(14) | **253 ★** | 274 |
| MACD(12, 26, 9) | 681 | 283 |
| Bollinger(20, 2) | **445 ★** | 462 |
| ATR(14) | 175 | 173 |
| Indicator | **★&nbsp;Wickra (per tick)** | talipp (per tick) |
|-----------|---------------------|---------------------------|
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
ta-rs is the per-indicator speed champion on almost every row — it returns a
bare `f64` with no warmup state and no input validation, trading away the
`None`-warmup and NaN-safety semantics Wickra keeps. Against kand, Wickra wins
streaming RSI, Bollinger and ATR (and batch RSI + Bollinger); Bollinger is the
one row where Wickra is the outright fastest of all four. The leaner crates
still win the pure recurrences (EMA, MACD) and SMA. yata exposes only SMA/EMA as
raw-value methods, so its other rows are omitted rather than faked.
> TA-Lib and pandas-ta are not included here because both fail to install
> cleanly on Windows without C build tooling — which is precisely the install
> pain Wickra was built to remove. The benchmark script auto-detects every
> peer library it can find and runs them on the same inputs as Wickra; install
> them in your environment to see those rows light up too.
### 2. Python vs the Python TA ecosystem — batch
Full pass over a 20 000-bar series, µs/op (lower = faster). **★** per row.
| Indicator | **★&nbsp;Wickra** | finta | TA-Lib | tulipy |
|------------------|------------------:|---------------------|--------|--------|
| SMA(20) | **59.6 ★** | 354.2 (5.9× slower) | ⧗ | ⧗ |
| EMA(20) | **88.4 ★** | 309.3 (3.5× slower) | ⧗ | ⧗ |
| RSI(14) | **77.3 ★** | 1 283 (16.6× slower)| ⧗ | ⧗ |
| MACD(12, 26, 9) | **116.4 ★** | 529.5 (4.6× slower) | ⧗ | ⧗ |
| Bollinger(20, 2) | **146.0 ★** | 1 246 (8.5× slower) | ⧗ | ⧗ |
| ATR(14) | **135.8 ★** | 3 812 (28× slower) | ⧗ | ⧗ |
> ⧗ = published by the CI Linux job. TA-Lib and tulipy ship C extensions that
> don't build cleanly on every desktop, so their canonical numbers come from the
> `cross-library-bench` workflow rather than this local table. pandas-ta needs
> Python ≥ 3.12 and isn't in the 3.11 CI matrix. The script auto-detects
> whichever peers are installed in your environment.
### 3. Python — streaming (per-tick latency)
Seed 5 000 bars, then feed ticks one at a time. talipp is the only Python peer
with a true incremental API; batch-only libraries like TA-Lib must recompute the
entire history on every tick — Wickra updates in O(1).
| Indicator | **★&nbsp;Wickra (per tick)** | talipp (per tick) |
|------------------|------------------------------:|-------------------------|
| SMA(20) | **0.067 µs ★** | 0.63 µs (9.4× slower) |
| EMA(20) | **0.051 µs ★** | 0.63 µs (12.2× slower) |
| RSI(14) | **0.053 µs ★** | 1.00 µs (19.1× slower) |
| MACD(12, 26, 9) | **0.071 µs ★** | 3.64 µs (51.5× slower) |
| Bollinger(20, 2) | **0.085 µs ★** | 4.87 µs (57.2× slower) |
Run the suite yourself:
```bash
pip install -e bindings/python[bench]
cargo bench -p wickra-bench # Rust core vs kand / ta-rs / yata
pip install -e bindings/python[bench] # Python peers
python -m benchmarks.compare_libraries
```
## Indicators
423 streaming-first indicators across twenty-four families. Every one passes the
440 streaming-first indicators across twenty-four families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests. Each has a per-indicator deep dive (formula, parameters,
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
@@ -147,9 +199,9 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Momentum Oscillators | RSI (Wilder), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia, ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100), Disparity Index, Fisher RSI, RSX, Dynamic Momentum Index, Stochastic CCI, RMI, Derivative Oscillator, Elder Ray, Intraday Momentum Index, QQE |
| Trend & Directional | MACD, MACD Fixed (MACDFIX), MACD Extended (MACDEXT), ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter, Plus DM, Minus DM, Plus DI, Minus DI, DX, TTM Trend, Trend Strength Index, Qstick, Polarized Fractal Efficiency, Wave PM, Gator Oscillator, Kase Permission Stochastic |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC, TSF Oscillator, MACD Histogram, PPO Histogram |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility |
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
| Trailing Stops | Parabolic SAR, Parabolic SAR Extended (SAREXT), SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
| 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 |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands, Spread AR(1) Coefficient |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
@@ -245,9 +297,10 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 423 indicators
│ ├── wickra-core/ core engine + all 440 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
── wickra-data/ CSV reader, tick aggregator, live exchange feeds
── wickra-data/ CSV reader, tick aggregator, live exchange feeds
│ └── wickra-bench/ internal cross-library benchmark harness (not published)
├── bindings/
│ ├── python/ PyO3 + maturin (publishes on PyPI)
│ ├── node/ napi-rs (publishes on npm)
@@ -261,9 +314,10 @@ wickra/
└── .github/workflows/ CI and release pipelines
```
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
in the workspace member crate at `examples/rust/`. There is no top-level
`benches/` directory.
Wickra's own regression benchmarks live in `crates/wickra/benches/`; the
cross-library comparison against kand, ta-rs and yata lives in the internal
`crates/wickra-bench/` crate. Runnable Rust examples live in the workspace member
crate at `examples/rust/`. There is no top-level `benches/` directory.
## Building everything from source
@@ -271,7 +325,8 @@ in the workspace member crate at `examples/rust/`. There is no top-level
# Rust core + tests
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo bench -p wickra
cargo bench -p wickra # Wickra's own regression benchmarks
cargo bench -p wickra-bench # cross-library comparison (kand, ta-rs, yata)
# Python binding (requires Rust toolchain + maturin)
cd bindings/python
@@ -371,3 +426,10 @@ The library is provided **as is**, without warranty of any kind; see
<p align="center">
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
</p>
<p align="center">
<a href="https://star-history.com/#wickra-lib/wickra&Date">
<img alt="Wickra star history" width="640"
src="https://api.star-history.com/svg?repos=wickra-lib/wickra&type=Date&theme=dark">
</a>
</p>
@@ -28,6 +28,10 @@ function num(v) {
// --- Scalar indicators: update(value) vs batch(prices) ---
const scalarFactories = {
BipowerVariation: () => new wickra.BipowerVariation(20),
VolatilityOfVolatility: () => new wickra.VolatilityOfVolatility(20, 20),
Garch11: () => new wickra.Garch11(0.000002, 0.1, 0.88),
EwmaVolatility: () => new wickra.EwmaVolatility(0.94),
PpoHistogram: () => new wickra.PpoHistogram(3, 6, 3),
MacdHistogram: () => new wickra.MacdHistogram(3, 6, 3),
TsfOscillator: () => new wickra.TsfOscillator(3),
@@ -350,6 +354,9 @@ const candleScalar = {
IMI: { make: () => new wickra.IMI(14), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TTM_TREND: { make: () => new wickra.TTM_TREND(6), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
Qstick: { make: () => new wickra.Qstick(10), step: (ind, i) => ind.update(open[i], close[i]), batch: (ind) => ind.batch(open, close) },
VolatilityRatio: { make: () => new wickra.VolatilityRatio(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ProjectionOscillator: { make: () => new wickra.ProjectionOscillator(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TimeBasedStop: { make: () => new wickra.TimeBasedStop(5), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
};
for (const [name, d] of Object.entries(candleScalar)) {
@@ -436,6 +443,16 @@ const multi = {
QQE: { make: () => new wickra.QQE(14, 5, 4.236), fields: ['rsiMa', 'trailingLine'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
GatorOscillator: { make: () => new wickra.GatorOscillator(13, 8, 5), fields: ['upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
KasePermissionStochastic: { make: () => new wickra.KasePermissionStochastic(9, 3), fields: ['fast', 'slow'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
VolatilityCone: { make: () => new wickra.VolatilityCone(20, 60), fields: ['current', 'min', 'median', 'max', 'percentile'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
QuartileBands: { make: () => new wickra.QuartileBands(4), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
BomarBands: { make: () => new wickra.BomarBands(4, 0.85), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
MedianChannel: { make: () => new wickra.MedianChannel(5, 2.0), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
ProjectionBands: { make: () => new wickra.ProjectionBands(3), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
KaseDevStop: { make: () => new wickra.KaseDevStop(3, 1.0), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ElderSafeZone: { make: () => new wickra.ElderSafeZone(14, 2.0), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
AtrRatchet: { make: () => new wickra.AtrRatchet(14, 4.0, 0.1), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
Nrtr: { make: () => new wickra.Nrtr(2.0), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ModifiedMaStop: { make: () => new wickra.ModifiedMaStop(14), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
};
for (const [name, d] of Object.entries(multi)) {
+224
View File
@@ -5,6 +5,17 @@
/** Library version (matches the Rust crate version). */
export declare function version(): string
/**
* Volatility-cone result: current realized volatility and its lookback
* envelope (min / median / max) plus the percentile rank of `current`.
*/
export interface VolatilityConeValue {
current: number
min: number
median: number
max: number
percentile: number
}
/** Lead/lag result: the offset that maximises correlation, and that correlation. */
export interface LeadLagValue {
/** Offset that maximises `|corr(a, b shifted)|`. Positive ⇒ `a` leads `b`. */
@@ -138,6 +149,26 @@ export interface DonchianStopValue {
stopLong: number
stopShort: number
}
export interface KaseDevStopValue {
value: number
direction: number
}
export interface ElderSafeZoneValue {
value: number
direction: number
}
export interface AtrRatchetValue {
value: number
direction: number
}
export interface NrtrValue {
value: number
direction: number
}
export interface ModifiedMaStopValue {
value: number
direction: number
}
/** Vortex Indicator pair: `VI+` and `VI-`. */
export interface VortexValue {
plus: number
@@ -188,6 +219,26 @@ export interface StandardErrorBandsValue {
middle: number
lower: number
}
export interface QuartileBandsValue {
upper: number
middle: number
lower: number
}
export interface BomarBandsValue {
upper: number
middle: number
lower: number
}
export interface MedianChannelValue {
upper: number
middle: number
lower: number
}
export interface ProjectionBandsValue {
upper: number
middle: number
lower: number
}
export interface DoubleBollingerValue {
upperOuter: number
upperInner: number
@@ -987,6 +1038,51 @@ export declare class TsfOscillator {
isReady(): boolean
warmupPeriod(): number
}
export type BipowerVariationNode = BipowerVariation
export declare class BipowerVariation {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type EwmaVolatilityNode = EwmaVolatility
export declare class EwmaVolatility {
constructor(lambda: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type Garch11Node = Garch11
export declare class Garch11 {
constructor(omega: number, alpha: number, beta: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type VolatilityOfVolatilityNode = VolatilityOfVolatility
export declare class VolatilityOfVolatility {
constructor(volWindow: number, vovWindow: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type VolatilityConeNode = VolatilityCone
export declare class VolatilityCone {
constructor(window: number, lookback: number)
update(high: number, low: number, close: number): VolatilityConeValue | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type JumpIndicatorNode = JumpIndicator
export declare class JumpIndicator {
constructor(period: number, threshold: number)
@@ -1574,6 +1670,33 @@ export declare class KasePermissionStochastic {
isReady(): boolean
warmupPeriod(): number
}
export type VolatilityRatioNode = VolatilityRatio
export declare class VolatilityRatio {
constructor(period: number)
update(high: number, low: number, close: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type ProjectionOscillatorNode = ProjectionOscillator
export declare class ProjectionOscillator {
constructor(period: number)
update(high: number, low: number, close: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TimeBasedStopNode = TimeBasedStop
export declare class TimeBasedStop {
constructor(maxBars: number)
update(high: number, low: number, close: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type StochNode = Stochastic
export declare class Stochastic {
constructor(kPeriod: number, dPeriod: number)
@@ -2234,6 +2357,71 @@ export declare class RenkoTrailingStop {
isReady(): boolean
warmupPeriod(): number
}
export type KaseDevStopNode = KaseDevStop
export declare class KaseDevStop {
constructor(period: number, dev: number)
update(high: number, low: number, close: number): KaseDevStopValue | null
/**
* Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
* Warmup positions are `NaN`.
*/
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type ElderSafeZoneNode = ElderSafeZone
export declare class ElderSafeZone {
constructor(period: number, coeff: number)
update(high: number, low: number, close: number): ElderSafeZoneValue | null
/**
* Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
* Warmup positions are `NaN`.
*/
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type AtrRatchetNode = AtrRatchet
export declare class AtrRatchet {
constructor(atrPeriod: number, startMult: number, increment: number)
update(high: number, low: number, close: number): AtrRatchetValue | null
/**
* Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
* Warmup positions are `NaN`.
*/
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type NrtrNode = Nrtr
export declare class Nrtr {
constructor(pct: number)
update(high: number, low: number, close: number): NrtrValue | null
/**
* Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
* Warmup positions are `NaN`.
*/
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type ModifiedMaStopNode = ModifiedMaStop
export declare class ModifiedMaStop {
constructor(period: number)
update(high: number, low: number, close: number): ModifiedMaStopValue | null
/**
* Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
* Warmup positions are `NaN`.
*/
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TypicalPriceNode = TypicalPrice
export declare class TypicalPrice {
constructor()
@@ -2562,6 +2750,42 @@ export declare class StandardErrorBands {
isReady(): boolean
warmupPeriod(): number
}
export type QuartileBandsNode = QuartileBands
export declare class QuartileBands {
constructor(period: number)
update(value: number): QuartileBandsValue | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type BomarBandsNode = BomarBands
export declare class BomarBands {
constructor(period: number, coverage: number)
update(value: number): BomarBandsValue | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type MedianChannelNode = MedianChannel
export declare class MedianChannel {
constructor(period: number, multiplier: number)
update(value: number): MedianChannelValue | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type ProjectionBandsNode = ProjectionBands
export declare class ProjectionBands {
constructor(period: number)
update(high: number, low: number): ProjectionBandsValue | null
batch(high: Array<number>, low: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type DoubleBollingerNode = DoubleBollinger
export declare class DoubleBollinger {
constructor(period: number, kInner: number, kOuter: number)
+18 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-arm64",
"version": "0.5.8",
"version": "0.6.2",
"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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-x64",
"version": "0.5.8",
"version": "0.6.2",
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-x64.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-arm64-gnu",
"version": "0.5.8",
"version": "0.6.2",
"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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-x64-gnu",
"version": "0.5.8",
"version": "0.6.2",
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-x64-gnu.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-win32-arm64-msvc",
"version": "0.5.8",
"version": "0.6.2",
"description": "Native binding for wickra (Windows arm64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-arm64-msvc.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-win32-x64-msvc",
"version": "0.5.8",
"version": "0.6.2",
"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": [
+20 -20
View File
@@ -1,12 +1,12 @@
{
"name": "wickra",
"version": "0.5.8",
"version": "0.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.5.8",
"version": "0.6.2",
"license": "MIT OR Apache-2.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -15,12 +15,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.5.8",
"wickra-darwin-x64": "0.5.8",
"wickra-linux-arm64-gnu": "0.5.8",
"wickra-linux-x64-gnu": "0.5.8",
"wickra-win32-arm64-msvc": "0.5.8",
"wickra-win32-x64-msvc": "0.5.8"
"wickra-darwin-arm64": "0.6.2",
"wickra-darwin-x64": "0.6.2",
"wickra-linux-arm64-gnu": "0.6.2",
"wickra-linux-x64-gnu": "0.6.2",
"wickra-win32-arm64-msvc": "0.6.2",
"wickra-win32-x64-msvc": "0.6.2"
}
},
"node_modules/@napi-rs/cli": {
@@ -41,8 +41,8 @@
}
},
"node_modules/wickra-darwin-arm64": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.8.tgz",
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.6.2.tgz",
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
"cpu": [
"arm64"
@@ -57,8 +57,8 @@
}
},
"node_modules/wickra-darwin-x64": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.8.tgz",
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.6.2.tgz",
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
"cpu": [
"x64"
@@ -73,8 +73,8 @@
}
},
"node_modules/wickra-linux-arm64-gnu": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.8.tgz",
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.6.2.tgz",
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
"cpu": [
"arm64"
@@ -89,8 +89,8 @@
}
},
"node_modules/wickra-linux-x64-gnu": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.8.tgz",
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.6.2.tgz",
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
"cpu": [
"x64"
@@ -105,8 +105,8 @@
}
},
"node_modules/wickra-win32-arm64-msvc": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.8.tgz",
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.6.2.tgz",
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
"cpu": [
"arm64"
@@ -121,8 +121,8 @@
}
},
"node_modules/wickra-win32-x64-msvc": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.8.tgz",
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.6.2.tgz",
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
"cpu": [
"x64"
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "wickra",
"version": "0.5.8",
"version": "0.6.2",
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
"author": "kingchenc <support@wickra.org>",
"main": "index.js",
@@ -47,12 +47,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-linux-x64-gnu": "0.5.8",
"wickra-linux-arm64-gnu": "0.5.8",
"wickra-darwin-x64": "0.5.8",
"wickra-darwin-arm64": "0.5.8",
"wickra-win32-x64-msvc": "0.5.8",
"wickra-win32-arm64-msvc": "0.5.8"
"wickra-linux-x64-gnu": "0.6.2",
"wickra-linux-arm64-gnu": "0.6.2",
"wickra-darwin-x64": "0.6.2",
"wickra-darwin-arm64": "0.6.2",
"wickra-win32-x64-msvc": "0.6.2",
"wickra-win32-arm64-msvc": "0.6.2"
},
"scripts": {
"build": "napi build --platform --release",
+951
View File
@@ -220,6 +220,199 @@ node_scalar_indicator!(
wc::TrendStrengthIndex
);
node_scalar_indicator!(TsfOscillatorNode, "TsfOscillator", wc::TsfOscillator);
node_scalar_indicator!(
BipowerVariationNode,
"BipowerVariation",
wc::BipowerVariation
);
#[napi(js_name = "EwmaVolatility")]
pub struct EwmaVolatilityNode {
inner: wc::EwmaVolatility,
}
#[napi]
impl EwmaVolatilityNode {
#[napi(constructor)]
pub fn new(lambda: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::EwmaVolatility::new(lambda).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "Garch11")]
pub struct Garch11Node {
inner: wc::Garch11,
}
#[napi]
impl Garch11Node {
#[napi(constructor)]
pub fn new(omega: f64, alpha: f64, beta: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::Garch11::new(omega, alpha, beta).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "VolatilityOfVolatility")]
pub struct VolatilityOfVolatilityNode {
inner: wc::VolatilityOfVolatility,
}
#[napi]
impl VolatilityOfVolatilityNode {
#[napi(constructor)]
pub fn new(vol_window: u32, vov_window: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::VolatilityOfVolatility::new(vol_window as usize, vov_window as usize)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
/// Volatility-cone result: current realized volatility and its lookback
/// envelope (min / median / max) plus the percentile rank of `current`.
#[napi(object)]
pub struct VolatilityConeValue {
pub current: f64,
pub min: f64,
pub median: f64,
pub max: f64,
pub percentile: f64,
}
#[napi(js_name = "VolatilityCone")]
pub struct VolatilityConeNode {
inner: wc::VolatilityCone,
}
#[napi]
impl VolatilityConeNode {
#[napi(constructor)]
pub fn new(window: u32, lookback: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::VolatilityCone::new(window as usize, lookback as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<VolatilityConeValue>> {
Ok(self
.inner
.update(cnd(high, low, close, 0.0)?)
.map(|o| VolatilityConeValue {
current: o.current,
min: o.min,
median: o.median,
max: o.max,
percentile: o.percentile,
}))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 5];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
out[i * 5] = o.current;
out[i * 5 + 1] = o.min;
out[i * 5 + 2] = o.median;
out[i * 5 + 3] = o.max;
out[i * 5 + 4] = o.percentile;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "JumpIndicator")]
pub struct JumpIndicatorNode {
inner: wc::JumpIndicator,
@@ -2623,6 +2816,165 @@ impl KasePermissionStochasticNode {
}
}
#[napi(js_name = "VolatilityRatio")]
pub struct VolatilityRatioNode {
inner: wc::VolatilityRatio,
}
#[napi]
impl VolatilityRatioNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::VolatilityRatio::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, 0.0)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], 0.0)?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "ProjectionOscillator")]
pub struct ProjectionOscillatorNode {
inner: wc::ProjectionOscillator,
}
#[napi]
impl ProjectionOscillatorNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::ProjectionOscillator::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, 0.0)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], 0.0)?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "TimeBasedStop")]
pub struct TimeBasedStopNode {
inner: wc::TimeBasedStop,
}
#[napi]
impl TimeBasedStopNode {
#[napi(constructor)]
pub fn new(max_bars: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::TimeBasedStop::new(max_bars as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, 0.0)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], 0.0)?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(object)]
pub struct StochValue {
pub k: f64,
@@ -6292,6 +6644,372 @@ impl RenkoTrailingStopNode {
}
}
// ============================== Kase DevStop ==============================
#[napi(object)]
pub struct KaseDevStopValue {
pub value: f64,
pub direction: f64,
}
#[napi(js_name = "KaseDevStop")]
pub struct KaseDevStopNode {
inner: wc::KaseDevStop,
}
#[napi]
impl KaseDevStopNode {
#[napi(constructor)]
pub fn new(period: u32, dev: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::KaseDevStop::new(period as usize, dev).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<KaseDevStopValue>> {
Ok(self
.inner
.update(cnd(high, low, close, 0.0)?)
.map(|o| KaseDevStopValue {
value: o.value,
direction: o.direction,
}))
}
/// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
/// Warmup positions are `NaN`.
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
out[i * 2] = o.value;
out[i * 2 + 1] = o.direction;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== Elder SafeZone ==============================
#[napi(object)]
pub struct ElderSafeZoneValue {
pub value: f64,
pub direction: f64,
}
#[napi(js_name = "ElderSafeZone")]
pub struct ElderSafeZoneNode {
inner: wc::ElderSafeZone,
}
#[napi]
impl ElderSafeZoneNode {
#[napi(constructor)]
pub fn new(period: u32, coeff: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::ElderSafeZone::new(period as usize, coeff).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<ElderSafeZoneValue>> {
Ok(self
.inner
.update(cnd(high, low, close, 0.0)?)
.map(|o| ElderSafeZoneValue {
value: o.value,
direction: o.direction,
}))
}
/// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
/// Warmup positions are `NaN`.
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
out[i * 2] = o.value;
out[i * 2 + 1] = o.direction;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== ATR Ratchet ==============================
#[napi(object)]
pub struct AtrRatchetValue {
pub value: f64,
pub direction: f64,
}
#[napi(js_name = "AtrRatchet")]
pub struct AtrRatchetNode {
inner: wc::AtrRatchet,
}
#[napi]
impl AtrRatchetNode {
#[napi(constructor)]
pub fn new(atr_period: u32, start_mult: f64, increment: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::AtrRatchet::new(atr_period as usize, start_mult, increment)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<AtrRatchetValue>> {
Ok(self
.inner
.update(cnd(high, low, close, 0.0)?)
.map(|o| AtrRatchetValue {
value: o.value,
direction: o.direction,
}))
}
/// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
/// Warmup positions are `NaN`.
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
out[i * 2] = o.value;
out[i * 2 + 1] = o.direction;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== NRTR ==============================
#[napi(object)]
pub struct NrtrValue {
pub value: f64,
pub direction: f64,
}
#[napi(js_name = "Nrtr")]
pub struct NrtrNode {
inner: wc::Nrtr,
}
#[napi]
impl NrtrNode {
#[napi(constructor)]
pub fn new(pct: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::Nrtr::new(pct).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result<Option<NrtrValue>> {
Ok(self
.inner
.update(cnd(high, low, close, 0.0)?)
.map(|o| NrtrValue {
value: o.value,
direction: o.direction,
}))
}
/// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
/// Warmup positions are `NaN`.
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
out[i * 2] = o.value;
out[i * 2 + 1] = o.direction;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== Modified MA Stop ==============================
#[napi(object)]
pub struct ModifiedMaStopValue {
pub value: f64,
pub direction: f64,
}
#[napi(js_name = "ModifiedMaStop")]
pub struct ModifiedMaStopNode {
inner: wc::ModifiedMaStop,
}
#[napi]
impl ModifiedMaStopNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::ModifiedMaStop::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<ModifiedMaStopValue>> {
Ok(self
.inner
.update(cnd(high, low, close, 0.0)?)
.map(|o| ModifiedMaStopValue {
value: o.value,
direction: o.direction,
}))
}
/// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
/// Warmup positions are `NaN`.
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
out[i * 2] = o.value;
out[i * 2 + 1] = o.direction;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== Typical Price ==============================
#[napi(js_name = "TypicalPrice")]
@@ -8283,6 +9001,239 @@ impl StandardErrorBandsNode {
}
}
// ---------- Quartile Bands ----------
#[napi(object)]
pub struct QuartileBandsValue {
pub upper: f64,
pub middle: f64,
pub lower: f64,
}
#[napi(js_name = "QuartileBands")]
pub struct QuartileBandsNode {
inner: wc::QuartileBands,
}
#[napi]
impl QuartileBandsNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::QuartileBands::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<QuartileBandsValue> {
self.inner.update(value).map(|o| QuartileBandsValue {
upper: o.upper,
middle: o.middle,
lower: o.lower,
})
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
let mut out = vec![f64::NAN; prices.len() * 3];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
out
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ---------- Bomar Bands ----------
#[napi(object)]
pub struct BomarBandsValue {
pub upper: f64,
pub middle: f64,
pub lower: f64,
}
#[napi(js_name = "BomarBands")]
pub struct BomarBandsNode {
inner: wc::BomarBands,
}
#[napi]
impl BomarBandsNode {
#[napi(constructor)]
pub fn new(period: u32, coverage: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::BomarBands::new(period as usize, coverage).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<BomarBandsValue> {
self.inner.update(value).map(|o| BomarBandsValue {
upper: o.upper,
middle: o.middle,
lower: o.lower,
})
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
let mut out = vec![f64::NAN; prices.len() * 3];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
out
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ---------- Median Channel ----------
#[napi(object)]
pub struct MedianChannelValue {
pub upper: f64,
pub middle: f64,
pub lower: f64,
}
#[napi(js_name = "MedianChannel")]
pub struct MedianChannelNode {
inner: wc::MedianChannel,
}
#[napi]
impl MedianChannelNode {
#[napi(constructor)]
pub fn new(period: u32, multiplier: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::MedianChannel::new(period as usize, multiplier).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<MedianChannelValue> {
self.inner.update(value).map(|o| MedianChannelValue {
upper: o.upper,
middle: o.middle,
lower: o.lower,
})
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
let mut out = vec![f64::NAN; prices.len() * 3];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
out
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ---------- Projection Bands ----------
#[napi(object)]
pub struct ProjectionBandsValue {
pub upper: f64,
pub middle: f64,
pub lower: f64,
}
#[napi(js_name = "ProjectionBands")]
pub struct ProjectionBandsNode {
inner: wc::ProjectionBands,
}
#[napi]
impl ProjectionBandsNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::ProjectionBands::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, high: f64, low: f64) -> napi::Result<Option<ProjectionBandsValue>> {
Ok(self
.inner
.update(cnd(high, low, low, 0.0)?)
.map(|o| ProjectionBandsValue {
upper: o.upper,
middle: o.middle,
lower: o.lower,
}))
}
#[napi]
pub fn batch(&mut self, high: Vec<f64>, low: Vec<f64>) -> napi::Result<Vec<f64>> {
if high.len() != low.len() {
return Err(NapiError::from_reason(
"high and low must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], low[i], 0.0)?) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ---------- Double Bollinger ----------
#[napi(object)]
@@ -49,6 +49,7 @@ TALIB = _try_import("talib")
PANDAS_TA = _try_import("pandas_ta")
TALIPP = _try_import("talipp.indicators") or _try_import("talipp")
FINTA = _try_import("finta")
TULIPY = _try_import("tulipy")
PD = _try_import("pandas")
import wickra as WICKRA # noqa: E402 -- the library under test must be importable
@@ -275,6 +276,34 @@ def talipp_bollinger_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return lambda: BB(period=20, std_dev_mult=2.0, input_values=list(prices))
# tulipy wraps the C "Tulip Indicators" library; it takes contiguous float64
# arrays and indicator options as positional arguments.
def tulipy_sma_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.sma(prices, 20))
def tulipy_ema_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.ema(prices, 20))
def tulipy_rsi_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.rsi(prices, 14))
def tulipy_macd_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.macd(prices, 12, 26, 9))
def tulipy_bollinger_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.bbands(prices, 20, 2.0))
def tulipy_atr_batch(high: np.ndarray, low: np.ndarray, close: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.atr(high, low, close, 14))
# --------------------------------------------------------------------------- #
# Streaming scenario: per-tick latency
# --------------------------------------------------------------------------- #
@@ -329,6 +358,105 @@ def talipp_rsi_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callabl
return run
# Scalar streaming peers: Wickra and talipp both update incrementally in O(1),
# so this is the like-for-like per-tick comparison (batch-only libs are covered
# by the batch tables and the recompute contrast on RSI above).
def wickra_sma_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
sma = WICKRA.SMA(20)
sma.batch(seed)
for p in live:
sma.update(float(p))
return run
def talipp_sma_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import SMA # type: ignore
def run() -> None:
sma = SMA(period=20, input_values=list(seed))
for p in live:
sma.add(float(p))
return run
def wickra_ema_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
ema = WICKRA.EMA(20)
ema.batch(seed)
for p in live:
ema.update(float(p))
return run
def talipp_ema_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import EMA # type: ignore
def run() -> None:
ema = EMA(period=20, input_values=list(seed))
for p in live:
ema.add(float(p))
return run
def wickra_macd_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
macd = WICKRA.MACD()
macd.batch(seed)
for p in live:
macd.update(float(p))
return run
def talipp_macd_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import MACD # type: ignore
def run() -> None:
macd = MACD(
fast_period=12, slow_period=26, signal_period=9, input_values=list(seed)
)
for p in live:
macd.add(float(p))
return run
def wickra_bollinger_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
bb = WICKRA.BollingerBands(20, 2.0)
bb.batch(seed)
for p in live:
bb.update(float(p))
return run
def talipp_bollinger_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import BB # type: ignore
def run() -> None:
bb = BB(period=20, std_dev_mult=2.0, input_values=list(seed))
for p in live:
bb.add(float(p))
return run
# --------------------------------------------------------------------------- #
# Runner
# --------------------------------------------------------------------------- #
@@ -339,6 +467,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_sma_batch),
("TA-Lib", talib_sma_batch),
("pandas-ta", pandas_ta_sma_batch),
("tulipy", tulipy_sma_batch),
("finta", finta_sma_batch),
("talipp", talipp_sma_batch),
]),
@@ -346,6 +475,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_ema_batch),
("TA-Lib", talib_ema_batch),
("pandas-ta", pandas_ta_ema_batch),
("tulipy", tulipy_ema_batch),
("finta", finta_ema_batch),
("talipp", talipp_ema_batch),
]),
@@ -353,6 +483,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_rsi_batch),
("TA-Lib", talib_rsi_batch),
("pandas-ta", pandas_ta_rsi_batch),
("tulipy", tulipy_rsi_batch),
("finta", finta_rsi_batch),
("talipp", talipp_rsi_batch),
]),
@@ -360,6 +491,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_macd_batch),
("TA-Lib", talib_macd_batch),
("pandas-ta", pandas_ta_macd_batch),
("tulipy", tulipy_macd_batch),
("finta", finta_macd_batch),
("talipp", talipp_macd_batch),
]),
@@ -367,6 +499,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_bollinger_batch),
("TA-Lib", talib_bollinger_batch),
("pandas-ta", pandas_ta_bollinger_batch),
("tulipy", tulipy_bollinger_batch),
("finta", finta_bollinger_batch),
("talipp", talipp_bollinger_batch),
]),
@@ -376,18 +509,35 @@ OHLC_INDICATORS = [
("ATR(14)", [
("Wickra", wickra_atr_batch),
("TA-Lib", talib_atr_batch),
("tulipy", tulipy_atr_batch),
("finta", finta_atr_batch),
("talipp", talipp_atr_batch),
]),
]
STREAMING_INDICATORS = [
("SMA(20)", [
("Wickra", wickra_sma_streaming),
("talipp", talipp_sma_streaming),
]),
("EMA(20)", [
("Wickra", wickra_ema_streaming),
("talipp", talipp_ema_streaming),
]),
("RSI(14)", [
("Wickra", wickra_rsi_streaming),
("TA-Lib", talib_rsi_streaming),
("pandas-ta", pandas_ta_rsi_streaming),
("talipp", talipp_rsi_streaming),
]),
("MACD(12, 26, 9)", [
("Wickra", wickra_macd_streaming),
("talipp", talipp_macd_streaming),
]),
("Bollinger(20, 2.0)", [
("Wickra", wickra_bollinger_streaming),
("talipp", talipp_bollinger_streaming),
]),
]
@@ -501,6 +651,7 @@ def main() -> None:
available = []
if TALIB is not None: available.append("TA-Lib")
if PANDAS_TA is not None: available.append("pandas-ta")
if TULIPY is not None: available.append("tulipy")
if FINTA is not None: available.append("finta")
if TALIPP is not None: available.append("talipp")
print(f"Wickra benchmark suite — wickra=v{WICKRA.__version__}")
+2 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.5.8"
version = "0.6.2"
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",
+36
View File
@@ -25,6 +25,14 @@ from __future__ import annotations
from ._wickra import (
__version__,
TimeBasedStop,
ProjectionOscillator,
VolatilityCone,
VolatilityRatio,
BipowerVariation,
VolatilityOfVolatility,
Garch11,
EwmaVolatility,
PpoHistogram,
MacdHistogram,
TsfOscillator,
@@ -161,6 +169,11 @@ from ._wickra import (
HistoricalVolatility,
BollingerBandwidth,
PercentB,
# Trailing Stops
ModifiedMaStop,
Nrtr,
AtrRatchet,
ElderSafeZone,
SuperTrend,
ChandelierExit,
ChandeKrollStop,
@@ -172,6 +185,7 @@ from ._wickra import (
PercentageTrailingStop,
StepTrailingStop,
RenkoTrailingStop,
KaseDevStop,
TrueRange,
ChaikinVolatility,
RVIVolatility,
@@ -257,6 +271,10 @@ from ._wickra import (
MAMA,
FAMA,
# Bands & Channels
ProjectionBands,
MedianChannel,
BomarBands,
QuartileBands,
MaEnvelope,
AccelerationBands,
StarcBands,
@@ -476,6 +494,14 @@ from ._wickra import (
)
__all__ = [
"TimeBasedStop",
"ProjectionOscillator",
"VolatilityCone",
"VolatilityRatio",
"BipowerVariation",
"VolatilityOfVolatility",
"Garch11",
"EwmaVolatility",
"PpoHistogram",
"MacdHistogram",
"TsfOscillator",
@@ -613,6 +639,11 @@ __all__ = [
"HistoricalVolatility",
"BollingerBandwidth",
"PercentB",
# Trailing Stops
"ModifiedMaStop",
"Nrtr",
"AtrRatchet",
"ElderSafeZone",
"SuperTrend",
"ChandelierExit",
"ChandeKrollStop",
@@ -624,6 +655,7 @@ __all__ = [
"PercentageTrailingStop",
"StepTrailingStop",
"RenkoTrailingStop",
"KaseDevStop",
"TrueRange",
"ChaikinVolatility",
"RVIVolatility",
@@ -709,6 +741,10 @@ __all__ = [
"MAMA",
"FAMA",
# Bands & Channels
"ProjectionBands",
"MedianChannel",
"BomarBands",
"QuartileBands",
"MaEnvelope",
"AccelerationBands",
"StarcBands",
File diff suppressed because it is too large Load Diff
@@ -45,6 +45,10 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
# --- Scalar (f64 -> f64) indicators ---------------------------------------
SCALAR = [
(ta.BipowerVariation, (20,)),
(ta.VolatilityOfVolatility, (20, 20)),
(ta.Garch11, (0.000002, 0.1, 0.88)),
(ta.EwmaVolatility, (0.94,)),
(ta.PpoHistogram, (3, 6, 3)),
(ta.MacdHistogram, (3, 6, 3)),
(ta.TsfOscillator, (3,)),
@@ -169,6 +173,9 @@ SCALAR = [
# Family 05 band/channel indicators with scalar input and multi-output.
# `cols` is the expected number of band columns from `batch`.
SCALAR_MULTI = {
"MedianChannel": (lambda: ta.MedianChannel(5, 2.0), 3),
"BomarBands": (lambda: ta.BomarBands(4, 0.85), 3),
"QuartileBands": (lambda: ta.QuartileBands(4), 3),
"Qqe": (lambda: ta.QQE(14, 5, 4.236), 2),
"MaEnvelope": (lambda: ta.MaEnvelope(20, 0.025), 3),
"LinRegChannel": (lambda: ta.LinRegChannel(20, 2.0), 3),
@@ -361,6 +368,9 @@ def test_relative_strength_streaming_matches_batch():
# 6-tuple candle; the batch helper takes only the columns it needs.
CANDLE_SCALAR = {
"TimeBasedStop": (lambda: ta.TimeBasedStop(5), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"ProjectionOscillator": (lambda: ta.ProjectionOscillator(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"VolatilityRatio": (lambda: ta.VolatilityRatio(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"TTM_TREND": (lambda: ta.TTM_TREND(6), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"StochasticCCI": (lambda: ta.StochasticCCI(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
# Per-bar OHLC transforms (open matters). The streaming harness feeds
@@ -899,6 +909,41 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
# --- Candle-input, multi-output indicators --------------------------------
MULTI = {
"ModifiedMaStop": (
lambda: ta.ModifiedMaStop(14),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"Nrtr": (
lambda: ta.Nrtr(2.0),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"AtrRatchet": (
lambda: ta.AtrRatchet(14, 4.0, 0.1),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"ElderSafeZone": (
lambda: ta.ElderSafeZone(14, 2.0),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"KaseDevStop": (
lambda: ta.KaseDevStop(3, 1.0),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"ProjectionBands": (
lambda: ta.ProjectionBands(3),
lambda ind, h, l, c, v: ind.batch(h, l),
3,
),
"VolatilityCone": (
lambda: ta.VolatilityCone(20, 60),
lambda ind, h, l, c, v: ind.batch(h, l, c),
5,
),
"KasePermissionStochastic": (
lambda: ta.KasePermissionStochastic(9, 3),
lambda ind, h, l, c, v: ind.batch(h, l, c),
@@ -2890,6 +2935,111 @@ def test_ppo_histogram_reference():
assert t.update(100.0 + i * 2.0) is None
assert t.update(100.0 + 7 * 2.0) == pytest.approx(-0.052098, abs=1e-6)
def test_ewma_volatility_reference():
t = ta.EwmaVolatility(0.94)
assert t.update(100.0) is None
assert t.update(110.0) == pytest.approx(0.09531017980432493)
assert t.update(99.0) == pytest.approx(0.0959428936787596)
def test_garch11_reference():
t = ta.Garch11(0.000002, 0.1, 0.88)
assert t.update(100.0) is None
assert t.update(110.0) == pytest.approx(0.009999999999999995)
assert t.update(99.0) == pytest.approx(0.031597516317477786)
def test_volatility_cone_reference():
t = ta.VolatilityCone(20, 60)
def test_quartile_bands_reference():
t = ta.QuartileBands(4)
assert t.update(40.0) is None
assert t.update(30.0) is None
assert t.update(20.0) is None
assert t.update(10.0) == pytest.approx((32.5, 25.0, 17.5))
def test_bomar_bands_reference():
t = ta.BomarBands(4, 0.85)
assert t.update(100.0) is None
assert t.update(102.0) is None
assert t.update(98.0) is None
assert t.update(104.0) == pytest.approx((104.0, 101.0, 98.0))
def test_median_channel_reference():
t = ta.MedianChannel(5, 2.0)
assert t.update(1.0) is None
assert t.update(2.0) is None
assert t.update(3.0) is None
assert t.update(4.0) is None
assert t.update(5.0) == pytest.approx((5.0, 3.0, 1.0))
def test_projection_bands_reference():
t = ta.ProjectionBands(3)
assert t.update((8.0, 10.0, 8.0, 9.0, 1.0, 0)) is None
assert t.update((9.0, 12.0, 9.0, 11.0, 1.0, 1)) is None
assert t.update((10.0, 11.0, 10.0, 11.0, 1.0, 2)) == pytest.approx((12.5, 11.25, 10.0))
def test_projection_oscillator_reference():
# Same window as ProjectionBands: upper 12.5, lower 10; close 11 -> 40.
t = ta.ProjectionOscillator(3)
assert t.update((8.0, 10.0, 8.0, 9.0, 1.0, 0)) is None
assert t.update((9.0, 12.0, 9.0, 11.0, 1.0, 1)) is None
assert t.update((10.0, 11.0, 10.0, 11.0, 1.0, 2)) == pytest.approx(40.0)
def test_kase_devstop_reference():
t = ta.KaseDevStop(3, 1.0)
assert t.update((100.0, 101.0, 99.0, 100.0, 1.0, 0)) is None
assert t.update((101.0, 102.0, 100.0, 101.0, 1.0, 1)) is None
assert t.update((102.0, 103.0, 101.0, 102.0, 1.0, 2)) is None
assert t.update((102.5, 104.0, 102.0, 103.0, 1.0, 3)) == pytest.approx((101.0, 1.0))
def _stop_candles(n):
# Gently rising, valid OHLC: high >= open/close, low <= open/close.
return [(100.0 + i, 101.5 + i, 98.5 + i, 100.5 + i, 1.0, i) for i in range(n)]
def test_elder_safezone_reference():
t = ta.ElderSafeZone(14, 2.0)
candles = _stop_candles(15)
for c in candles[:14]:
assert t.update(c) is None
assert t.update(candles[14]) == pytest.approx((112.5, 1.0))
def test_atr_ratchet_reference():
t = ta.AtrRatchet(14, 4.0, 0.1)
candles = _stop_candles(14)
for c in candles[:13]:
assert t.update(c) is None
assert t.update(candles[13]) == pytest.approx((101.5, 1.0))
def test_nrtr_reference():
t = ta.Nrtr(2.0)
assert t.update((100.0, 100.0, 100.0, 100.0, 1.0, 0)) == pytest.approx((98.0, 1.0))
def test_time_based_stop_reference():
t = ta.TimeBasedStop(5)
assert t.update((100.0, 101.0, 99.0, 100.0, 1.0, 0)) == pytest.approx(0.2)
def test_modified_ma_stop_reference():
t = ta.ModifiedMaStop(14)
candles = _stop_candles(14)
for c in candles[:13]:
assert t.update(c) is None
assert t.update(candles[13]) == pytest.approx((107.0, 1.0))
# --- Lifecycle ------------------------------------------------------------
+666
View File
@@ -2476,6 +2476,120 @@ impl WasmKasePermissionStochastic {
}
}
#[wasm_bindgen(js_name = VolatilityRatio)]
pub struct WasmVolatilityRatio {
inner: wc::VolatilityRatio,
}
#[wasm_bindgen(js_class = VolatilityRatio)]
impl WasmVolatilityRatio {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmVolatilityRatio, JsError> {
Ok(Self {
inner: wc::VolatilityRatio::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != close.len() {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = ProjectionOscillator)]
pub struct WasmProjectionOscillator {
inner: wc::ProjectionOscillator,
}
#[wasm_bindgen(js_class = ProjectionOscillator)]
impl WasmProjectionOscillator {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmProjectionOscillator, JsError> {
Ok(Self {
inner: wc::ProjectionOscillator::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != close.len() {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = TimeBasedStop)]
pub struct WasmTimeBasedStop {
inner: wc::TimeBasedStop,
}
#[wasm_bindgen(js_class = TimeBasedStop)]
impl WasmTimeBasedStop {
#[wasm_bindgen(constructor)]
pub fn new(max_bars: usize) -> Result<WasmTimeBasedStop, JsError> {
Ok(Self {
inner: wc::TimeBasedStop::new(max_bars).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != close.len() {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = Stochastic)]
pub struct WasmStoch {
inner: wc::Stochastic,
@@ -3757,6 +3871,275 @@ impl WasmRenkoTrailingStop {
}
}
#[wasm_bindgen(js_name = KaseDevStop)]
pub struct WasmKaseDevStop {
inner: wc::KaseDevStop,
}
#[wasm_bindgen(js_class = KaseDevStop)]
impl WasmKaseDevStop {
#[wasm_bindgen(constructor)]
pub fn new(period: usize, dev: f64) -> Result<WasmKaseDevStop, JsError> {
Ok(Self {
inner: wc::KaseDevStop::new(period, dev).map_err(map_err)?,
})
}
/// Returns `{ value, direction }` once warm, else `null`.
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(match self.inner.update(c) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"value".into(), &o.value.into()).ok();
Reflect::set(&obj, &"direction".into(), &o.direction.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
/// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
/// Warmup is NaN.
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
if low.len() != n || close.len() != n {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
if let Some(o) = self.inner.update(c) {
out[i * 2] = o.value;
out[i * 2 + 1] = o.direction;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = ElderSafeZone)]
pub struct WasmElderSafeZone {
inner: wc::ElderSafeZone,
}
#[wasm_bindgen(js_class = ElderSafeZone)]
impl WasmElderSafeZone {
#[wasm_bindgen(constructor)]
pub fn new(period: usize, coeff: f64) -> Result<WasmElderSafeZone, JsError> {
Ok(Self {
inner: wc::ElderSafeZone::new(period, coeff).map_err(map_err)?,
})
}
/// Returns `{ value, direction }` once warm, else `null`.
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(match self.inner.update(c) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"value".into(), &o.value.into()).ok();
Reflect::set(&obj, &"direction".into(), &o.direction.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
/// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
/// Warmup is NaN.
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
if low.len() != n || close.len() != n {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
if let Some(o) = self.inner.update(c) {
out[i * 2] = o.value;
out[i * 2 + 1] = o.direction;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = AtrRatchet)]
pub struct WasmAtrRatchet {
inner: wc::AtrRatchet,
}
#[wasm_bindgen(js_class = AtrRatchet)]
impl WasmAtrRatchet {
#[wasm_bindgen(constructor)]
pub fn new(
atr_period: usize,
start_mult: f64,
increment: f64,
) -> Result<WasmAtrRatchet, JsError> {
Ok(Self {
inner: wc::AtrRatchet::new(atr_period, start_mult, increment).map_err(map_err)?,
})
}
/// Returns `{ value, direction }` once warm, else `null`.
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(match self.inner.update(c) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"value".into(), &o.value.into()).ok();
Reflect::set(&obj, &"direction".into(), &o.direction.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
/// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
/// Warmup is NaN.
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
if low.len() != n || close.len() != n {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
if let Some(o) = self.inner.update(c) {
out[i * 2] = o.value;
out[i * 2 + 1] = o.direction;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = Nrtr)]
pub struct WasmNrtr {
inner: wc::Nrtr,
}
#[wasm_bindgen(js_class = Nrtr)]
impl WasmNrtr {
#[wasm_bindgen(constructor)]
pub fn new(pct: f64) -> Result<WasmNrtr, JsError> {
Ok(Self {
inner: wc::Nrtr::new(pct).map_err(map_err)?,
})
}
/// Returns `{ value, direction }` once warm, else `null`.
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(match self.inner.update(c) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"value".into(), &o.value.into()).ok();
Reflect::set(&obj, &"direction".into(), &o.direction.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
/// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
/// Warmup is NaN.
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
if low.len() != n || close.len() != n {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
if let Some(o) = self.inner.update(c) {
out[i * 2] = o.value;
out[i * 2 + 1] = o.direction;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = ModifiedMaStop)]
pub struct WasmModifiedMaStop {
inner: wc::ModifiedMaStop,
}
#[wasm_bindgen(js_class = ModifiedMaStop)]
impl WasmModifiedMaStop {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmModifiedMaStop, JsError> {
Ok(Self {
inner: wc::ModifiedMaStop::new(period).map_err(map_err)?,
})
}
/// Returns `{ value, direction }` once warm, else `null`.
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(match self.inner.update(c) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"value".into(), &o.value.into()).ok();
Reflect::set(&obj, &"direction".into(), &o.direction.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
/// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
/// Warmup is NaN.
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
if low.len() != n || close.len() != n {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
if let Some(o) = self.inner.update(c) {
out[i * 2] = o.value;
out[i * 2 + 1] = o.direction;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = TypicalPrice)]
pub struct WasmTypicalPrice {
inner: wc::TypicalPrice,
@@ -5806,6 +6189,219 @@ impl WasmStandardErrorBands {
}
}
// ---------- Quartile Bands (scalar input, 3 outputs) ----------
#[wasm_bindgen(js_name = QuartileBands)]
pub struct WasmQuartileBands {
inner: wc::QuartileBands,
}
#[wasm_bindgen(js_class = QuartileBands)]
impl WasmQuartileBands {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmQuartileBands, JsError> {
Ok(Self {
inner: wc::QuartileBands::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, value: f64) -> JsValue {
match self.inner.update(value) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok();
Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok();
Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
let n = prices.len();
let mut out = vec![f64::NAN; n * 3];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
Float64Array::from(out.as_slice())
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- Bomar Bands (scalar input, 3 outputs) ----------
#[wasm_bindgen(js_name = BomarBands)]
pub struct WasmBomarBands {
inner: wc::BomarBands,
}
#[wasm_bindgen(js_class = BomarBands)]
impl WasmBomarBands {
#[wasm_bindgen(constructor)]
pub fn new(period: usize, coverage: f64) -> Result<WasmBomarBands, JsError> {
Ok(Self {
inner: wc::BomarBands::new(period, coverage).map_err(map_err)?,
})
}
pub fn update(&mut self, value: f64) -> JsValue {
match self.inner.update(value) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok();
Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok();
Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
let n = prices.len();
let mut out = vec![f64::NAN; n * 3];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
Float64Array::from(out.as_slice())
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- Median Channel (scalar input, 3 outputs) ----------
#[wasm_bindgen(js_name = MedianChannel)]
pub struct WasmMedianChannel {
inner: wc::MedianChannel,
}
#[wasm_bindgen(js_class = MedianChannel)]
impl WasmMedianChannel {
#[wasm_bindgen(constructor)]
pub fn new(period: usize, multiplier: f64) -> Result<WasmMedianChannel, JsError> {
Ok(Self {
inner: wc::MedianChannel::new(period, multiplier).map_err(map_err)?,
})
}
pub fn update(&mut self, value: f64) -> JsValue {
match self.inner.update(value) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok();
Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok();
Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
let n = prices.len();
let mut out = vec![f64::NAN; n * 3];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
Float64Array::from(out.as_slice())
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- Projection Bands (high/low input, 3 outputs) ----------
#[wasm_bindgen(js_name = ProjectionBands)]
pub struct WasmProjectionBands {
inner: wc::ProjectionBands,
}
#[wasm_bindgen(js_class = ProjectionBands)]
impl WasmProjectionBands {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmProjectionBands, JsError> {
Ok(Self {
inner: wc::ProjectionBands::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64) -> Result<JsValue, JsError> {
let candle = make_candle(high, low, low, 0.0)?;
match self.inner.update(candle) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok();
Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok();
Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok();
Ok(obj.into())
}
None => Ok(JsValue::NULL),
}
}
pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result<Float64Array, JsError> {
if high.len() != low.len() {
return Err(JsError::new("high and low must be equal length"));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
let candle = make_candle(high[i], low[i], low[i], 0.0)?;
if let Some(o) = self.inner.update(candle) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- Double Bollinger (scalar input, 5 outputs) ----------
#[wasm_bindgen(js_name = DoubleBollinger)]
@@ -10656,6 +11252,76 @@ wasm_scalar_indicator!(WasmTrendStrengthIndex, "TREND_STRENGTH_INDEX", wc::Trend
wasm_scalar_indicator!(WasmTsfOscillator, "TsfOscillator", wc::TsfOscillator, period: usize);
wasm_scalar_indicator!(WasmMacdHistogram, "MacdHistogram", wc::MacdHistogram, fast: usize, slow: usize, signal: usize);
wasm_scalar_indicator!(WasmPpoHistogram, "PpoHistogram", wc::PpoHistogram, fast: usize, slow: usize, signal: usize);
wasm_scalar_indicator!(WasmBipowerVariation, "BipowerVariation", wc::BipowerVariation, period: usize);
wasm_scalar_indicator!(WasmEwmaVolatility, "EwmaVolatility", wc::EwmaVolatility, lambda: f64);
wasm_scalar_indicator!(WasmGarch11, "Garch11", wc::Garch11, omega: f64, alpha: f64, beta: f64);
wasm_scalar_indicator!(WasmVolatilityOfVolatility, "VolatilityOfVolatility", wc::VolatilityOfVolatility, vol_window: usize, vov_window: usize);
// --- VolatilityCone: Candle in, struct out (current/min/median/max/percentile) ---
#[wasm_bindgen(js_name = VolatilityCone)]
pub struct WasmVolatilityCone {
inner: wc::VolatilityCone,
}
#[wasm_bindgen(js_class = VolatilityCone)]
impl WasmVolatilityCone {
#[wasm_bindgen(constructor)]
pub fn new(window: usize, lookback: usize) -> Result<WasmVolatilityCone, JsError> {
Ok(Self {
inner: wc::VolatilityCone::new(window, lookback).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(match self.inner.update(c) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"current".into(), &o.current.into()).ok();
Reflect::set(&obj, &"min".into(), &o.min.into()).ok();
Reflect::set(&obj, &"median".into(), &o.median.into()).ok();
Reflect::set(&obj, &"max".into(), &o.max.into()).ok();
Reflect::set(&obj, &"percentile".into(), &o.percentile.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
if low.len() != n || close.len() != n {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = vec![f64::NAN; n * 5];
for i in 0..n {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
if let Some(o) = self.inner.update(c) {
out[i * 5] = o.current;
out[i * 5 + 1] = o.min;
out[i * 5 + 2] = o.median;
out[i * 5 + 3] = o.max;
out[i * 5 + 4] = o.percentile;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// --- DrawdownDuration: u32 output, no constructor args ---
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "wickra-bench"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
description = "Internal cross-library benchmark harness (not published)."
[lints]
workspace = true
[dev-dependencies]
wickra = { path = "../wickra" }
wickra-data = { path = "../wickra-data" }
criterion = { workspace = true }
kand = "0.2.2"
ta = "0.5.0"
yata = "0.7.0"
[[bench]]
name = "cross_lib"
harness = false
+695
View File
@@ -0,0 +1,695 @@
//! Cross-library Criterion benchmark: Wickra vs `kand` vs `ta` (ta-rs) vs `yata`.
//!
//! All four are pure-Rust technical-analysis crates, so this is a like-for-like
//! Rust-vs-Rust comparison with no language-binding overhead. It feeds the exact
//! same BTCUSDT 1-minute candle series used by `crates/wickra/benches/indicators.rs`.
//!
//! Two arenas, kept honest:
//!
//! * **Streaming** (`*/stream`): one value fed at a time. Wickra (`Indicator::update`),
//! ta-rs (`Next::next`) and yata (`Method::next`) carry their own state; `kand`
//! exposes stateless `*_inc` helpers, so the per-tick state is threaded manually
//! here, seeded from `kand`'s own batch output (the seed is computed outside the
//! timed closure). yata only appears for SMA/EMA — its RSI/MACD/Bollinger/ATR are
//! exposed through a heavier signal-oriented indicator API, not a raw-value method,
//! so they are intentionally left out rather than compared unfairly.
//! * **Batch** (`*/batch`): the whole series at once. Only Wickra (`BatchExt::batch`)
//! and `kand` (TA-Lib-style fill-the-output-slice functions) have a real batch API;
//! ta-rs and yata are streaming-only and are deliberately absent from this arena.
//!
//! Run: `cargo bench -p wickra-bench`
// Each indicator's benchmark group spells out every library arm explicitly, which
// runs a few groups over the 100-line lint threshold; that verbosity is the point.
#![allow(clippy::too_many_lines)]
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use std::hint::black_box;
use wickra::{Atr, BatchExt, BollingerBands, Candle, Ema, Indicator, MacdIndicator, Rsi, Sma};
use wickra_data::csv::CandleReader;
use yata::prelude::Method;
const SIZES: &[usize] = &[1_000, 10_000, 50_000];
const SMA_PERIOD: usize = 20;
const EMA_PERIOD: usize = 20;
const RSI_PERIOD: usize = 14;
const ATR_PERIOD: usize = 14;
const BB_PERIOD: usize = 20;
const BB_DEV: f64 = 2.0;
const MACD_FAST: usize = 12;
const MACD_SLOW: usize = 26;
const MACD_SIGNAL: usize = 9;
fn load_candles() -> Vec<Candle> {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../examples/data/btcusdt-1m.csv"
);
CandleReader::open(path)
.expect("dataset present")
.read_all()
.expect("valid OHLCV rows")
}
/// Mean of the first `period` samples — the warmup seed for `kand`'s SMA/EMA `*_inc`.
fn window_mean(series: &[f64], period: usize) -> f64 {
series[..period].iter().sum::<f64>() / period as f64
}
fn sma_group(crit: &mut Criterion, closes: &[f64]) {
let mut group = crit.benchmark_group("sma_20");
for &len in SIZES {
let len = len.min(closes.len());
let series: &[f64] = &closes[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Sma::new(SMA_PERIOD).unwrap();
for &price in series {
black_box(ind.update(price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Sma::new(SMA_PERIOD).unwrap();
black_box(ind.batch(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
let seed = window_mean(series, SMA_PERIOD);
bencher.iter(|| {
let mut prev = seed;
for idx in SMA_PERIOD..series.len() {
prev = kand::ohlcv::sma::sma_inc(
prev,
series[idx],
series[idx - SMA_PERIOD],
SMA_PERIOD,
)
.unwrap();
black_box(prev);
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut out = vec![0.0; series.len()];
kand::ohlcv::sma::sma(series, SMA_PERIOD, &mut out).unwrap();
black_box(&out);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = ta::indicators::SimpleMovingAverage::new(SMA_PERIOD).unwrap();
for &price in series {
black_box(ta::Next::next(&mut ind, price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("yata/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = yata::methods::SMA::new(SMA_PERIOD as u8, &series[0]).unwrap();
for price in series {
black_box(ind.next(price));
}
});
},
);
}
group.finish();
}
fn ema_group(crit: &mut Criterion, closes: &[f64]) {
let mut group = crit.benchmark_group("ema_20");
for &len in SIZES {
let len = len.min(closes.len());
let series: &[f64] = &closes[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Ema::new(EMA_PERIOD).unwrap();
for &price in series {
black_box(ind.update(price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Ema::new(EMA_PERIOD).unwrap();
black_box(ind.batch(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
let seed = window_mean(series, EMA_PERIOD);
bencher.iter(|| {
let mut prev = seed;
for &price in &series[EMA_PERIOD..] {
prev = kand::ohlcv::ema::ema_inc(price, prev, EMA_PERIOD, None).unwrap();
black_box(prev);
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut out = vec![0.0; series.len()];
kand::ohlcv::ema::ema(series, EMA_PERIOD, None, &mut out).unwrap();
black_box(&out);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind =
ta::indicators::ExponentialMovingAverage::new(EMA_PERIOD).unwrap();
for &price in series {
black_box(ta::Next::next(&mut ind, price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("yata/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = yata::methods::EMA::new(EMA_PERIOD as u8, &series[0]).unwrap();
for price in series {
black_box(ind.next(price));
}
});
},
);
}
group.finish();
}
fn rsi_group(crit: &mut Criterion, closes: &[f64]) {
let mut group = crit.benchmark_group("rsi_14");
for &len in SIZES {
let len = len.min(closes.len());
let series: &[f64] = &closes[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Rsi::new(RSI_PERIOD).unwrap();
for &price in series {
black_box(ind.update(price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Rsi::new(RSI_PERIOD).unwrap();
black_box(ind.batch(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
// Wilder seed: simple average of the first `period` gains and losses.
let mut gain = 0.0;
let mut loss = 0.0;
for idx in 1..=RSI_PERIOD {
let delta = series[idx] - series[idx - 1];
if delta > 0.0 {
gain += delta;
} else {
loss -= delta;
}
}
let seed_gain = gain / RSI_PERIOD as f64;
let seed_loss = loss / RSI_PERIOD as f64;
bencher.iter(|| {
let mut avg_gain = seed_gain;
let mut avg_loss = seed_loss;
let mut prev_price = series[RSI_PERIOD];
for &price in &series[RSI_PERIOD + 1..] {
let (rsi, next_gain, next_loss) = kand::ohlcv::rsi::rsi_inc(
price, prev_price, avg_gain, avg_loss, RSI_PERIOD,
)
.unwrap();
avg_gain = next_gain;
avg_loss = next_loss;
prev_price = price;
black_box(rsi);
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut rsi = vec![0.0; series.len()];
let mut avg_gain = vec![0.0; series.len()];
let mut avg_loss = vec![0.0; series.len()];
kand::ohlcv::rsi::rsi(
series,
RSI_PERIOD,
&mut rsi,
&mut avg_gain,
&mut avg_loss,
)
.unwrap();
black_box(&rsi);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = ta::indicators::RelativeStrengthIndex::new(RSI_PERIOD).unwrap();
for &price in series {
black_box(ta::Next::next(&mut ind, price));
}
});
},
);
}
group.finish();
}
fn macd_group(crit: &mut Criterion, closes: &[f64]) {
let mut group = crit.benchmark_group("macd_12_26_9");
for &len in SIZES {
let len = len.min(closes.len());
let series: &[f64] = &closes[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = MacdIndicator::classic();
for &price in series {
black_box(ind.update(price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = MacdIndicator::classic();
black_box(ind.batch(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
// Seed the fast/slow/signal EMAs from kand's own warmed-up batch state.
let lookback =
kand::ohlcv::macd::lookback(MACD_FAST, MACD_SLOW, MACD_SIGNAL).unwrap();
let mut macd_line = vec![0.0; series.len()];
let mut signal_line = vec![0.0; series.len()];
let mut histogram = vec![0.0; series.len()];
let mut fast_ema = vec![0.0; series.len()];
let mut slow_ema = vec![0.0; series.len()];
kand::ohlcv::macd::macd(
series,
MACD_FAST,
MACD_SLOW,
MACD_SIGNAL,
&mut macd_line,
&mut signal_line,
&mut histogram,
&mut fast_ema,
&mut slow_ema,
)
.unwrap();
let seed_fast = fast_ema[lookback];
let seed_slow = slow_ema[lookback];
let seed_signal = signal_line[lookback];
bencher.iter(|| {
// macd_inc returns (macd, signal, hist) but not the new EMAs, so the
// fast/slow/signal state is threaded with kand's own ema_inc primitive.
let mut prev_fast = seed_fast;
let mut prev_slow = seed_slow;
let mut prev_signal = seed_signal;
for &price in &series[lookback + 1..] {
let fast =
kand::ohlcv::ema::ema_inc(price, prev_fast, MACD_FAST, None).unwrap();
let slow =
kand::ohlcv::ema::ema_inc(price, prev_slow, MACD_SLOW, None).unwrap();
let macd = fast - slow;
let signal =
kand::ohlcv::ema::ema_inc(macd, prev_signal, MACD_SIGNAL, None)
.unwrap();
prev_fast = fast;
prev_slow = slow;
prev_signal = signal;
black_box((macd, signal, macd - signal));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut macd_line = vec![0.0; series.len()];
let mut signal_line = vec![0.0; series.len()];
let mut histogram = vec![0.0; series.len()];
let mut fast_ema = vec![0.0; series.len()];
let mut slow_ema = vec![0.0; series.len()];
kand::ohlcv::macd::macd(
series,
MACD_FAST,
MACD_SLOW,
MACD_SIGNAL,
&mut macd_line,
&mut signal_line,
&mut histogram,
&mut fast_ema,
&mut slow_ema,
)
.unwrap();
black_box(&macd_line);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = ta::indicators::MovingAverageConvergenceDivergence::new(
MACD_FAST,
MACD_SLOW,
MACD_SIGNAL,
)
.unwrap();
for &price in series {
black_box(ta::Next::next(&mut ind, price));
}
});
},
);
}
group.finish();
}
fn bbands_group(crit: &mut Criterion, closes: &[f64]) {
let mut group = crit.benchmark_group("bollinger_20_2");
for &len in SIZES {
let len = len.min(closes.len());
let series: &[f64] = &closes[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = BollingerBands::new(BB_PERIOD, BB_DEV).unwrap();
for &price in series {
black_box(ind.update(price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = BollingerBands::new(BB_PERIOD, BB_DEV).unwrap();
black_box(ind.batch(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
// Seed running sma/sum/sum_sq from kand's batch state at the warmup edge.
let mut upper = vec![0.0; series.len()];
let mut middle = vec![0.0; series.len()];
let mut lower = vec![0.0; series.len()];
let mut sma = vec![0.0; series.len()];
let mut variance = vec![0.0; series.len()];
let mut sum = vec![0.0; series.len()];
let mut sum_sq = vec![0.0; series.len()];
kand::ohlcv::bbands::bbands(
series,
BB_PERIOD,
BB_DEV,
BB_DEV,
&mut upper,
&mut middle,
&mut lower,
&mut sma,
&mut variance,
&mut sum,
&mut sum_sq,
)
.unwrap();
let seed_sma = sma[BB_PERIOD - 1];
let seed_sum = sum[BB_PERIOD - 1];
let seed_sum_sq = sum_sq[BB_PERIOD - 1];
bencher.iter(|| {
let mut prev_sma = seed_sma;
let mut prev_sum = seed_sum;
let mut prev_sum_sq = seed_sum_sq;
for idx in BB_PERIOD..series.len() {
let result = kand::ohlcv::bbands::bbands_inc(
series[idx],
prev_sma,
prev_sum,
prev_sum_sq,
series[idx - BB_PERIOD],
BB_PERIOD,
BB_DEV,
BB_DEV,
)
.unwrap();
prev_sma = result.1;
prev_sum = result.4;
prev_sum_sq = result.5;
black_box((result.0, result.1, result.2));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut upper = vec![0.0; series.len()];
let mut middle = vec![0.0; series.len()];
let mut lower = vec![0.0; series.len()];
let mut sma = vec![0.0; series.len()];
let mut variance = vec![0.0; series.len()];
let mut sum = vec![0.0; series.len()];
let mut sum_sq = vec![0.0; series.len()];
kand::ohlcv::bbands::bbands(
series,
BB_PERIOD,
BB_DEV,
BB_DEV,
&mut upper,
&mut middle,
&mut lower,
&mut sma,
&mut variance,
&mut sum,
&mut sum_sq,
)
.unwrap();
black_box(&upper);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = ta::indicators::BollingerBands::new(BB_PERIOD, BB_DEV).unwrap();
for &price in series {
black_box(ta::Next::next(&mut ind, price));
}
});
},
);
}
group.finish();
}
fn atr_group(crit: &mut Criterion, candles: &[Candle]) {
let mut group = crit.benchmark_group("atr_14");
for &len in SIZES {
let len = len.min(candles.len());
let series: &[Candle] = &candles[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Atr::new(ATR_PERIOD).unwrap();
for &candle in series {
black_box(ind.update(candle));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Atr::new(ATR_PERIOD).unwrap();
black_box(ind.batch(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
let high: Vec<f64> = series.iter().map(|candle| candle.high).collect();
let low: Vec<f64> = series.iter().map(|candle| candle.low).collect();
let close: Vec<f64> = series.iter().map(|candle| candle.close).collect();
// Seed prev_atr from kand's batch ATR at the first valid index (= period).
let mut atr_out = vec![0.0; series.len()];
kand::ohlcv::atr::atr(&high, &low, &close, ATR_PERIOD, &mut atr_out).unwrap();
let seed_atr = atr_out[ATR_PERIOD];
bencher.iter(|| {
let mut prev_atr = seed_atr;
for idx in ATR_PERIOD + 1..series.len() {
prev_atr = kand::ohlcv::atr::atr_inc(
high[idx],
low[idx],
close[idx - 1],
prev_atr,
ATR_PERIOD,
)
.unwrap();
black_box(prev_atr);
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
let high: Vec<f64> = series.iter().map(|candle| candle.high).collect();
let low: Vec<f64> = series.iter().map(|candle| candle.low).collect();
let close: Vec<f64> = series.iter().map(|candle| candle.close).collect();
bencher.iter(|| {
let mut atr_out = vec![0.0; series.len()];
kand::ohlcv::atr::atr(&high, &low, &close, ATR_PERIOD, &mut atr_out).unwrap();
black_box(&atr_out);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
let items: Vec<ta::DataItem> = series
.iter()
.map(|candle| {
ta::DataItem::builder()
.open(candle.open)
.high(candle.high)
.low(candle.low)
.close(candle.close)
.volume(candle.volume)
.build()
.unwrap()
})
.collect();
bencher.iter(|| {
let mut ind = ta::indicators::AverageTrueRange::new(ATR_PERIOD).unwrap();
for item in &items {
black_box(ta::Next::next(&mut ind, item));
}
});
},
);
}
group.finish();
}
fn benches(crit: &mut Criterion) {
let candles = load_candles();
let closes: Vec<f64> = candles.iter().map(|candle| candle.close).collect();
sma_group(crit, &closes);
ema_group(crit, &closes);
rsi_group(crit, &closes);
macd_group(crit, &closes);
bbands_group(crit, &closes);
atr_group(crit, &candles);
}
criterion_group!(name = cross_lib; config = Criterion::default(); targets = benches);
criterion_main!(cross_lib);
+6
View File
@@ -0,0 +1,6 @@
//! Internal cross-library benchmark harness for Wickra.
//!
//! This crate is `publish = false`. It exists only to host the Criterion
//! benchmark in `benches/cross_lib.rs`, which compares Wickra against the
//! Rust technical-analysis crates `kand`, `ta` (ta-rs) and `yata` on an
//! identical candle series. It deliberately carries no library code.
+27 -10
View File
@@ -28,9 +28,17 @@ use crate::traits::Indicator;
#[derive(Debug, Clone)]
pub struct Atr {
period: usize,
/// `period - 1` as `f64`, precomputed for the Wilder smoothing step.
n_minus_1: f64,
/// `1 / period`, precomputed so the per-tick smoothing multiplies instead of
/// divides.
inv_period: f64,
prev_close: Option<f64>,
seed_buf: Vec<f64>,
avg: Option<f64>,
/// Smoothed ATR, valid once `seeded` is set. Bare `f64` + flag rather than
/// `Option<f64>` so the hot recurrence avoids an enum-tag read per tick.
avg: f64,
seeded: bool,
}
impl Atr {
@@ -45,9 +53,12 @@ impl Atr {
}
Ok(Self {
period,
n_minus_1: (period - 1) as f64,
inv_period: 1.0 / period as f64,
prev_close: None,
seed_buf: Vec::with_capacity(period),
avg: None,
avg: 0.0,
seeded: false,
})
}
@@ -58,7 +69,11 @@ impl Atr {
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.avg
if self.seeded {
Some(self.avg)
} else {
None
}
}
}
@@ -70,17 +85,18 @@ impl Indicator for Atr {
let tr = candle.true_range(self.prev_close);
self.prev_close = Some(candle.close);
if let Some(avg) = self.avg {
let n = self.period as f64;
let new_avg = avg.mul_add(n - 1.0, tr) / n;
self.avg = Some(new_avg);
if self.seeded {
// Wilder smoothing with the reciprocal hoisted out of the hot path.
let new_avg = self.avg.mul_add(self.n_minus_1, tr) * self.inv_period;
self.avg = new_avg;
return Some(new_avg);
}
self.seed_buf.push(tr);
if self.seed_buf.len() == self.period {
let seed = self.seed_buf.iter().copied().sum::<f64>() / self.period as f64;
self.avg = Some(seed);
self.avg = seed;
self.seeded = true;
return Some(seed);
}
None
@@ -89,7 +105,8 @@ impl Indicator for Atr {
fn reset(&mut self) {
self.prev_close = None;
self.seed_buf.clear();
self.avg = None;
self.avg = 0.0;
self.seeded = false;
}
fn warmup_period(&self) -> usize {
@@ -97,7 +114,7 @@ impl Indicator for Atr {
}
fn is_ready(&self) -> bool {
self.avg.is_some()
self.seeded
}
fn name(&self) -> &'static str {
@@ -0,0 +1,279 @@
//! ATR Ratchet (Kaufman) — a trailing stop that creeps toward price each bar.
use crate::error::{Error, Result};
use crate::indicators::atr::Atr;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`AtrRatchet`]: the active stop level and the trend direction.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AtrRatchetOutput {
/// The ratchet stop level — below price when long, above price when short.
pub value: f64,
/// Trend direction: `+1.0` long, `-1.0` short.
pub direction: f64,
}
/// ATR Ratchet — Perry Kaufman's time-based volatility stop that tightens by a
/// fixed fraction of ATR **every bar**, whether or not price moves.
///
/// ```text
/// on entry (long): stop = close start_mult · ATR
/// each later bar: stop = stop + increment · ATR (ratchets toward price)
/// flip to short when close < stop, reseeding stop = close + start_mult · ATR
/// ```
///
/// Most trailing stops only move when price makes a new extreme. Kaufman's ratchet
/// instead advances the stop a little each bar — `increment · ATR` — so a trade
/// that stalls is squeezed out over time even in a flat market. The initial
/// distance (`start_mult · ATR`) gives the position room to breathe; the per-bar
/// `increment` controls how aggressively the leash shortens. When price closes
/// through the stop the system reverses and reseeds at the full initial distance.
///
/// The first stop lands once ATR is ready (`atr_period` inputs). Each `update` is
/// O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AtrRatchet};
///
/// let mut indicator = AtrRatchet::new(14, 4.0, 0.1).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AtrRatchet {
atr: Atr,
atr_period: usize,
start_mult: f64,
increment: f64,
direction: f64,
stop: f64,
last: Option<AtrRatchetOutput>,
}
impl AtrRatchet {
/// Construct an ATR Ratchet stop.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `atr_period == 0` and
/// [`Error::NonPositiveMultiplier`] if `start_mult` or `increment` is not
/// finite and positive.
pub fn new(atr_period: usize, start_mult: f64, increment: f64) -> Result<Self> {
if !start_mult.is_finite()
|| start_mult <= 0.0
|| !increment.is_finite()
|| increment <= 0.0
{
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
atr: Atr::new(atr_period)?,
atr_period,
start_mult,
increment,
direction: 0.0,
stop: 0.0,
last: None,
})
}
/// Configured `(atr_period, start_mult, increment)`.
pub const fn params(&self) -> (usize, f64, f64) {
(self.atr_period, self.start_mult, self.increment)
}
/// Current value if available.
pub const fn value(&self) -> Option<AtrRatchetOutput> {
self.last
}
}
impl Indicator for AtrRatchet {
type Input = Candle;
type Output = AtrRatchetOutput;
fn update(&mut self, candle: Candle) -> Option<AtrRatchetOutput> {
let atr = self.atr.update(candle)?;
let close = candle.close;
if self.direction == 0.0 {
self.direction = 1.0;
self.stop = close - self.start_mult * atr;
} else if self.direction > 0.0 {
self.stop += self.increment * atr;
if close < self.stop {
self.direction = -1.0;
self.stop = close + self.start_mult * atr;
}
} else {
self.stop -= self.increment * atr;
if close > self.stop {
self.direction = 1.0;
self.stop = close - self.start_mult * atr;
}
}
let out = AtrRatchetOutput {
value: self.stop,
direction: self.direction,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.atr.reset();
self.direction = 0.0;
self.stop = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.atr_period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"AtrRatchet"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0)
}
#[test]
fn rejects_invalid_params() {
assert!(matches!(
AtrRatchet::new(0, 4.0, 0.1),
Err(Error::PeriodZero)
));
assert!(matches!(
AtrRatchet::new(14, 0.0, 0.1),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
AtrRatchet::new(14, 4.0, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
AtrRatchet::new(14, 4.0, f64::NAN),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let r = AtrRatchet::new(14, 4.0, 0.1).unwrap();
assert_eq!(r.params(), (14, 4.0, 0.1));
assert_eq!(r.warmup_period(), 14);
assert_eq!(r.name(), "AtrRatchet");
assert!(!r.is_ready());
assert_eq!(r.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut r = AtrRatchet::new(5, 4.0, 0.1).unwrap();
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base)
})
.collect();
let out = r.batch(&candles);
for v in out.iter().take(4) {
assert!(v.is_none());
}
assert!(out[4].is_some());
}
#[test]
fn uptrend_keeps_stop_below_price() {
let mut r = AtrRatchet::new(5, 4.0, 0.05).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + 2.0 * f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
for (o, candle) in r.batch(&candles).into_iter().zip(candles.iter()) {
if let Some(o) = o {
assert_eq!(o.direction, 1.0);
assert!(o.value < candle.close);
}
}
}
#[test]
fn stall_eventually_triggers_flip() {
// A long trend then a long flat stretch: the ratchet creeps up each bar
// and eventually overtakes the flat close, flipping to short.
let mut r = AtrRatchet::new(5, 2.0, 0.5).unwrap();
let mut candles: Vec<Candle> = (0..20)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
// Flat stretch at the last price.
candles.extend((0..40).map(|_| c(120.6, 118.6, 119.5)));
let dirs: Vec<f64> = r
.batch(&candles)
.into_iter()
.flatten()
.map(|o| o.direction)
.collect();
assert!(
dirs.iter().any(|&d| d < 0.0),
"the ratchet should eventually flip short"
);
}
#[test]
fn reset_clears_state() {
let mut r = AtrRatchet::new(5, 4.0, 0.1).unwrap();
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
r.batch(&candles);
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.value(), None);
assert_eq!(r.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
c(base + 2.0, base - 1.5, base + 0.5)
})
.collect();
let batch = AtrRatchet::new(14, 4.0, 0.1).unwrap().batch(&candles);
let mut b = AtrRatchet::new(14, 4.0, 0.1).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,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_{t1})
/// BV = (π / 2) · Σ |r_t| · |r_{t1}| 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);
}
}
+34 -14
View File
@@ -1,7 +1,5 @@
//! Bollinger Bands.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
@@ -49,7 +47,13 @@ pub struct BollingerOutput {
pub struct BollingerBands {
period: usize,
multiplier: f64,
window: VecDeque<f64>,
/// Fixed-capacity ring buffer of the last `period` finite inputs. A flat
/// `Box<[f64]>` with a manual write cursor beats `VecDeque` on this hot path.
buf: Box<[f64]>,
/// Index of the next slot to write — also the oldest element once full.
head: usize,
/// Number of slots filled, saturating at `period`.
count: usize,
sum: f64,
sum_sq: f64,
/// Number of finite updates since the running sums were last reseeded
@@ -80,7 +84,9 @@ impl BollingerBands {
Ok(Self {
period,
multiplier,
window: VecDeque::with_capacity(period),
buf: vec![0.0; period].into_boxed_slice(),
head: 0,
count: 0,
sum: 0.0,
sum_sq: 0.0,
updates_since_recompute: 0,
@@ -103,7 +109,7 @@ impl BollingerBands {
}
fn current(&self) -> Option<BollingerOutput> {
if self.window.len() != self.period {
if self.count != self.period {
return None;
}
let n = self.period as f64;
@@ -129,25 +135,38 @@ impl Indicator for BollingerBands {
if !input.is_finite() {
return self.current();
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
if self.count == self.period {
let old = self.buf[self.head];
self.sum -= old;
self.sum_sq -= old * old;
self.buf[self.head] = input;
self.sum += input;
self.sum_sq += input * input;
} else {
self.buf[self.head] = input;
self.sum += input;
self.sum_sq += input * input;
self.count += 1;
}
self.head += 1;
if self.head == self.period {
self.head = 0;
}
self.window.push_back(input);
self.sum += input;
self.sum_sq += input * input;
self.updates_since_recompute += 1;
if self.updates_since_recompute >= RECOMPUTE_EVERY * self.period {
self.sum = self.window.iter().copied().sum();
self.sum_sq = self.window.iter().copied().map(|x| x * x).sum();
// Reseed in chronological order (oldest at `head`) to keep the running
// sums bit-equivalent to a fresh from-scratch pass on stable inputs.
let chronological = self.buf[self.head..].iter().chain(&self.buf[..self.head]);
self.sum = chronological.clone().copied().sum();
self.sum_sq = chronological.map(|&x| x * x).sum();
self.updates_since_recompute = 0;
}
self.current()
}
fn reset(&mut self) {
self.window.clear();
self.head = 0;
self.count = 0;
self.sum = 0.0;
self.sum_sq = 0.0;
self.updates_since_recompute = 0;
@@ -158,7 +177,7 @@ impl Indicator for BollingerBands {
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
self.count == self.period
}
fn name(&self) -> &'static str {
@@ -171,6 +190,7 @@ mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
use std::collections::VecDeque;
fn naive(prices: &[f64], period: usize, mult: f64) -> BollingerOutput {
assert!(
@@ -0,0 +1,256 @@
//! Bomar Bands — adaptive percentage bands that contain a target fraction of
//! recent price.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::rolling_quantile::quantile_sorted;
use crate::traits::Indicator;
/// Bomar Bands output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BomarBandsOutput {
/// Upper band: `middle + |middle| · p`.
pub upper: f64,
/// Middle line: the simple moving average over the window.
pub middle: f64,
/// Lower band: `middle |middle| · p`.
pub lower: f64,
}
/// Bomar Bands: percentage bands whose width adapts so that a fixed `coverage`
/// fraction of recent closes falls inside them.
///
/// The Bomar Bands predate Bollinger Bands; John Bollinger cites them as an
/// inspiration — percentage bands around a moving average, with the percentage
/// tuned so a fixed share (classically ~85%) of price stayed within. Wickra
/// realises that idea deterministically: the half-width is the `coverage`
/// quantile of the relative deviations from the midline, so by construction
/// `coverage` of the window's closes lie inside the bands.
///
/// ```text
/// middle = SMA(close, period)
/// dev_i = | close_i / middle 1 | // relative distance from midline
/// p = coverage-quantile of { dev_i } // type-7 interpolation
/// upper = middle + |middle| · p
/// lower = middle |middle| · p
/// ```
///
/// Unlike the fixed-percentage [`MaEnvelope`](crate::MaEnvelope), the offset
/// here is data-driven: the bands widen in turbulent regimes and tighten in
/// quiet ones without a volatility input. Unlike Bollinger Bands, the width is
/// an order statistic of the actual deviations rather than a multiple of the
/// standard deviation, so it is unaffected by the shape of the tails beyond the
/// `coverage` rank. When the midline is zero the relative deviation is
/// undefined and the bands collapse onto the midline.
///
/// # Example
///
/// ```
/// use wickra_core::{BomarBands, Indicator};
///
/// let mut indicator = BomarBands::new(20, 0.85).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i % 7));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct BomarBands {
period: usize,
coverage: f64,
window: VecDeque<f64>,
scratch: Vec<f64>,
}
impl BomarBands {
/// Construct new Bomar Bands.
///
/// `coverage` is the target fraction of closes to contain, in `(0.0, 1.0]`.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`, or
/// [`Error::InvalidParameter`] if `coverage` is not a finite value in
/// `(0.0, 1.0]`.
pub fn new(period: usize, coverage: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !coverage.is_finite() || coverage <= 0.0 || coverage > 1.0 {
return Err(Error::InvalidParameter {
message: "bomar bands coverage must be a finite value in (0.0, 1.0]",
});
}
Ok(Self {
period,
coverage,
window: VecDeque::with_capacity(period),
scratch: Vec::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured coverage fraction.
pub const fn coverage(&self) -> f64 {
self.coverage
}
}
impl Indicator for BomarBands {
type Input = f64;
type Output = BomarBandsOutput;
fn update(&mut self, value: f64) -> Option<BomarBandsOutput> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
let sum: f64 = self.window.iter().sum();
let middle = sum / (self.period as f64);
let denom = middle.abs();
self.scratch.clear();
for &v in &self.window {
let dev = if denom == 0.0 {
0.0
} else {
((v - middle) / denom).abs()
};
self.scratch.push(dev);
}
self.scratch.sort_by(f64::total_cmp);
let p = quantile_sorted(&self.scratch, self.coverage);
let offset = denom * p;
Some(BomarBandsOutput {
upper: middle + offset,
middle,
lower: middle - offset,
})
}
fn reset(&mut self) {
self.window.clear();
self.scratch.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"BomarBands"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(BomarBands::new(0, 0.85), Err(Error::PeriodZero)));
assert!(BomarBands::new(1, 0.85).is_ok());
}
#[test]
fn rejects_out_of_range_coverage() {
assert!(matches!(
BomarBands::new(20, 0.0),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
BomarBands::new(20, 1.1),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
BomarBands::new(20, -0.5),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
BomarBands::new(20, f64::NAN),
Err(Error::InvalidParameter { .. })
));
}
#[test]
fn accessors_and_metadata() {
let bb = BomarBands::new(20, 0.85).unwrap();
assert_eq!(bb.period(), 20);
assert_relative_eq!(bb.coverage(), 0.85, epsilon = 1e-12);
assert_eq!(bb.warmup_period(), 20);
assert_eq!(bb.name(), "BomarBands");
assert!(!bb.is_ready());
}
#[test]
fn warms_up_then_emits() {
let mut bb = BomarBands::new(4, 0.85).unwrap();
assert!(bb.update(100.0).is_none());
assert!(bb.update(102.0).is_none());
assert!(bb.update(98.0).is_none());
assert!(bb.update(104.0).is_some());
assert!(bb.is_ready());
}
#[test]
fn known_bands() {
// mean=101; |dev| = {1,1,3,3}/101; coverage 0.85 quantile -> 3/101.
// offset = 101 * 3/101 = 3 -> upper 104, lower 98.
let mut bb = BomarBands::new(4, 0.85).unwrap();
let out = bb.batch(&[100.0, 102.0, 98.0, 104.0]);
let last = out[3].unwrap();
assert_relative_eq!(last.middle, 101.0, epsilon = 1e-9);
assert_relative_eq!(last.upper, 104.0, epsilon = 1e-9);
assert_relative_eq!(last.lower, 98.0, epsilon = 1e-9);
}
#[test]
fn zero_midline_collapses_bands() {
// Window mean exactly zero -> relative deviation undefined -> collapse.
let mut bb = BomarBands::new(2, 0.85).unwrap();
let out = bb.batch(&[3.0, -3.0]);
let last = out[1].unwrap();
assert_relative_eq!(last.middle, 0.0, epsilon = 1e-12);
assert_relative_eq!(last.upper, 0.0, epsilon = 1e-12);
assert_relative_eq!(last.lower, 0.0, epsilon = 1e-12);
}
#[test]
fn rolling_window_evicts_oldest() {
// Eight values through a period-4 window: only the last four survive,
// reproducing the `known_bands` window.
let mut bb = BomarBands::new(4, 0.85).unwrap();
let out = bb.batch(&[50.0, 50.0, 50.0, 50.0, 100.0, 102.0, 98.0, 104.0]);
let last = out[7].unwrap();
assert_relative_eq!(last.middle, 101.0, epsilon = 1e-9);
assert_relative_eq!(last.upper, 104.0, epsilon = 1e-9);
assert_relative_eq!(last.lower, 98.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut bb = BomarBands::new(4, 0.85).unwrap();
for v in [100.0, 102.0, 98.0, 104.0] {
bb.update(v);
}
assert!(bb.is_ready());
bb.reset();
assert!(!bb.is_ready());
assert!(bb.update(100.0).is_none());
}
}
@@ -0,0 +1,360 @@
//! Elder `SafeZone` Stop — a trailing stop set by the average noise penetration.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`ElderSafeZone`]: the active stop level and the trend direction.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ElderSafeZoneOutput {
/// The `SafeZone` stop level — below price when long, above price when short.
pub value: f64,
/// Trend direction: `+1.0` long, `-1.0` short.
pub direction: f64,
}
/// Elder `SafeZone` Stop — Alexander Elder's stop placed a multiple of the
/// **average market noise** away from price.
///
/// ```text
/// long market noise = average downside penetration = mean( prev_low low | low < prev_low )
/// short market noise = average upside penetration = mean( high prev_high | high > prev_high )
/// long stop = ratchet_up( low_t coeff · avg_down_penetration )
/// short stop = ratchet_down( high_t + coeff · avg_up_penetration )
/// ```
///
/// Elder defines *noise* in an uptrend as the part of each bar that pokes below
/// the previous bar's low (a "downside penetration"). Averaging those
/// penetrations over a lookback and placing the stop `coeff` multiples below the
/// current low keeps the stop just outside normal pullbacks while still exiting on
/// a genuine reversal. The stop trails in the trend's favour and flips when price
/// closes through it. The average uses only the bars that actually penetrated
/// (Elder's definition), so a noiseless trend gives a tight stop at the bar's
/// extreme.
///
/// The first bar seeds the prior candle; the next `period` bars accumulate the
/// penetration statistics, so the first stop lands after `period + 1` inputs.
/// Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ElderSafeZone};
///
/// let mut indicator = ElderSafeZone::new(14, 2.0).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ElderSafeZone {
period: usize,
coeff: f64,
prev: Option<Candle>,
down_pen: VecDeque<f64>,
up_pen: VecDeque<f64>,
down_sum: f64,
up_sum: f64,
down_count: usize,
up_count: usize,
direction: f64,
stop: f64,
last: Option<ElderSafeZoneOutput>,
}
impl ElderSafeZone {
/// Construct an Elder `SafeZone` stop with the given averaging `period` and
/// noise `coeff`icient.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0` and
/// [`Error::NonPositiveMultiplier`] if `coeff` is not finite and positive.
pub fn new(period: usize, coeff: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !coeff.is_finite() || coeff <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
period,
coeff,
prev: None,
down_pen: VecDeque::with_capacity(period),
up_pen: VecDeque::with_capacity(period),
down_sum: 0.0,
up_sum: 0.0,
down_count: 0,
up_count: 0,
direction: 0.0,
stop: 0.0,
last: None,
})
}
/// Configured `(period, coeff)`.
pub const fn params(&self) -> (usize, f64) {
(self.period, self.coeff)
}
/// Current value if available.
pub const fn value(&self) -> Option<ElderSafeZoneOutput> {
self.last
}
fn push(window: &mut VecDeque<f64>, sum: &mut f64, count: &mut usize, period: usize, pen: f64) {
if window.len() == period {
let old = window.pop_front().expect("non-empty");
*sum -= old;
if old > 0.0 {
*count -= 1;
}
}
window.push_back(pen);
*sum += pen;
if pen > 0.0 {
*count += 1;
}
}
fn avg(sum: f64, count: usize) -> f64 {
if count == 0 {
0.0
} else {
sum / count as f64
}
}
}
impl Indicator for ElderSafeZone {
type Input = Candle;
type Output = ElderSafeZoneOutput;
fn update(&mut self, candle: Candle) -> Option<ElderSafeZoneOutput> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
let dp = (prev.low - candle.low).max(0.0);
let up = (candle.high - prev.high).max(0.0);
self.prev = Some(candle);
Self::push(
&mut self.down_pen,
&mut self.down_sum,
&mut self.down_count,
self.period,
dp,
);
Self::push(
&mut self.up_pen,
&mut self.up_sum,
&mut self.up_count,
self.period,
up,
);
if self.down_pen.len() < self.period {
return None;
}
let avg_down = Self::avg(self.down_sum, self.down_count);
let avg_up = Self::avg(self.up_sum, self.up_count);
if self.direction == 0.0 {
self.direction = 1.0;
self.stop = candle.low - self.coeff * avg_down;
} else if self.direction > 0.0 {
let raw = candle.low - self.coeff * avg_down;
self.stop = self.stop.max(raw);
if candle.close < self.stop {
self.direction = -1.0;
self.stop = candle.high + self.coeff * avg_up;
}
} else {
let raw = candle.high + self.coeff * avg_up;
self.stop = self.stop.min(raw);
if candle.close > self.stop {
self.direction = 1.0;
self.stop = candle.low - self.coeff * avg_down;
}
}
let out = ElderSafeZoneOutput {
value: self.stop,
direction: self.direction,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.prev = None;
self.down_pen.clear();
self.up_pen.clear();
self.down_sum = 0.0;
self.up_sum = 0.0;
self.down_count = 0;
self.up_count = 0;
self.direction = 0.0;
self.stop = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"ElderSafeZone"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0)
}
#[test]
fn rejects_invalid_params() {
assert!(matches!(ElderSafeZone::new(0, 2.0), Err(Error::PeriodZero)));
assert!(matches!(
ElderSafeZone::new(14, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
ElderSafeZone::new(14, -1.0),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let e = ElderSafeZone::new(14, 2.0).unwrap();
assert_eq!(e.params(), (14, 2.0));
assert_eq!(e.warmup_period(), 15);
assert_eq!(e.name(), "ElderSafeZone");
assert!(!e.is_ready());
assert_eq!(e.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut e = ElderSafeZone::new(3, 2.0).unwrap();
let candles: Vec<Candle> = (0..8)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base)
})
.collect();
let out = e.batch(&candles);
let warmup = e.warmup_period(); // 4
assert_eq!(warmup, 4);
for v in out.iter().take(warmup - 1) {
assert!(v.is_none());
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn uptrend_keeps_stop_below_price() {
let mut e = ElderSafeZone::new(5, 2.0).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + 2.0 * f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
for (o, candle) in e.batch(&candles).into_iter().zip(candles.iter()) {
if let Some(o) = o {
assert_eq!(o.direction, 1.0);
assert!(o.value <= candle.close);
}
}
}
#[test]
fn noiseless_trend_stop_sits_at_low() {
// Every bar makes a higher low -> no downside penetration -> avg 0 ->
// the stop sits exactly at the bar's low.
let mut e = ElderSafeZone::new(3, 2.0).unwrap();
let candles: Vec<Candle> = (0..10)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
let out = e.batch(&candles);
let last_candle = candles.last().unwrap();
let last = out.last().unwrap().unwrap();
assert!((last.value - last_candle.low).abs() < 1e-9);
}
#[test]
fn flips_on_reversal() {
let mut candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
candles.extend((0..40).map(|i| {
let base = 140.0 - f64::from(i);
c(base + 1.0, base - 1.0, base - 0.5)
}));
let mut e = ElderSafeZone::new(5, 2.0).unwrap();
let dirs: Vec<f64> = e
.batch(&candles)
.into_iter()
.flatten()
.map(|o| o.direction)
.collect();
assert!(dirs.iter().any(|&d| d > 0.0));
assert!(dirs.iter().any(|&d| d < 0.0));
}
#[test]
fn reset_clears_state() {
let mut e = ElderSafeZone::new(5, 2.0).unwrap();
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
e.batch(&candles);
assert!(e.is_ready());
e.reset();
assert!(!e.is_ready());
assert_eq!(e.value(), None);
assert_eq!(e.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
c(base + 2.0, base - 1.5, base + 0.5)
})
.collect();
let batch = ElderSafeZone::new(14, 2.0).unwrap().batch(&candles);
let mut b = ElderSafeZone::new(14, 2.0).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
+31 -11
View File
@@ -25,7 +25,15 @@ use crate::traits::Indicator;
pub struct Ema {
period: usize,
alpha: f64,
state: Option<f64>,
/// `1 - alpha`, precomputed so the recurrence avoids a subtraction per tick.
/// Cached value, so the steady-state output is bit-for-bit unchanged.
one_minus_alpha: f64,
/// Latest EMA value, valid only once `seeded` is true. Stored as a bare `f64`
/// (plus the `seeded` flag) rather than `Option<f64>` so the steady-state
/// recurrence reads and writes 8 bytes with no enum-tag handling per tick.
current: f64,
/// Whether `current` holds a real value yet (warmup complete).
seeded: bool,
warmup_buf: Vec<f64>,
}
@@ -43,7 +51,9 @@ impl Ema {
Ok(Self {
period,
alpha,
state: None,
one_minus_alpha: 1.0 - alpha,
current: 0.0,
seeded: false,
warmup_buf: Vec::with_capacity(period),
})
}
@@ -66,7 +76,9 @@ impl Ema {
Ok(Self {
period: 1,
alpha,
state: None,
one_minus_alpha: 1.0 - alpha,
current: 0.0,
seeded: false,
warmup_buf: Vec::with_capacity(1),
})
}
@@ -83,21 +95,28 @@ impl Ema {
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.state
if self.seeded {
Some(self.current)
} else {
None
}
}
/// Internal helper that feeds a value without finiteness validation. The caller
/// guarantees `input.is_finite()`. Used by MACD which has already validated.
pub(crate) fn step_unchecked(&mut self, input: f64) -> Option<f64> {
if let Some(prev) = self.state {
let new = self.alpha.mul_add(input, (1.0 - self.alpha) * prev);
self.state = Some(new);
if self.seeded {
let new = self
.alpha
.mul_add(input, self.one_minus_alpha * self.current);
self.current = new;
return Some(new);
}
self.warmup_buf.push(input);
if self.warmup_buf.len() == self.period {
let seed = self.warmup_buf.iter().copied().sum::<f64>() / self.period as f64;
self.state = Some(seed);
self.current = seed;
self.seeded = true;
return Some(seed);
}
None
@@ -110,13 +129,14 @@ impl Indicator for Ema {
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.state;
return self.value();
}
self.step_unchecked(input)
}
fn reset(&mut self) {
self.state = None;
self.current = 0.0;
self.seeded = false;
self.warmup_buf.clear();
}
@@ -125,7 +145,7 @@ impl Indicator for Ema {
}
fn is_ready(&self) -> bool {
self.state.is_some()
self.seeded
}
fn name(&self) -> &'static str {
@@ -0,0 +1,264 @@
//! EWMA Volatility — `RiskMetrics` exponentially-weighted volatility.
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// EWMA Volatility — the `RiskMetrics` exponentially-weighted estimate of the
/// volatility of log returns.
///
/// ```text
/// r_t = ln(price_t / price_{t1})
/// σ²_t = λ · σ²_{t1} + (1 λ) · r²_t
/// EWMA = √σ²_t
/// ```
///
/// Unlike [`HistoricalVolatility`](crate::HistoricalVolatility) — an equally
/// weighted, mean-centred sample standard deviation over a fixed window — the
/// EWMA estimator weights recent squared returns geometrically by the decay
/// factor `λ`. The most recent return carries weight `1 λ`, the one before it
/// `λ(1 λ)`, and so on, so the estimate reacts to a volatility shock
/// immediately and then forgets it at rate `λ`. This is the J.P. Morgan
/// `RiskMetrics` one-parameter model; the standard daily decay is `λ = 0.94`
/// (monthly `0.97`). No mean is subtracted: squared returns *are* the variance
/// contribution, which matches the `RiskMetrics` assumption of a zero conditional
/// mean over short horizons.
///
/// The recursion is seeded with the first squared return (`σ²₁ = r²₁`) and emits
/// from the first return onward, so the very first reading is a one-observation
/// estimate that the decay then refines. Each `update` is O(1).
///
/// Non-finite and non-positive prices are ignored (the log return would be
/// undefined): the tick is dropped, state is left untouched, and the last value
/// is returned.
///
/// # Example
///
/// ```
/// use wickra_core::{EwmaVolatility, Indicator};
///
/// let mut indicator = EwmaVolatility::new(0.94).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct EwmaVolatility {
lambda: f64,
prev_price: Option<f64>,
/// Exponentially-weighted variance of log returns; `None` until seeded.
variance: Option<f64>,
last: Option<f64>,
}
impl EwmaVolatility {
/// Construct a new EWMA-volatility indicator.
///
/// `lambda` is the decay factor, strictly between `0` and `1` (`RiskMetrics`
/// uses `0.94` for daily data). Larger `lambda` means a longer memory and a
/// smoother estimate.
///
/// # Errors
/// Returns [`Error::InvalidParameter`] if `lambda` is not finite or not in
/// the open interval `(0, 1)`.
pub fn new(lambda: f64) -> Result<Self> {
if !lambda.is_finite() || lambda <= 0.0 || lambda >= 1.0 {
return Err(Error::InvalidParameter {
message: "EWMA volatility lambda must be in the open interval (0, 1)",
});
}
Ok(Self {
lambda,
prev_price: None,
variance: None,
last: None,
})
}
/// Configured decay factor.
pub const fn lambda(&self) -> f64 {
self.lambda
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for EwmaVolatility {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Non-finite / non-positive prices are skipped: `ln(input / prev)` is
// undefined, so the tick must not enter the variance recursion.
if !input.is_finite() || input <= 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(input);
return None;
};
self.prev_price = Some(input);
// `prev` came from `self.prev_price`, gated by the guard above, so it is
// finite and positive — the log return is always well-defined.
let r = (input / prev).ln();
let var = match self.variance {
// Seed the recursion with the first squared return.
None => r * r,
Some(prev_var) => self.lambda * prev_var + (1.0 - self.lambda) * r * r,
};
self.variance = Some(var);
// `var` is a convex combination of non-negative terms, but rounding can
// leave a tiny negative residual when every return is ~0; clamp first.
let vol = var.max(0.0).sqrt();
self.last = Some(vol);
Some(vol)
}
fn reset(&mut self) {
self.prev_price = None;
self.variance = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
// The first log return needs a previous price; the estimate is seeded
// and emitted on that first return.
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"EwmaVolatility"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_lambda() {
for bad in [0.0, 1.0, -0.5, 1.5, f64::NAN, f64::INFINITY] {
assert!(matches!(
EwmaVolatility::new(bad),
Err(Error::InvalidParameter { .. })
));
}
}
#[test]
fn accessors_and_metadata() {
let ewma = EwmaVolatility::new(0.94).unwrap();
assert_relative_eq!(ewma.lambda(), 0.94);
assert_eq!(ewma.warmup_period(), 2);
assert_eq!(ewma.name(), "EwmaVolatility");
assert!(!ewma.is_ready());
assert_eq!(ewma.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut ewma = EwmaVolatility::new(0.94).unwrap();
assert_eq!(ewma.update(100.0), None);
let out = ewma.update(110.0);
assert!(out.is_some());
assert!(ewma.is_ready());
}
#[test]
fn known_value() {
// r1 = ln(110/100), r2 = ln(99/110). Seed σ²₁ = r1²; then
// σ²₂ = λ·r1² + (1−λ)·r2².
let lambda = 0.94;
let mut ewma = EwmaVolatility::new(lambda).unwrap();
let out = ewma.batch(&[100.0, 110.0, 99.0]);
let r1 = (110.0_f64 / 100.0).ln();
let r2 = (99.0_f64 / 110.0).ln();
assert_relative_eq!(out[1].unwrap(), r1.abs(), epsilon = 1e-12);
let var2 = lambda * r1 * r1 + (1.0 - lambda) * r2 * r2;
assert_relative_eq!(out[2].unwrap(), var2.sqrt(), epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
let mut ewma = EwmaVolatility::new(0.9).unwrap();
for v in ewma.batch(&[100.0; 40]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn output_is_non_negative() {
let mut ewma = EwmaVolatility::new(0.94).unwrap();
let prices: Vec<f64> = (1..=200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
.collect();
for v in ewma.batch(&prices).into_iter().flatten() {
assert!(v >= 0.0, "EWMA volatility must be non-negative, got {v}");
}
}
#[test]
fn ignores_non_finite_input() {
let mut ewma = EwmaVolatility::new(0.94).unwrap();
let out = ewma.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(ewma.update(f64::NAN), last);
assert_eq!(ewma.update(f64::INFINITY), last);
}
#[test]
fn skips_non_positive_prices() {
let mut ewma = EwmaVolatility::new(0.94).unwrap();
let warmup = ewma.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let baseline = warmup.last().copied().flatten().expect("warmed up");
assert_eq!(ewma.update(-5.0), Some(baseline));
assert_eq!(ewma.update(0.0), Some(baseline));
// State untouched: a clone advanced by the same real tick agrees.
let mut control = ewma.clone();
let after = ewma.update(21.0).expect("ready");
assert_eq!(control.update(21.0).expect("ready"), after);
}
#[test]
fn skips_non_positive_before_first_price() {
// The skip guard fires before any previous price exists.
let mut ewma = EwmaVolatility::new(0.94).unwrap();
assert_eq!(ewma.update(0.0), None);
assert_eq!(ewma.update(f64::NAN), None);
assert_eq!(ewma.update(100.0), None);
assert!(ewma.update(110.0).is_some());
}
#[test]
fn reset_clears_state() {
let mut ewma = EwmaVolatility::new(0.94).unwrap();
ewma.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(ewma.is_ready());
ewma.reset();
assert!(!ewma.is_ready());
assert_eq!(ewma.value(), None);
assert_eq!(ewma.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = EwmaVolatility::new(0.94).unwrap().batch(&prices);
let mut b = EwmaVolatility::new(0.94).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,325 @@
//! GARCH(1,1) — conditional volatility with a long-run-variance anchor.
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// GARCH(1,1) conditional volatility — the square root of the
/// generalized-autoregressive-conditional-heteroskedasticity variance recursion.
///
/// ```text
/// r_t = ln(price_t / price_{t1})
/// σ²_t = ω + α · r²_{t1} + β · σ²_{t1}
/// out = √σ²_t
/// ```
///
/// GARCH(1,1) (Bollerslev 1986) generalizes the
/// [`EwmaVolatility`](crate::EwmaVolatility) recursion by adding a constant `ω`,
/// which pins the process to a finite long-run (unconditional) variance
/// `ω / (1 α β)`. The `α` term gives weight to the latest squared return
/// (the "ARCH" shock) and `β` to the previous variance (the "GARCH"
/// persistence). When `ω = 0` and `α + β = 1` the model degenerates to EWMA; a
/// proper GARCH keeps `ω > 0` and `α + β < 1` so volatility mean-reverts rather
/// than drifting.
///
/// The recursion is seeded with the unconditional variance (`σ²₁ = ω / (1 α
/// β)`) and emits from the first log return onward. Unlike EWMA — which decays to
/// zero on a flat series — a flat series here mean-reverts toward `ω / (1 β)`
/// (the `α`-term vanishes but the `ω` floor and the `β` carry remain), so the
/// output is always strictly positive. Each `update` is O(1).
///
/// Non-finite and non-positive prices are ignored (the log return would be
/// undefined): the tick is dropped, state is left untouched, and the last value
/// is returned.
///
/// # Example
///
/// ```
/// use wickra_core::{Garch11, Indicator};
///
/// // Typical equity daily estimate.
/// let mut indicator = Garch11::new(0.000_002, 0.10, 0.88).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Garch11 {
omega: f64,
alpha: f64,
beta: f64,
unconditional: f64,
prev_price: Option<f64>,
/// `(σ²_{t1}, r²_{t1})` — previous variance and previous squared return.
state: Option<(f64, f64)>,
last: Option<f64>,
}
impl Garch11 {
/// Construct a new GARCH(1,1) indicator from its three parameters.
///
/// `omega` (`ω`) is the constant variance floor, `alpha` (`α`) the weight on
/// the latest squared return, and `beta` (`β`) the persistence of the
/// previous variance.
///
/// # Errors
/// Returns [`Error::InvalidParameter`] unless every parameter is finite,
/// `omega > 0`, `alpha >= 0`, `beta >= 0`, and `alpha + beta < 1` (the
/// covariance-stationarity condition that gives a finite long-run variance).
pub fn new(omega: f64, alpha: f64, beta: f64) -> Result<Self> {
if !omega.is_finite() || !alpha.is_finite() || !beta.is_finite() {
return Err(Error::InvalidParameter {
message: "GARCH(1,1) parameters must be finite",
});
}
if omega <= 0.0 {
return Err(Error::InvalidParameter {
message: "GARCH(1,1) omega must be > 0",
});
}
if alpha < 0.0 || beta < 0.0 {
return Err(Error::InvalidParameter {
message: "GARCH(1,1) alpha and beta must be >= 0",
});
}
if alpha + beta >= 1.0 {
return Err(Error::InvalidParameter {
message: "GARCH(1,1) requires alpha + beta < 1 (covariance stationarity)",
});
}
Ok(Self {
omega,
alpha,
beta,
unconditional: omega / (1.0 - alpha - beta),
prev_price: None,
state: None,
last: None,
})
}
/// Configured `(omega, alpha, beta)`.
pub const fn params(&self) -> (f64, f64, f64) {
(self.omega, self.alpha, self.beta)
}
/// Long-run (unconditional) variance `ω / (1 α β)`.
pub const fn unconditional_variance(&self) -> f64 {
self.unconditional
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for Garch11 {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Non-finite / non-positive prices are skipped: `ln(input / prev)` is
// undefined, so the tick must not enter the variance recursion.
if !input.is_finite() || input <= 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(input);
return None;
};
self.prev_price = Some(input);
// `prev` came from `self.prev_price`, gated by the guard above, so it is
// finite and positive — the log return is always well-defined.
let r = (input / prev).ln();
let r_sq = r * r;
let var = match self.state {
// Seed the recursion with the unconditional variance.
None => self.unconditional,
Some((prev_var, prev_r_sq)) => {
self.omega + self.alpha * prev_r_sq + self.beta * prev_var
}
};
self.state = Some((var, r_sq));
// `var` is `omega (> 0) + non-negative terms`, so it is strictly
// positive — the square root is always well-defined.
let vol = var.sqrt();
self.last = Some(vol);
Some(vol)
}
fn reset(&mut self) {
self.prev_price = None;
self.state = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
// The first log return needs a previous price; the estimate is seeded
// with the unconditional variance and emitted on that first return.
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"Garch11"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_params() {
assert!(matches!(
Garch11::new(0.0, 0.1, 0.8),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Garch11::new(-1.0, 0.1, 0.8),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Garch11::new(0.001, -0.1, 0.8),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Garch11::new(0.001, 0.1, -0.8),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Garch11::new(0.001, 0.5, 0.5),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Garch11::new(f64::NAN, 0.1, 0.8),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Garch11::new(0.001, f64::INFINITY, 0.8),
Err(Error::InvalidParameter { .. })
));
}
#[test]
fn accessors_and_metadata() {
let g = Garch11::new(0.001, 0.1, 0.85).unwrap();
assert_eq!(g.params(), (0.001, 0.1, 0.85));
assert_relative_eq!(g.unconditional_variance(), 0.001 / 0.05, epsilon = 1e-12);
assert_eq!(g.warmup_period(), 2);
assert_eq!(g.name(), "Garch11");
assert!(!g.is_ready());
assert_eq!(g.value(), None);
}
#[test]
fn first_emission_is_unconditional() {
// The first log return emits the seed = sqrt(unconditional variance),
// independent of the return value.
let g = Garch11::new(0.002, 0.1, 0.85);
let mut g = g.unwrap();
assert_eq!(g.update(100.0), None);
let out = g.update(110.0).unwrap();
assert_relative_eq!(out, (0.002_f64 / 0.05).sqrt(), epsilon = 1e-12);
}
#[test]
fn known_value() {
// σ²₁ = uncond; σ²₂ = ω + α·r1² + β·uncond.
let (omega, alpha, beta) = (0.002, 0.1, 0.85);
let mut g = Garch11::new(omega, alpha, beta).unwrap();
let out = g.batch(&[100.0, 110.0, 99.0]);
let uncond = omega / (1.0 - alpha - beta);
let r1 = (110.0_f64 / 100.0).ln();
assert_relative_eq!(out[1].unwrap(), uncond.sqrt(), epsilon = 1e-12);
let var2 = omega + alpha * r1 * r1 + beta * uncond;
assert_relative_eq!(out[2].unwrap(), var2.sqrt(), epsilon = 1e-12);
}
#[test]
fn flat_series_converges_to_long_run() {
// With zero returns the alpha term vanishes; the variance mean-reverts
// to the fixed point ω / (1 β), NOT to zero (the key GARCH/EWMA
// distinction).
let (omega, beta) = (0.002, 0.85);
let mut g = Garch11::new(omega, 0.10, beta).unwrap();
let out = g.batch(&[100.0; 400]);
let fixed_point = (omega / (1.0 - beta)).sqrt();
assert_relative_eq!(out.last().unwrap().unwrap(), fixed_point, epsilon = 1e-9);
}
#[test]
fn output_is_strictly_positive() {
let mut g = Garch11::new(0.000_002, 0.1, 0.88).unwrap();
let prices: Vec<f64> = (1..=200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
.collect();
for v in g.batch(&prices).into_iter().flatten() {
assert!(
v > 0.0,
"GARCH volatility must be strictly positive, got {v}"
);
}
}
#[test]
fn ignores_non_finite_input() {
let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
let out = g.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(g.update(f64::NAN), last);
assert_eq!(g.update(f64::INFINITY), last);
}
#[test]
fn skips_non_positive_prices() {
let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
let warmup = g.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let baseline = warmup.last().copied().flatten().expect("warmed up");
assert_eq!(g.update(-5.0), Some(baseline));
assert_eq!(g.update(0.0), Some(baseline));
// State untouched: a clone advanced by the same real tick agrees.
let mut control = g.clone();
let after = g.update(21.0).expect("ready");
assert_eq!(control.update(21.0).expect("ready"), after);
}
#[test]
fn skips_non_positive_before_first_price() {
let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
assert_eq!(g.update(0.0), None);
assert_eq!(g.update(f64::NAN), None);
assert_eq!(g.update(100.0), None);
assert!(g.update(110.0).is_some());
}
#[test]
fn reset_clears_state() {
let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
g.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(g.is_ready());
g.reset();
assert!(!g.is_ready());
assert_eq!(g.value(), None);
assert_eq!(g.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = Garch11::new(0.000_002, 0.1, 0.88).unwrap().batch(&prices);
let mut b = Garch11::new(0.000_002, 0.1, 0.88).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,341 @@
//! Kase `DevStop` — a volatility trailing stop on the standard deviation of the
//! two-bar true range.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`KaseDevStop`]: the active trailing-stop level and the trend
/// direction it protects.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct KaseDevStopOutput {
/// The `DevStop` level — below price in an uptrend, above price in a downtrend.
pub value: f64,
/// Trend direction: `+1.0` long (stop below price), `-1.0` short.
pub direction: f64,
}
/// Sample standard deviation from a running `(sum, sum_of_squares, count)`.
fn sample_stddev(sum: f64, sum_sq: f64, count: usize) -> f64 {
let n = count as f64;
let mean = sum / n;
(((sum_sq - n * mean * mean) / (n - 1.0)).max(0.0)).sqrt()
}
/// Kase `DevStop` — Cynthia Kase's volatility stop, built on the **standard
/// deviation of the two-bar true range** rather than a single-bar ATR.
///
/// ```text
/// DTR_t = max(high_t, high_{t1}) min(low_t, low_{t1}) (two-bar range)
/// band = mean(DTR, period) + dev · stddev(DTR, period)
/// long stop = ratchet_up( highest_high_since_flip band )
/// short stop = ratchet_down( lowest_low_since_flip + band )
/// ```
///
/// Kase observed that range expansion is better captured by a two-bar range than
/// a one-bar one, and that subtracting a *standard-deviation* band (not a fixed
/// ATR multiple) adapts the stop to changing volatility. The stop trails the
/// extreme reached since the last reversal — ratcheting only in the trend's favour
/// — and flips sides when price closes through it. `dev` selects which `DevStop`
/// line to follow (`1`, `2` or `3` standard deviations are Kase's warning lines).
///
/// The first bar seeds the prior candle; the next `period` two-bar ranges seed the
/// mean and standard deviation, so the first stop lands after `period + 1` inputs.
/// Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, KaseDevStop};
///
/// let mut indicator = KaseDevStop::new(30, 1.0).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct KaseDevStop {
period: usize,
dev: f64,
prev: Option<Candle>,
window: VecDeque<f64>,
sum: f64,
sum_sq: f64,
direction: f64,
extreme: f64,
stop: f64,
last: Option<KaseDevStopOutput>,
}
impl KaseDevStop {
/// Construct a Kase `DevStop` with the given lookback `period` and
/// standard-deviation multiplier `dev`.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `period < 2` (a standard deviation
/// needs at least two samples) and [`Error::NonPositiveMultiplier`] if `dev`
/// is not finite and positive.
pub fn new(period: usize, dev: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "Kase DevStop period must be >= 2",
});
}
if !dev.is_finite() || dev <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
period,
dev,
prev: None,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_sq: 0.0,
direction: 0.0,
extreme: 0.0,
stop: 0.0,
last: None,
})
}
/// Configured `(period, dev)`.
pub const fn params(&self) -> (usize, f64) {
(self.period, self.dev)
}
/// Current value if available.
pub const fn value(&self) -> Option<KaseDevStopOutput> {
self.last
}
}
impl Indicator for KaseDevStop {
type Input = Candle;
type Output = KaseDevStopOutput;
fn update(&mut self, candle: Candle) -> Option<KaseDevStopOutput> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
let dtr = candle.high.max(prev.high) - candle.low.min(prev.low);
self.prev = Some(candle);
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
self.sum_sq -= old * old;
}
self.window.push_back(dtr);
self.sum += dtr;
self.sum_sq += dtr * dtr;
if self.window.len() < self.period {
return None;
}
let mean = self.sum / self.period as f64;
let band = mean + self.dev * sample_stddev(self.sum, self.sum_sq, self.period);
if self.direction == 0.0 {
// Seed the trend as long off the first fully-warmed bar.
self.direction = 1.0;
self.extreme = candle.high;
self.stop = candle.high - band;
} else if self.direction > 0.0 {
self.extreme = self.extreme.max(candle.high);
let raw = self.extreme - band;
self.stop = self.stop.max(raw);
if candle.close < self.stop {
self.direction = -1.0;
self.extreme = candle.low;
self.stop = candle.low + band;
}
} else {
self.extreme = self.extreme.min(candle.low);
let raw = self.extreme + band;
self.stop = self.stop.min(raw);
if candle.close > self.stop {
self.direction = 1.0;
self.extreme = candle.high;
self.stop = candle.high - band;
}
}
let out = KaseDevStopOutput {
value: self.stop,
direction: self.direction,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.prev = None;
self.window.clear();
self.sum = 0.0;
self.sum_sq = 0.0;
self.direction = 0.0;
self.extreme = 0.0;
self.stop = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"KaseDevStop"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0)
}
#[test]
fn rejects_invalid_params() {
assert!(matches!(
KaseDevStop::new(1, 1.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
KaseDevStop::new(30, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
KaseDevStop::new(30, -1.0),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let k = KaseDevStop::new(30, 1.0).unwrap();
assert_eq!(k.params(), (30, 1.0));
assert_eq!(k.warmup_period(), 31);
assert_eq!(k.name(), "KaseDevStop");
assert!(!k.is_ready());
assert_eq!(k.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut k = KaseDevStop::new(3, 1.0).unwrap();
let candles: Vec<Candle> = (0..8)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base)
})
.collect();
let out = k.batch(&candles);
let warmup = k.warmup_period(); // 4
assert_eq!(warmup, 4);
for v in out.iter().take(warmup - 1) {
assert!(v.is_none());
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn uptrend_keeps_stop_below_price() {
let mut k = KaseDevStop::new(5, 1.0).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + 2.0 * f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
for (o, candle) in k.batch(&candles).into_iter().zip(candles.iter()) {
if let Some(o) = o {
assert_eq!(o.direction, 1.0, "pure uptrend stays long");
assert!(o.value < candle.close, "stop below price");
}
}
}
#[test]
fn stop_ratchets_up_in_uptrend() {
let mut k = KaseDevStop::new(5, 1.0).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + 2.0 * f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
let mut prev = f64::NEG_INFINITY;
for o in k.batch(&candles).into_iter().flatten() {
assert!(o.value >= prev, "long stop must not fall");
prev = o.value;
}
}
#[test]
fn flips_on_reversal() {
let mut candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
candles.extend((0..40).map(|i| {
let base = 140.0 - f64::from(i);
c(base + 1.0, base - 1.0, base - 0.5)
}));
let mut k = KaseDevStop::new(5, 1.0).unwrap();
let dirs: Vec<f64> = k
.batch(&candles)
.into_iter()
.flatten()
.map(|o| o.direction)
.collect();
assert!(dirs.iter().any(|&d| d > 0.0));
assert!(dirs.iter().any(|&d| d < 0.0));
}
#[test]
fn reset_clears_state() {
let mut k = KaseDevStop::new(5, 1.0).unwrap();
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
k.batch(&candles);
assert!(k.is_ready());
k.reset();
assert!(!k.is_ready());
assert_eq!(k.value(), None);
assert_eq!(k.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
c(base + 2.0, base - 1.5, base + 0.5)
})
.collect();
let batch = KaseDevStop::new(20, 2.0).unwrap().batch(&candles);
let mut b = KaseDevStop::new(20, 2.0).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,237 @@
//! Median Channel — a robust median ± MAD envelope.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::rolling_quantile::quantile_sorted;
use crate::traits::Indicator;
/// Median Channel output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MedianChannelOutput {
/// Upper band: `median + multiplier · MAD`.
pub upper: f64,
/// Middle line: the rolling median.
pub middle: f64,
/// Lower band: `median multiplier · MAD`.
pub lower: f64,
}
/// Median Channel: a robust analogue of Bollinger Bands built from the rolling
/// median and the median absolute deviation (MAD).
///
/// ```text
/// middle = median(close, period)
/// MAD = median( | close_i middle | )
/// upper = middle + multiplier · MAD
/// lower = middle multiplier · MAD
/// ```
///
/// Where [`BollingerBands`](crate::BollingerBands) centre on the mean and scale
/// by the standard deviation — both of which a single spike can drag
/// arbitrarily far — the Median Channel uses two order statistics. The
/// breakdown point of the median and MAD is 50%: up to half the window can be
/// contaminated before the centre or width is materially distorted. That makes
/// the channel well suited to noisy, gap-prone, or fat-tailed series where
/// Bollinger Bands flare on every outlier. Both quantiles use the type-7
/// interpolation shared with [`RollingQuantile`](crate::RollingQuantile).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, MedianChannel};
///
/// let mut indicator = MedianChannel::new(20, 2.0).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i % 5));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MedianChannel {
period: usize,
multiplier: f64,
window: VecDeque<f64>,
scratch: Vec<f64>,
deviations: Vec<f64>,
}
impl MedianChannel {
/// Construct a new Median Channel.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`, or
/// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
/// positive and finite.
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !multiplier.is_finite() || multiplier <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
period,
multiplier,
window: VecDeque::with_capacity(period),
scratch: Vec::with_capacity(period),
deviations: Vec::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured multiplier.
pub const fn multiplier(&self) -> f64 {
self.multiplier
}
}
impl Indicator for MedianChannel {
type Input = f64;
type Output = MedianChannelOutput;
fn update(&mut self, value: f64) -> Option<MedianChannelOutput> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
self.scratch.clear();
self.scratch.extend(self.window.iter().copied());
self.scratch.sort_by(f64::total_cmp);
let median = quantile_sorted(&self.scratch, 0.5);
self.deviations.clear();
for &v in &self.window {
self.deviations.push((v - median).abs());
}
self.deviations.sort_by(f64::total_cmp);
let mad = quantile_sorted(&self.deviations, 0.5);
let offset = self.multiplier * mad;
Some(MedianChannelOutput {
upper: median + offset,
middle: median,
lower: median - offset,
})
}
fn reset(&mut self) {
self.window.clear();
self.scratch.clear();
self.deviations.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"MedianChannel"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(MedianChannel::new(0, 2.0), Err(Error::PeriodZero)));
assert!(MedianChannel::new(1, 2.0).is_ok());
}
#[test]
fn rejects_non_positive_multiplier() {
assert!(matches!(
MedianChannel::new(20, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
MedianChannel::new(20, -1.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
MedianChannel::new(20, f64::NAN),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let mc = MedianChannel::new(20, 2.0).unwrap();
assert_eq!(mc.period(), 20);
assert_relative_eq!(mc.multiplier(), 2.0, epsilon = 1e-12);
assert_eq!(mc.warmup_period(), 20);
assert_eq!(mc.name(), "MedianChannel");
assert!(!mc.is_ready());
}
#[test]
fn warms_up_then_emits() {
let mut mc = MedianChannel::new(5, 2.0).unwrap();
for v in [1.0, 2.0, 3.0, 4.0] {
assert!(mc.update(v).is_none());
}
assert!(mc.update(5.0).is_some());
assert!(mc.is_ready());
}
#[test]
fn known_channel() {
// [1,2,3,4,5]: median 3; |dev| sorted [0,1,1,2,2] -> MAD 1.
// upper = 3 + 2*1 = 5; lower = 3 - 2*1 = 1.
let mut mc = MedianChannel::new(5, 2.0).unwrap();
let out = mc.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let last = out[4].unwrap();
assert_relative_eq!(last.middle, 3.0, epsilon = 1e-12);
assert_relative_eq!(last.upper, 5.0, epsilon = 1e-12);
assert_relative_eq!(last.lower, 1.0, epsilon = 1e-12);
}
#[test]
fn robust_to_outlier() {
// Replacing the last value with a huge spike leaves the median centre
// unchanged (still the middle order statistic).
let mut mc = MedianChannel::new(5, 2.0).unwrap();
let out = mc.batch(&[1.0, 2.0, 3.0, 4.0, 1_000.0]);
assert_relative_eq!(out[4].unwrap().middle, 3.0, epsilon = 1e-12);
}
#[test]
fn rolling_window_evicts_oldest() {
// Ten values through a period-5 window: only the last five survive,
// reproducing the `known_channel` window.
let mut mc = MedianChannel::new(5, 2.0).unwrap();
let out = mc.batch(&[10.0, 10.0, 10.0, 10.0, 10.0, 1.0, 2.0, 3.0, 4.0, 5.0]);
let last = out[9].unwrap();
assert_relative_eq!(last.middle, 3.0, epsilon = 1e-12);
assert_relative_eq!(last.upper, 5.0, epsilon = 1e-12);
assert_relative_eq!(last.lower, 1.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut mc = MedianChannel::new(5, 2.0).unwrap();
for v in [1.0, 2.0, 3.0, 4.0, 5.0] {
mc.update(v);
}
assert!(mc.is_ready());
mc.reset();
assert!(!mc.is_ready());
assert!(mc.update(1.0).is_none());
}
}
+52 -1
View File
@@ -35,6 +35,7 @@ mod aroon;
mod aroon_oscillator;
mod atr;
mod atr_bands;
mod atr_ratchet;
mod atr_trailing_stop;
mod auto_fib;
mod autocorrelation;
@@ -48,9 +49,11 @@ mod bat;
mod belt_hold;
mod beta;
mod beta_neutral_spread;
mod bipower_variation;
mod body_size_pct;
mod bollinger;
mod bollinger_bandwidth;
mod bomar_bands;
mod breadth_thrust;
mod breakaway;
mod bullish_percent_index;
@@ -113,11 +116,13 @@ mod ehlers_stochastic;
mod ehma;
mod elder_impulse;
mod elder_ray;
mod elder_safezone;
mod ema;
mod empirical_mode_decomposition;
mod engulfing;
mod evening_doji_star;
mod evwma;
mod ewma_volatility;
mod expectancy;
mod falling_three_methods;
mod fama;
@@ -143,6 +148,7 @@ mod funding_rate_mean;
mod funding_rate_zscore;
mod gain_loss_ratio;
mod gap_side_by_side_white;
mod garch11;
mod garman_klass;
mod gartley;
mod gator_oscillator;
@@ -188,6 +194,7 @@ mod jump_indicator;
mod kagi_bars;
mod kalman_hedge_ratio;
mod kama;
mod kase_devstop;
mod kase_permission_stochastic;
mod kelly_criterion;
mod keltner;
@@ -226,6 +233,7 @@ mod mcclellan_oscillator;
mod mcclellan_summation_index;
mod mcginley_dynamic;
mod median_absolute_deviation;
mod median_channel;
mod median_ma;
mod median_price;
mod mfi;
@@ -234,11 +242,13 @@ mod mid_point;
mod mid_price;
mod minus_di;
mod minus_dm;
mod modified_ma_stop;
mod mom;
mod morning_doji_star;
mod morning_evening_star;
mod natr;
mod new_highs_new_lows;
mod nrtr;
mod nvi;
mod ob_imbalance_full;
mod ob_imbalance_top1;
@@ -273,10 +283,13 @@ mod polarized_fractal_efficiency;
mod ppo;
mod ppo_histogram;
mod profit_factor;
mod projection_bands;
mod projection_oscillator;
mod psar;
mod pvi;
mod qqe;
mod qstick;
mod quartile_bands;
mod quoted_spread;
mod r_squared;
mod realized_spread;
@@ -370,6 +383,7 @@ mod three_stars_in_south;
mod thrusting;
mod tick_index;
mod tii;
mod time_based_stop;
mod time_of_day_return_profile;
mod tpo_profile;
mod trade_imbalance;
@@ -404,6 +418,9 @@ mod variance;
mod variance_ratio;
mod vertical_horizontal_filter;
mod vidya;
mod volatility_cone;
mod volatility_of_volatility;
mod volatility_ratio;
mod volty_stop;
mod volume_by_time_profile;
mod volume_oscillator;
@@ -458,6 +475,7 @@ pub use aroon::{Aroon, AroonOutput};
pub use aroon_oscillator::AroonOscillator;
pub use atr::Atr;
pub use atr_bands::{AtrBands, AtrBandsOutput};
pub use atr_ratchet::{AtrRatchet, AtrRatchetOutput};
pub use atr_trailing_stop::AtrTrailingStop;
pub use auto_fib::{AutoFib, AutoFibOutput};
pub use autocorrelation::Autocorrelation;
@@ -471,9 +489,11 @@ pub use bat::Bat;
pub use belt_hold::BeltHold;
pub use beta::Beta;
pub use beta_neutral_spread::BetaNeutralSpread;
pub use bipower_variation::BipowerVariation;
pub use body_size_pct::BodySizePct;
pub use bollinger::{BollingerBands, BollingerOutput};
pub use bollinger_bandwidth::BollingerBandwidth;
pub use bomar_bands::{BomarBands, BomarBandsOutput};
pub use breadth_thrust::BreadthThrust;
pub use breakaway::Breakaway;
pub use bullish_percent_index::BullishPercentIndex;
@@ -536,11 +556,13 @@ pub use ehlers_stochastic::EhlersStochastic;
pub use ehma::Ehma;
pub use elder_impulse::ElderImpulse;
pub use elder_ray::{ElderRay, ElderRayOutput};
pub use elder_safezone::{ElderSafeZone, ElderSafeZoneOutput};
pub use ema::Ema;
pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
pub use engulfing::Engulfing;
pub use evening_doji_star::EveningDojiStar;
pub use evwma::Evwma;
pub use ewma_volatility::EwmaVolatility;
pub use expectancy::Expectancy;
pub use falling_three_methods::FallingThreeMethods;
pub use fama::Fama;
@@ -566,6 +588,7 @@ pub use funding_rate_mean::FundingRateMean;
pub use funding_rate_zscore::FundingRateZScore;
pub use gain_loss_ratio::GainLossRatio;
pub use gap_side_by_side_white::GapSideBySideWhite;
pub use garch11::Garch11;
pub use garman_klass::GarmanKlassVolatility;
pub use gartley::Gartley;
pub use gator_oscillator::{GatorOscillator, GatorOscillatorOutput};
@@ -611,6 +634,7 @@ pub use jump_indicator::JumpIndicator;
pub use kagi_bars::{KagiBar, KagiBars};
pub use kalman_hedge_ratio::{KalmanHedgeRatio, KalmanHedgeRatioOutput};
pub use kama::Kama;
pub use kase_devstop::{KaseDevStop, KaseDevStopOutput};
pub use kase_permission_stochastic::{KasePermissionStochastic, KasePermissionStochasticOutput};
pub use kelly_criterion::KellyCriterion;
pub use keltner::{Keltner, KeltnerOutput};
@@ -649,6 +673,7 @@ pub use mcclellan_oscillator::McClellanOscillator;
pub use mcclellan_summation_index::McClellanSummationIndex;
pub use mcginley_dynamic::McGinleyDynamic;
pub use median_absolute_deviation::MedianAbsoluteDeviation;
pub use median_channel::{MedianChannel, MedianChannelOutput};
pub use median_ma::MedianMa;
pub use median_price::MedianPrice;
pub use mfi::Mfi;
@@ -657,11 +682,13 @@ pub use mid_point::MidPoint;
pub use mid_price::MidPrice;
pub use minus_di::MinusDi;
pub use minus_dm::MinusDm;
pub use modified_ma_stop::{ModifiedMaStop, ModifiedMaStopOutput};
pub use mom::Mom;
pub use morning_doji_star::MorningDojiStar;
pub use morning_evening_star::MorningEveningStar;
pub use natr::Natr;
pub use new_highs_new_lows::NewHighsNewLows;
pub use nrtr::{Nrtr, NrtrOutput};
pub use nvi::Nvi;
pub use ob_imbalance_full::OrderBookImbalanceFull;
pub use ob_imbalance_top1::OrderBookImbalanceTop1;
@@ -696,10 +723,13 @@ pub use polarized_fractal_efficiency::PolarizedFractalEfficiency;
pub use ppo::Ppo;
pub use ppo_histogram::PpoHistogram;
pub use profit_factor::ProfitFactor;
pub use projection_bands::{ProjectionBands, ProjectionBandsOutput};
pub use projection_oscillator::ProjectionOscillator;
pub use psar::Psar;
pub use pvi::Pvi;
pub use qqe::{Qqe, QqeOutput};
pub use qstick::Qstick;
pub use quartile_bands::{QuartileBands, QuartileBandsOutput};
pub use quoted_spread::QuotedSpread;
pub use r_squared::RSquared;
pub use realized_spread::RealizedSpread;
@@ -793,6 +823,7 @@ pub use three_stars_in_south::ThreeStarsInSouth;
pub use thrusting::Thrusting;
pub use tick_index::TickIndex;
pub use tii::Tii;
pub use time_based_stop::TimeBasedStop;
pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput};
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
pub use trade_imbalance::TradeImbalance;
@@ -827,6 +858,9 @@ pub use variance::Variance;
pub use variance_ratio::VarianceRatio;
pub use vertical_horizontal_filter::VerticalHorizontalFilter;
pub use vidya::Vidya;
pub use volatility_cone::{VolatilityCone, VolatilityConeOutput};
pub use volatility_of_volatility::VolatilityOfVolatility;
pub use volatility_ratio::VolatilityRatio;
pub use volty_stop::VoltyStop;
pub use volume_by_time_profile::{VolumeByTimeProfile, VolumeByTimeProfileOutput};
pub use volume_oscillator::VolumeOscillator;
@@ -1006,6 +1040,12 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"YangZhangVolatility",
"JumpIndicator",
"RegimeLabel",
"EwmaVolatility",
"Garch11",
"VolatilityOfVolatility",
"BipowerVariation",
"VolatilityRatio",
"VolatilityCone",
],
),
(
@@ -1022,6 +1062,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"TtmSqueeze",
"FractalChaosBands",
"VwapStdDevBands",
"QuartileBands",
"BomarBands",
"MedianChannel",
"ProjectionBands",
"ProjectionOscillator",
],
),
(
@@ -1040,6 +1085,12 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"StepTrailingStop",
"RenkoTrailingStop",
"SarExt",
"KaseDevStop",
"ElderSafeZone",
"AtrRatchet",
"Nrtr",
"TimeBasedStop",
"ModifiedMaStop",
],
),
(
@@ -1423,6 +1474,6 @@ mod family_tests {
// the actual indicator count is the early-warning signal that an
// indicator was added without being assigned a family.
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
assert_eq!(total, 423, "FAMILIES total drifted from indicator count");
assert_eq!(total, 440, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,238 @@
//! Modified-MA Stop — a trailing stop riding the Modified Moving Average (SMMA).
use crate::error::{Error, Result};
use crate::indicators::smma::Smma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`ModifiedMaStop`]: the active stop level and the trend direction.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ModifiedMaStopOutput {
/// The stop level (a directionally-ratcheted Modified Moving Average).
pub value: f64,
/// Trend direction: `+1.0` long (stop below price), `-1.0` short.
pub direction: f64,
}
/// Modified-MA Stop — a trailing stop whose line is the **Modified Moving
/// Average** (SMMA / Wilder's RMA) of price, allowed to move only in the trend's
/// favour.
///
/// ```text
/// ma = SMMA(close, period) (Modified Moving Average)
/// long: stop = max(prev_stop, ma); flip short when close < stop
/// short: stop = min(prev_stop, ma); flip long when close > stop
/// ```
///
/// The Modified Moving Average (also called the smoothed or running moving
/// average) is the slow, low-lag average Wilder used throughout his systems. Using
/// it directly as a trailing line — but **ratcheting** so the long stop never
/// falls and the short stop never rises — turns the smooth average into a stop
/// that hugs price in a trend and flips when price decisively crosses it. Because
/// the SMMA lags, the stop gives trends room while still exiting clean reversals.
///
/// The first stop lands once the SMMA is ready (`period` inputs). Each `update` is
/// O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ModifiedMaStop};
///
/// let mut indicator = ModifiedMaStop::new(14).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ModifiedMaStop {
smma: Smma,
period: usize,
direction: f64,
stop: f64,
last: Option<ModifiedMaStopOutput>,
}
impl ModifiedMaStop {
/// Construct a Modified-MA stop with the given SMMA `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
smma: Smma::new(period)?,
period,
direction: 0.0,
stop: 0.0,
last: None,
})
}
/// Configured SMMA period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<ModifiedMaStopOutput> {
self.last
}
}
impl Indicator for ModifiedMaStop {
type Input = Candle;
type Output = ModifiedMaStopOutput;
fn update(&mut self, candle: Candle) -> Option<ModifiedMaStopOutput> {
let ma = self.smma.update(candle.close)?;
let close = candle.close;
if self.direction == 0.0 {
self.direction = if close >= ma { 1.0 } else { -1.0 };
self.stop = ma;
} else if self.direction > 0.0 {
self.stop = self.stop.max(ma);
if close < self.stop {
self.direction = -1.0;
self.stop = ma;
}
} else {
self.stop = self.stop.min(ma);
if close > self.stop {
self.direction = 1.0;
self.stop = ma;
}
}
let out = ModifiedMaStopOutput {
value: self.stop,
direction: self.direction,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.smma.reset();
self.direction = 0.0;
self.stop = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"ModifiedMaStop"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(close: f64) -> Candle {
Candle::new_unchecked(close, close + 1.0, close - 1.0, close, 1_000.0, 0)
}
#[test]
fn rejects_zero_period() {
assert!(matches!(ModifiedMaStop::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let m = ModifiedMaStop::new(14).unwrap();
assert_eq!(m.period(), 14);
assert_eq!(m.warmup_period(), 14);
assert_eq!(m.name(), "ModifiedMaStop");
assert!(!m.is_ready());
assert_eq!(m.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut m = ModifiedMaStop::new(5).unwrap();
let candles: Vec<Candle> = (0..12).map(|i| c(100.0 + f64::from(i))).collect();
let out = m.batch(&candles);
for v in out.iter().take(4) {
assert!(v.is_none());
}
assert!(out[4].is_some());
}
#[test]
fn uptrend_keeps_stop_below_price() {
let mut m = ModifiedMaStop::new(5).unwrap();
let candles: Vec<Candle> = (0..60).map(|i| c(100.0 + 2.0 * f64::from(i))).collect();
for (o, candle) in m.batch(&candles).into_iter().zip(candles.iter()) {
if let Some(o) = o {
assert_eq!(o.direction, 1.0);
assert!(o.value < candle.close);
}
}
}
#[test]
fn long_stop_ratchets_up() {
let mut m = ModifiedMaStop::new(5).unwrap();
let candles: Vec<Candle> = (0..60).map(|i| c(100.0 + 2.0 * f64::from(i))).collect();
let mut prev = f64::NEG_INFINITY;
for o in m.batch(&candles).into_iter().flatten() {
assert_eq!(o.direction, 1.0, "pure uptrend stays long");
assert!(o.value >= prev, "long stop must not fall");
prev = o.value;
}
}
#[test]
fn flips_on_reversal() {
let mut candles: Vec<Candle> = (0..40).map(|i| c(100.0 + f64::from(i))).collect();
candles.extend((0..40).map(|i| c(140.0 - f64::from(i))));
let mut m = ModifiedMaStop::new(5).unwrap();
let dirs: Vec<f64> = m
.batch(&candles)
.into_iter()
.flatten()
.map(|o| o.direction)
.collect();
assert!(dirs.iter().any(|&d| d > 0.0));
assert!(dirs.iter().any(|&d| d < 0.0));
}
#[test]
fn reset_clears_state() {
let mut m = ModifiedMaStop::new(5).unwrap();
m.batch(&(0..40).map(|i| c(100.0 + f64::from(i))).collect::<Vec<_>>());
assert!(m.is_ready());
m.reset();
assert!(!m.is_ready());
assert_eq!(m.value(), None);
assert_eq!(m.update(c(100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
.collect();
let batch = ModifiedMaStop::new(14).unwrap().batch(&candles);
let mut b = ModifiedMaStop::new(14).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
+259
View File
@@ -0,0 +1,259 @@
//! NRTR — Nick Rypock Trailing Reverse, a percentage trailing-reverse stop.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`Nrtr`]: the trailing-reverse line and the trend direction.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct NrtrOutput {
/// The NRTR line — below price in an uptrend, above price in a downtrend.
pub value: f64,
/// Trend direction: `+1.0` up (line below price), `-1.0` down.
pub direction: f64,
}
/// NRTR (Nick Rypock Trailing Reverse) — a **percentage** trailing-reverse stop
/// that follows the trend extreme and flips when price retraces by a fixed
/// percentage.
///
/// ```text
/// uptrend: high_water = max(high_water, close)
/// line = high_water · (1 pct/100)
/// flip down when close < line (reseed low_water = close)
/// downtrend: low_water = min(low_water, close)
/// line = low_water · (1 + pct/100)
/// flip up when close > line (reseed high_water = close)
/// ```
///
/// Unlike volatility stops (ATR, σ-of-range), NRTR uses a pure **percentage**
/// retracement: the line trails the highest close reached in the up-leg at a
/// fixed `pct` below it, and a close that gives back that percentage reverses the
/// trend, handing the line to the opposite extreme. This makes it scale-free and
/// trivially tunable — one number sets how much retracement you tolerate. It
/// differs from a fixed percentage *stop-loss* in that it **reverses** (tracks
/// both directions) rather than just exiting.
///
/// The first bar seeds the up-trend and emits a line immediately. Each `update` is
/// O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Nrtr};
///
/// let mut indicator = Nrtr::new(2.0).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let close = 100.0 + f64::from(i);
/// let c = Candle::new(close, close + 0.5, close - 0.5, close, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Nrtr {
pct: f64,
direction: f64,
water: f64,
last: Option<NrtrOutput>,
}
impl Nrtr {
/// Construct an NRTR with the given trailing percentage (e.g. `2.0` for 2%).
///
/// # Errors
///
/// Returns [`Error::InvalidParameter`] if `pct` is not finite or is outside
/// `(0, 100)`.
pub fn new(pct: f64) -> Result<Self> {
if !pct.is_finite() || pct <= 0.0 || pct >= 100.0 {
return Err(Error::InvalidParameter {
message: "NRTR percentage must be in (0, 100)",
});
}
Ok(Self {
pct,
direction: 0.0,
water: 0.0,
last: None,
})
}
/// Configured trailing percentage.
pub const fn pct(&self) -> f64 {
self.pct
}
/// Current value if available.
pub const fn value(&self) -> Option<NrtrOutput> {
self.last
}
}
impl Indicator for Nrtr {
type Input = Candle;
type Output = NrtrOutput;
fn update(&mut self, candle: Candle) -> Option<NrtrOutput> {
let close = candle.close;
let down = self.pct / 100.0;
let up = self.pct / 100.0;
if self.direction == 0.0 {
self.direction = 1.0;
self.water = close;
} else if self.direction > 0.0 {
self.water = self.water.max(close);
let line = self.water * (1.0 - down);
if close < line {
self.direction = -1.0;
self.water = close;
}
} else {
self.water = self.water.min(close);
let line = self.water * (1.0 + up);
if close > line {
self.direction = 1.0;
self.water = close;
}
}
let line = if self.direction > 0.0 {
self.water * (1.0 - down)
} else {
self.water * (1.0 + up)
};
let out = NrtrOutput {
value: line,
direction: self.direction,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.direction = 0.0;
self.water = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"Nrtr"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(close: f64) -> Candle {
Candle::new_unchecked(close, close, close, close, 1_000.0, 0)
}
#[test]
fn rejects_invalid_pct() {
assert!(matches!(
Nrtr::new(0.0),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Nrtr::new(100.0),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Nrtr::new(f64::NAN),
Err(Error::InvalidParameter { .. })
));
assert!(Nrtr::new(2.0).is_ok());
}
#[test]
fn accessors_and_metadata() {
let n = Nrtr::new(2.0).unwrap();
assert_eq!(n.pct(), 2.0);
assert_eq!(n.warmup_period(), 1);
assert_eq!(n.name(), "Nrtr");
assert!(!n.is_ready());
assert_eq!(n.value(), None);
}
#[test]
fn first_bar_emits_up_line() {
let mut n = Nrtr::new(10.0).unwrap();
let o = n.update(c(100.0)).unwrap();
assert_eq!(o.direction, 1.0);
// line = 100 * (1 - 0.10) = 90.
assert!((o.value - 90.0).abs() < 1e-9);
}
#[test]
fn uptrend_keeps_line_below_price() {
let mut n = Nrtr::new(5.0).unwrap();
let candles: Vec<Candle> = (0..40).map(|i| c(100.0 + f64::from(i))).collect();
for (o, candle) in n.batch(&candles).into_iter().zip(candles.iter()) {
let o = o.unwrap();
assert_eq!(o.direction, 1.0);
assert!(o.value < candle.close);
}
}
#[test]
fn reverses_on_retracement() {
let mut n = Nrtr::new(5.0).unwrap();
// Rise to 120, then drop sharply -> a >5% retracement reverses the trend.
let mut candles: Vec<Candle> = (0..20).map(|i| c(100.0 + f64::from(i))).collect();
candles.extend((0..10).map(|i| c(119.0 - 3.0 * f64::from(i))));
let dirs: Vec<f64> = n
.batch(&candles)
.into_iter()
.flatten()
.map(|o| o.direction)
.collect();
assert!(dirs.iter().any(|&d| d > 0.0));
assert!(dirs.iter().any(|&d| d < 0.0));
}
#[test]
fn downtrend_keeps_line_above_price() {
let mut n = Nrtr::new(5.0).unwrap();
// Establish a downtrend after an initial bar.
let mut candles = vec![c(100.0)];
candles.extend((0..30).map(|i| c(80.0 - f64::from(i))));
let out = n.batch(&candles);
let o = out.last().unwrap().unwrap();
let candle = candles.last().unwrap();
assert_eq!(o.direction, -1.0);
assert!(o.value > candle.close);
}
#[test]
fn reset_clears_state() {
let mut n = Nrtr::new(2.0).unwrap();
n.batch(&(0..20).map(|i| c(100.0 + f64::from(i))).collect::<Vec<_>>());
assert!(n.is_ready());
n.reset();
assert!(!n.is_ready());
assert_eq!(n.value(), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 15.0))
.collect();
let batch = Nrtr::new(3.0).unwrap().batch(&candles);
let mut b = Nrtr::new(3.0).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,253 @@
//! Projection Bands (Mel Widner) — a high/low linear-regression projection
//! envelope.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Projection Bands output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ProjectionBandsOutput {
/// Upper band: the maximum forward-projected high in the window.
pub upper: f64,
/// Middle line: the midpoint of the upper and lower bands.
pub middle: f64,
/// Lower band: the minimum forward-projected low in the window.
pub lower: f64,
}
/// Projection Bands: forward-projected high/low envelope.
///
/// Mel Widner ("Projection Bands and the Projection Oscillator", *Technical
/// Analysis of Stocks & Commodities*, May 1995) fits a separate linear
/// regression to the highs and to the lows over the last `period` bars, then
/// slides every bar's high and low forward to the current bar along its own
/// slope. The upper band is the maximum of the projected highs, the lower band
/// the minimum of the projected lows:
///
/// ```text
/// slope_h = OLS slope of (x, high) over the window
/// slope_l = OLS slope of (x, low) over the window
/// // bar i (0 = oldest, period-1 = newest) is (period-1-i) bars in the past
/// upper = max over i of [ high_i + slope_h · (period-1-i) ]
/// lower = min over i of [ low_i + slope_l · (period-1-i) ]
/// middle = (upper + lower) / 2
/// ```
///
/// Unlike [`LinRegChannel`](crate::LinRegChannel) and
/// [`StandardErrorBands`](crate::StandardErrorBands) — which wrap a single
/// close-regression endpoint by a dispersion statistic — Projection Bands are
/// built from the *extremes*: the envelope adapts to the trend's slope yet
/// always contains every projected high and low, so by construction price never
/// pierces the bands within the window. A flat slope reduces the bands to the
/// rolling highest-high / lowest-low (a Donchian channel); a steep slope tilts
/// the whole envelope with the trend.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ProjectionBands};
///
/// let mut indicator = ProjectionBands::new(14).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ProjectionBands {
period: usize,
highs: VecDeque<f64>,
lows: VecDeque<f64>,
sum_x: f64,
sum_xx: f64,
}
impl ProjectionBands {
/// Construct new Projection Bands.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` (a regression slope
/// needs at least two points).
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "projection bands need period >= 2",
});
}
let n = period as f64;
Ok(Self {
period,
highs: VecDeque::with_capacity(period),
lows: VecDeque::with_capacity(period),
sum_x: n * (n - 1.0) / 2.0,
sum_xx: (n - 1.0) * n * (2.0 * n - 1.0) / 6.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// OLS slope of `(0..period, values)` over the live window.
fn slope(&self, values: &VecDeque<f64>) -> f64 {
let n = self.period as f64;
let mut sum_y = 0.0;
let mut sum_xy = 0.0;
for (i, &y) in values.iter().enumerate() {
sum_y += y;
sum_xy += (i as f64) * y;
}
let denom = n * self.sum_xx - self.sum_x * self.sum_x;
(n * sum_xy - self.sum_x * sum_y) / denom
}
}
impl Indicator for ProjectionBands {
type Input = Candle;
type Output = ProjectionBandsOutput;
fn update(&mut self, candle: Candle) -> Option<ProjectionBandsOutput> {
if self.highs.len() == self.period {
self.highs.pop_front();
self.lows.pop_front();
}
self.highs.push_back(candle.high);
self.lows.push_back(candle.low);
if self.highs.len() < self.period {
return None;
}
let slope_h = self.slope(&self.highs);
let slope_l = self.slope(&self.lows);
let last = (self.period - 1) as f64;
let mut upper = f64::NEG_INFINITY;
let mut lower = f64::INFINITY;
for (i, (&high, &low)) in self.highs.iter().zip(self.lows.iter()).enumerate() {
let forward = last - (i as f64);
let projected_high = high + slope_h * forward;
let projected_low = low + slope_l * forward;
if projected_high > upper {
upper = projected_high;
}
if projected_low < lower {
lower = projected_low;
}
}
Some(ProjectionBandsOutput {
upper,
middle: f64::midpoint(upper, lower),
lower,
})
}
fn reset(&mut self) {
self.highs.clear();
self.lows.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.highs.len() == self.period
}
fn name(&self) -> &'static str {
"ProjectionBands"
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(low, high, low, close, 10.0, ts).unwrap()
}
#[test]
fn rejects_period_below_two() {
assert!(matches!(
ProjectionBands::new(0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
ProjectionBands::new(1),
Err(Error::InvalidPeriod { .. })
));
assert!(ProjectionBands::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let pb = ProjectionBands::new(14).unwrap();
assert_eq!(pb.period(), 14);
assert_eq!(pb.warmup_period(), 14);
assert_eq!(pb.name(), "ProjectionBands");
assert!(!pb.is_ready());
}
#[test]
fn warms_up_then_emits() {
let mut pb = ProjectionBands::new(3).unwrap();
assert!(pb.update(candle(10.0, 8.0, 9.0, 0)).is_none());
assert!(pb.update(candle(12.0, 9.0, 11.0, 1)).is_none());
assert!(pb.update(candle(11.0, 10.0, 11.0, 2)).is_some());
assert!(pb.is_ready());
}
#[test]
fn known_projection() {
// highs 10,12,11 -> slope_h = 0.5; projected = 11, 12.5, 11 -> upper 12.5
// lows 8, 9,10 -> slope_l = 1.0; projected = 10, 10, 10 -> lower 10
let mut pb = ProjectionBands::new(3).unwrap();
pb.update(candle(10.0, 8.0, 9.0, 0));
pb.update(candle(12.0, 9.0, 11.0, 1));
let out = pb.update(candle(11.0, 10.0, 11.0, 2)).unwrap();
assert_relative_eq!(out.upper, 12.5, epsilon = 1e-9);
assert_relative_eq!(out.lower, 10.0, epsilon = 1e-9);
assert_relative_eq!(out.middle, 11.25, epsilon = 1e-9);
}
#[test]
fn perfect_trend_pins_bands_to_current_extremes() {
// High_i and Low_i both rise by exactly 1 per bar: every projected high
// collapses onto the current high, every projected low onto the current
// low.
let mut pb = ProjectionBands::new(5).unwrap();
let mut last = None;
for i in 0..10 {
let high = 100.0 + f64::from(i);
let low = 95.0 + f64::from(i);
last = pb.update(candle(high, low, high, i64::from(i)));
}
let out = last.unwrap();
assert_relative_eq!(out.upper, 109.0, epsilon = 1e-9);
assert_relative_eq!(out.lower, 104.0, epsilon = 1e-9);
assert_relative_eq!(out.middle, 106.5, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut pb = ProjectionBands::new(3).unwrap();
pb.update(candle(10.0, 8.0, 9.0, 0));
pb.update(candle(12.0, 9.0, 11.0, 1));
pb.update(candle(11.0, 10.0, 11.0, 2));
assert!(pb.is_ready());
pb.reset();
assert!(!pb.is_ready());
assert!(pb.update(candle(10.0, 8.0, 9.0, 3)).is_none());
}
}
@@ -0,0 +1,168 @@
//! Projection Oscillator (Mel Widner) — the close's position inside the
//! [`ProjectionBands`](crate::ProjectionBands).
use crate::error::Result;
use crate::indicators::projection_bands::ProjectionBands;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Projection Oscillator: where the close sits inside the projection bands,
/// scaled to `0..100`.
///
/// The companion to [`ProjectionBands`](crate::ProjectionBands) from Mel
/// Widner's May 1995 *Stocks & Commodities* article. It maps the close onto the
/// `[lower, upper]` projection envelope:
///
/// ```text
/// PO = 100 · (close lower) / (upper lower)
/// ```
///
/// `PO = 0` means the close is sitting on the lower band, `PO = 100` on the
/// upper band, and `PO = 50` at the midline. Because the bands by construction
/// bracket every projected high and low, the close almost always falls inside
/// them and the oscillator stays in `0..100` — readings near the extremes flag
/// an overbought/oversold position *relative to the trend-tilted channel*
/// rather than to a horizontal level. When the bands collapse (a zero-range
/// window, `upper == lower`) the position is undefined and the oscillator
/// returns the neutral `50.0`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ProjectionOscillator};
///
/// let mut indicator = ProjectionOscillator::new(14).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ProjectionOscillator {
bands: ProjectionBands,
}
impl ProjectionOscillator {
/// Construct a new Projection Oscillator.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`](crate::Error::InvalidPeriod) if
/// `period < 2`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
bands: ProjectionBands::new(period)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.bands.period()
}
}
impl Indicator for ProjectionOscillator {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let bands = self.bands.update(candle)?;
let width = bands.upper - bands.lower;
if width == 0.0 {
return Some(50.0);
}
Some(100.0 * (candle.close - bands.lower) / width)
}
fn reset(&mut self) {
self.bands.reset();
}
fn warmup_period(&self) -> usize {
self.bands.warmup_period()
}
fn is_ready(&self) -> bool {
self.bands.is_ready()
}
fn name(&self) -> &'static str {
"ProjectionOscillator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(low, high, low, close, 10.0, ts).unwrap()
}
#[test]
fn rejects_period_below_two() {
assert!(matches!(
ProjectionOscillator::new(1),
Err(Error::InvalidPeriod { .. })
));
assert!(ProjectionOscillator::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let po = ProjectionOscillator::new(14).unwrap();
assert_eq!(po.period(), 14);
assert_eq!(po.warmup_period(), 14);
assert_eq!(po.name(), "ProjectionOscillator");
assert!(!po.is_ready());
}
#[test]
fn warms_up_then_emits() {
let mut po = ProjectionOscillator::new(3).unwrap();
assert!(po.update(candle(10.0, 8.0, 9.0, 0)).is_none());
assert!(po.update(candle(12.0, 9.0, 11.0, 1)).is_none());
assert!(po.update(candle(11.0, 10.0, 11.0, 2)).is_some());
assert!(po.is_ready());
}
#[test]
fn known_position() {
// Same window as ProjectionBands::known_projection: upper 12.5, lower 10.
// close 11 -> 100 * (11 - 10) / (12.5 - 10) = 40.
let mut po = ProjectionOscillator::new(3).unwrap();
po.update(candle(10.0, 8.0, 9.0, 0));
po.update(candle(12.0, 9.0, 11.0, 1));
let out = po.update(candle(11.0, 10.0, 11.0, 2)).unwrap();
assert_relative_eq!(out, 40.0, epsilon = 1e-9);
}
#[test]
fn collapsed_bands_return_neutral() {
// Zero-range, perfectly trending candles: upper == lower every bar.
let mut po = ProjectionOscillator::new(3).unwrap();
let mut last = None;
for i in 0..6 {
let v = 100.0 + f64::from(i);
last = po.update(candle(v, v, v, i64::from(i)));
}
assert_relative_eq!(last.unwrap(), 50.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut po = ProjectionOscillator::new(3).unwrap();
po.update(candle(10.0, 8.0, 9.0, 0));
po.update(candle(12.0, 9.0, 11.0, 1));
po.update(candle(11.0, 10.0, 11.0, 2));
assert!(po.is_ready());
po.reset();
assert!(!po.is_ready());
assert!(po.update(candle(10.0, 8.0, 9.0, 3)).is_none());
}
}
@@ -0,0 +1,194 @@
//! Quartile Bands — rolling 25th / 50th / 75th percentile envelope.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::rolling_quantile::quantile_sorted;
use crate::traits::Indicator;
/// Quartile Bands output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct QuartileBandsOutput {
/// Upper band: the rolling third quartile (75th percentile, `Q3`).
pub upper: f64,
/// Middle line: the rolling median (50th percentile, `Q2`).
pub middle: f64,
/// Lower band: the rolling first quartile (25th percentile, `Q1`).
pub lower: f64,
}
/// Quartile Bands: a distribution-based envelope drawn at the rolling quartiles.
///
/// ```text
/// lower = Q1 = 25th percentile of the last `period` values
/// middle = Q2 = 50th percentile (median)
/// upper = Q3 = 75th percentile
/// ```
///
/// Quantiles use the type-7 (`NumPy`/`R-7`) linear interpolation shared with
/// [`RollingQuantile`](crate::RollingQuantile). Where Bollinger Bands assume an
/// approximately normal distribution and size the envelope by the mean and
/// standard deviation, Quartile Bands are fully **non-parametric**: the band
/// edges are order statistics, so a single outlier shifts at most one rank
/// rather than inflating the whole width, and the inter-quartile span between
/// the bands is exactly the [`RollingIqr`](crate::RollingIqr). The middle line
/// is the robust median rather than the mean, so it is unmoved by spikes.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, QuartileBands};
///
/// let mut indicator = QuartileBands::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct QuartileBands {
period: usize,
window: VecDeque<f64>,
scratch: Vec<f64>,
}
impl QuartileBands {
/// Construct new Quartile Bands.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
scratch: Vec::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for QuartileBands {
type Input = f64;
type Output = QuartileBandsOutput;
fn update(&mut self, value: f64) -> Option<QuartileBandsOutput> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
self.scratch.clear();
self.scratch.extend(self.window.iter().copied());
self.scratch.sort_by(f64::total_cmp);
Some(QuartileBandsOutput {
upper: quantile_sorted(&self.scratch, 0.75),
middle: quantile_sorted(&self.scratch, 0.5),
lower: quantile_sorted(&self.scratch, 0.25),
})
}
fn reset(&mut self) {
self.window.clear();
self.scratch.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"QuartileBands"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(QuartileBands::new(0), Err(Error::PeriodZero)));
assert!(QuartileBands::new(1).is_ok());
}
#[test]
fn accessors_and_metadata() {
let qb = QuartileBands::new(20).unwrap();
assert_eq!(qb.period(), 20);
assert_eq!(qb.warmup_period(), 20);
assert_eq!(qb.name(), "QuartileBands");
assert!(!qb.is_ready());
}
#[test]
fn warms_up_then_emits() {
let mut qb = QuartileBands::new(4).unwrap();
assert!(qb.update(10.0).is_none());
assert!(qb.update(20.0).is_none());
assert!(qb.update(30.0).is_none());
assert!(qb.update(40.0).is_some());
assert!(qb.is_ready());
}
#[test]
fn known_quartiles() {
// sorted [10,20,30,40]:
// Q1 h=(4-1)*0.25=0.75 -> 10 + 0.75*10 = 17.5
// Q2 h=1.5 -> 20 + 0.5*10 = 25.0
// Q3 h=2.25 -> 30 + 0.25*10 = 32.5
let mut qb = QuartileBands::new(4).unwrap();
let out = qb.batch(&[40.0, 30.0, 20.0, 10.0]);
let last = out[3].unwrap();
assert_relative_eq!(last.lower, 17.5, epsilon = 1e-9);
assert_relative_eq!(last.middle, 25.0, epsilon = 1e-9);
assert_relative_eq!(last.upper, 32.5, epsilon = 1e-9);
}
#[test]
fn median_robust_to_outlier() {
// A single spike shifts the mean a lot but the median by at most one rank.
let mut qb = QuartileBands::new(5).unwrap();
let out = qb.batch(&[1.0, 2.0, 3.0, 4.0, 1000.0]);
assert_relative_eq!(out[4].unwrap().middle, 3.0, epsilon = 1e-12);
}
#[test]
fn rolling_window_evicts_oldest() {
// Eight values through a period-4 window: only the last four survive,
// reproducing the `known_quartiles` window.
let mut qb = QuartileBands::new(4).unwrap();
let out = qb.batch(&[1.0, 2.0, 3.0, 4.0, 40.0, 30.0, 20.0, 10.0]);
let last = out[7].unwrap();
assert_relative_eq!(last.lower, 17.5, epsilon = 1e-9);
assert_relative_eq!(last.middle, 25.0, epsilon = 1e-9);
assert_relative_eq!(last.upper, 32.5, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut qb = QuartileBands::new(4).unwrap();
for v in [10.0, 20.0, 30.0, 40.0] {
qb.update(v);
}
assert!(qb.is_ready());
qb.reset();
assert!(!qb.is_ready());
assert!(qb.update(10.0).is_none());
}
}
+51 -30
View File
@@ -25,13 +25,24 @@ use crate::traits::Indicator;
#[derive(Debug, Clone)]
pub struct Rsi {
period: usize,
prev_close: Option<f64>,
/// `period - 1` as `f64`, precomputed for the Wilder smoothing step.
n_minus_1: f64,
/// `1 / period`, precomputed so the per-tick smoothing multiplies instead of
/// divides (a reciprocal is hoisted out of the hot path).
inv_period: f64,
/// Previous close, valid once `has_prev` is set. Bare `f64` + flag instead of
/// `Option<f64>` to avoid an enum-tag read on every tick.
prev_close: f64,
has_prev: bool,
// Wilder seeds with the simple average of the first `period` gains/losses,
// then transitions to recursive smoothing.
seed_buf_gains: Vec<f64>,
seed_buf_losses: Vec<f64>,
avg_gain: Option<f64>,
avg_loss: Option<f64>,
/// Smoothed average gain / loss, valid once `avgs_seeded` is set. Bare `f64`s
/// + flag so the hot recurrence avoids reading two `Option<f64>` tags per tick.
avg_gain: f64,
avg_loss: f64,
avgs_seeded: bool,
last_value: Option<f64>,
}
@@ -47,11 +58,15 @@ impl Rsi {
}
Ok(Self {
period,
prev_close: None,
n_minus_1: (period - 1) as f64,
inv_period: 1.0 / period as f64,
prev_close: 0.0,
has_prev: false,
seed_buf_gains: Vec::with_capacity(period),
seed_buf_losses: Vec::with_capacity(period),
avg_gain: None,
avg_loss: None,
avg_gain: 0.0,
avg_loss: 0.0,
avgs_seeded: false,
last_value: None,
})
}
@@ -67,16 +82,16 @@ impl Rsi {
}
fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
if avg_loss == 0.0 {
if avg_gain == 0.0 {
// No movement at all -> RSI undefined; standard convention returns 50.
50.0
} else {
100.0
}
// Algebraically `100 - 100/(1 + ag/al)` collapses to `100·ag/(ag+al)`,
// which needs a single division instead of two and removes the separate
// `rs` step. Edge cases stay exact: `al == 0, ag > 0` gives `100·ag/ag =
// 100`; `ag == 0, al > 0` gives `0`; both zero (no movement) is the
// undefined case and returns the neutral 50.
let denom = avg_gain + avg_loss;
if denom == 0.0 {
50.0
} else {
let rs = avg_gain / avg_loss;
100.0 - 100.0 / (1.0 + rs)
100.0 * avg_gain / denom
}
}
}
@@ -90,22 +105,25 @@ impl Indicator for Rsi {
return self.last_value;
}
let Some(prev) = self.prev_close else {
self.prev_close = Some(input);
if !self.has_prev {
self.prev_close = input;
self.has_prev = true;
return None;
};
self.prev_close = Some(input);
}
let prev = self.prev_close;
self.prev_close = input;
let diff = input - prev;
let gain = if diff > 0.0 { diff } else { 0.0 };
let loss = if diff < 0.0 { -diff } else { 0.0 };
if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
let n = self.period as f64;
let new_ag = (ag * (n - 1.0) + gain) / n;
let new_al = (al * (n - 1.0) + loss) / n;
self.avg_gain = Some(new_ag);
self.avg_loss = Some(new_al);
if self.avgs_seeded {
// Wilder smoothing `(prev·(n-1) + x) / n` with the reciprocal hoisted:
// a fused multiply-add then a multiply by `1/n`, no per-tick division.
let new_ag = self.avg_gain.mul_add(self.n_minus_1, gain) * self.inv_period;
let new_al = self.avg_loss.mul_add(self.n_minus_1, loss) * self.inv_period;
self.avg_gain = new_ag;
self.avg_loss = new_al;
let v = Self::rsi_from_avgs(new_ag, new_al);
self.last_value = Some(v);
return Some(v);
@@ -116,8 +134,9 @@ impl Indicator for Rsi {
if self.seed_buf_gains.len() == self.period {
let ag = self.seed_buf_gains.iter().sum::<f64>() / self.period as f64;
let al = self.seed_buf_losses.iter().sum::<f64>() / self.period as f64;
self.avg_gain = Some(ag);
self.avg_loss = Some(al);
self.avg_gain = ag;
self.avg_loss = al;
self.avgs_seeded = true;
let v = Self::rsi_from_avgs(ag, al);
self.last_value = Some(v);
return Some(v);
@@ -126,11 +145,13 @@ impl Indicator for Rsi {
}
fn reset(&mut self) {
self.prev_close = None;
self.prev_close = 0.0;
self.has_prev = false;
self.seed_buf_gains.clear();
self.seed_buf_losses.clear();
self.avg_gain = None;
self.avg_loss = None;
self.avg_gain = 0.0;
self.avg_loss = 0.0;
self.avgs_seeded = false;
self.last_value = None;
}
+39 -16
View File
@@ -1,7 +1,5 @@
//! Simple Moving Average.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
@@ -33,7 +31,14 @@ use crate::traits::Indicator;
#[derive(Debug, Clone)]
pub struct Sma {
period: usize,
window: VecDeque<f64>,
/// Fixed-capacity ring buffer of the last `period` finite inputs. A flat
/// `Box<[f64]>` with a manual write cursor beats `VecDeque` on this hot path:
/// sequential storage, branchless wraparound, no per-call bookkeeping.
buf: Box<[f64]>,
/// Index of the next slot to write — also the oldest element once full.
head: usize,
/// Number of slots filled, saturating at `period`.
count: usize,
sum: f64,
/// Number of finite updates since the running `sum` was last reseeded from
/// the live window. Caps accumulated floating-point drift on long streams.
@@ -60,7 +65,9 @@ impl Sma {
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
buf: vec![0.0; period].into_boxed_slice(),
head: 0,
count: 0,
sum: 0.0,
updates_since_recompute: 0,
})
@@ -73,7 +80,7 @@ impl Sma {
/// Current value if available.
pub fn value(&self) -> Option<f64> {
if self.window.len() == self.period {
if self.count == self.period {
Some(self.sum / self.period as f64)
} else {
None
@@ -89,25 +96,40 @@ impl Indicator for Sma {
if !input.is_finite() {
return self.value();
}
if self.window.len() == self.period {
// Slide: drop the oldest, then add the new. Each step is a single
// f64 add/subtract — O(1) but introduces ~1 ULP of rounding noise.
// The periodic reseed below caps the accumulated drift.
let old = self.window.pop_front().expect("window non-empty");
self.sum -= old;
if self.count == self.period {
// Window full: overwrite the oldest slot (at `head`). Each step is a
// single f64 add/subtract — O(1) but introduces ~1 ULP of rounding
// noise. The periodic reseed below caps the accumulated drift.
self.sum -= self.buf[self.head];
self.buf[self.head] = input;
self.sum += input;
} else {
self.buf[self.head] = input;
self.sum += input;
self.count += 1;
}
// Branchless-ish wraparound, cheaper than `% period`.
self.head += 1;
if self.head == self.period {
self.head = 0;
}
self.window.push_back(input);
self.sum += input;
self.updates_since_recompute += 1;
if self.updates_since_recompute >= RECOMPUTE_EVERY * self.period {
self.sum = self.window.iter().copied().sum();
// Reseed in chronological order (oldest at `head`) so the running sum
// tracks a fresh from-scratch mean to the bit on stable inputs.
self.sum = self.buf[self.head..]
.iter()
.chain(&self.buf[..self.head])
.copied()
.sum();
self.updates_since_recompute = 0;
}
self.value()
}
fn reset(&mut self) {
self.window.clear();
self.head = 0;
self.count = 0;
self.sum = 0.0;
self.updates_since_recompute = 0;
}
@@ -117,7 +139,7 @@ impl Indicator for Sma {
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
self.count == self.period
}
fn name(&self) -> &'static str {
@@ -130,6 +152,7 @@ mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
use std::collections::VecDeque;
#[test]
fn new_rejects_zero_period() {
@@ -0,0 +1,192 @@
//! Time-Based Stop — a holding-period timer that fires after a fixed bar count.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Time-Based Stop — exits a position purely on **elapsed bars**, independent of
/// price.
///
/// ```text
/// bars_held increments by 1 each bar (since the last reset)
/// progress = min(bars_held / max_bars, 1.0) in [0, 1]
/// stop fires when progress == 1.0 (bars_held >= max_bars)
/// ```
///
/// Some setups should not be given unlimited time to work: a mean-reversion entry
/// that has not reverted within `max_bars`, or an event trade whose catalyst has
/// passed, is best closed regardless of price. This indicator is a pure timer —
/// it ignores the candle's prices entirely and reports the fraction of the
/// holding window that has elapsed, reaching `1.0` (the stop) after `max_bars`
/// bars. **Call [`reset`](Indicator::reset) on each new entry** so the timer
/// restarts from the position open.
///
/// Each `update` is O(1) and the first bar already emits a value
/// (`1 / max_bars`).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TimeBasedStop};
///
/// let mut indicator = TimeBasedStop::new(5).unwrap();
/// let c = Candle::new(100.0, 101.0, 99.0, 100.0, 1.0, 0).unwrap();
/// // Five bars reach the stop.
/// let mut last = 0.0;
/// for _ in 0..5 {
/// last = indicator.update(c).unwrap();
/// }
/// assert_eq!(last, 1.0);
/// ```
#[derive(Debug, Clone)]
pub struct TimeBasedStop {
max_bars: usize,
bars_held: usize,
last: Option<f64>,
}
impl TimeBasedStop {
/// Construct a time-based stop that fires after `max_bars` bars.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `max_bars == 0`.
pub fn new(max_bars: usize) -> Result<Self> {
if max_bars == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
max_bars,
bars_held: 0,
last: None,
})
}
/// Configured maximum holding period in bars.
pub const fn max_bars(&self) -> usize {
self.max_bars
}
/// Number of bars held since the last reset.
pub const fn bars_held(&self) -> usize {
self.bars_held
}
/// Whether the stop has fired (the holding period has fully elapsed).
pub const fn triggered(&self) -> bool {
self.bars_held >= self.max_bars
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for TimeBasedStop {
type Input = Candle;
type Output = f64;
fn update(&mut self, _candle: Candle) -> Option<f64> {
self.bars_held += 1;
let progress = (self.bars_held as f64 / self.max_bars as f64).min(1.0);
self.last = Some(progress);
Some(progress)
}
fn reset(&mut self) {
self.bars_held = 0;
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"TimeBasedStop"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c() -> Candle {
Candle::new_unchecked(100.0, 101.0, 99.0, 100.0, 1.0, 0)
}
#[test]
fn rejects_zero_max_bars() {
assert!(matches!(TimeBasedStop::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let t = TimeBasedStop::new(5).unwrap();
assert_eq!(t.max_bars(), 5);
assert_eq!(t.bars_held(), 0);
assert!(!t.triggered());
assert_eq!(t.warmup_period(), 1);
assert_eq!(t.name(), "TimeBasedStop");
assert!(!t.is_ready());
assert_eq!(t.value(), None);
}
#[test]
fn progress_climbs_to_one() {
let mut t = TimeBasedStop::new(4).unwrap();
let out = t.batch(&[c(), c(), c(), c()]);
assert_relative_eq!(out[0].unwrap(), 0.25, epsilon = 1e-12);
assert_relative_eq!(out[1].unwrap(), 0.50, epsilon = 1e-12);
assert_relative_eq!(out[2].unwrap(), 0.75, epsilon = 1e-12);
assert_relative_eq!(out[3].unwrap(), 1.00, epsilon = 1e-12);
}
#[test]
fn triggers_after_max_bars() {
let mut t = TimeBasedStop::new(3).unwrap();
t.update(c());
assert!(!t.triggered());
t.update(c());
assert!(!t.triggered());
t.update(c());
assert!(t.triggered());
}
#[test]
fn progress_saturates_at_one() {
// Beyond max_bars the progress stays clamped at 1.0.
let mut t = TimeBasedStop::new(2).unwrap();
let out = t.batch(&[c(), c(), c(), c()]);
assert_relative_eq!(out[2].unwrap(), 1.0, epsilon = 1e-12);
assert_relative_eq!(out[3].unwrap(), 1.0, epsilon = 1e-12);
}
#[test]
fn reset_restarts_timer() {
let mut t = TimeBasedStop::new(3).unwrap();
t.batch(&[c(), c(), c()]);
assert!(t.triggered());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.bars_held(), 0);
assert!(!t.triggered());
assert_relative_eq!(t.update(c()).unwrap(), 1.0 / 3.0, epsilon = 1e-12);
}
#[test]
fn batch_equals_streaming() {
let candles = [c(); 10];
let batch = TimeBasedStop::new(4).unwrap().batch(&candles);
let mut b = TimeBasedStop::new(4).unwrap();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,381 @@
//! Volatility Cone — current realized volatility within its historical envelope.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`VolatilityCone`]: the current realized volatility together with
/// the envelope (the "cone") it sits inside over the lookback window.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VolatilityConeOutput {
/// Latest realized volatility (sample stddev of log returns over `window`).
pub current: f64,
/// Lowest realized volatility seen over the `lookback` window.
pub min: f64,
/// Median realized volatility over the `lookback` window.
pub median: f64,
/// Highest realized volatility seen over the `lookback` window.
pub max: f64,
/// Percentile rank of `current` within the lookback distribution, in
/// `[0, 100]` — the share of stored volatilities `<= current`, times 100.
pub percentile: f64,
}
/// Sample standard deviation from a running `(sum, sum_of_squares, count)`.
fn sample_stddev(sum: f64, sum_sq: f64, count: usize) -> f64 {
let n = count as f64;
let mean = sum / n;
let variance = ((sum_sq - n * mean * mean) / (n - 1.0)).max(0.0);
variance.sqrt()
}
/// Volatility Cone — the current realized volatility positioned within the
/// historical range ("cone") of realized volatilities over a lookback window.
///
/// ```text
/// r_t = ln(close_t / close_{t1})
/// vol_t = stddev_sample(r over window) (rolling realized volatility)
/// cone = { min, median, max, percentile } of vol over the last `lookback`
/// ```
///
/// A volatility cone (Burghardt & Lane 1990) shows whether current volatility is
/// high or low *relative to its own history*, rather than as an absolute number.
/// This streaming form tracks one horizon: it maintains the rolling realized
/// volatility of log returns over `window`, then reports the latest reading
/// (`current`) alongside the `min`, `median`, `max` and percentile rank of that
/// volatility series over the trailing `lookback`. `current` always lies within
/// `[min, max]` because it is itself the newest member of the lookback set.
///
/// Only the candle's **close** is used (the log-return series); the high and low
/// are ignored. The volatility is per-period (sample stddev of log returns, not
/// annualised) — multiply by `√trading_periods` for an annual figure. Each
/// `update` is O(`lookback log lookback`) from sorting the envelope.
///
/// Non-positive closes are ignored (the log return would be undefined): the tick
/// is dropped, state is left untouched, and the last value is returned.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, VolatilityCone};
///
/// let mut indicator = VolatilityCone::new(20, 60).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// let c = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
/// let candle = Candle::new(c, c + 1.0, c - 1.0, c, 1_000.0, 0).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct VolatilityCone {
window: usize,
lookback: usize,
prev_close: Option<f64>,
/// Rolling window of log returns for the inner realized-volatility series.
returns: VecDeque<f64>,
ret_sum: f64,
ret_sum_sq: f64,
/// Rolling window of realized-volatility readings (the cone envelope).
vols: VecDeque<f64>,
last: Option<VolatilityConeOutput>,
}
impl VolatilityCone {
/// Construct a new volatility-cone indicator.
///
/// `window` is the realized-volatility estimation window; `lookback` is the
/// number of volatility readings forming the historical cone.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if either argument is `0`, or
/// [`Error::InvalidPeriod`] if `window < 2` (a sample stddev needs two
/// returns) or `lookback < 2` (an envelope needs at least two readings).
pub fn new(window: usize, lookback: usize) -> Result<Self> {
if window == 0 || lookback == 0 {
return Err(Error::PeriodZero);
}
if window < 2 || lookback < 2 {
return Err(Error::InvalidPeriod {
message: "volatility cone window and lookback must both be >= 2",
});
}
Ok(Self {
window,
lookback,
prev_close: None,
returns: VecDeque::with_capacity(window),
ret_sum: 0.0,
ret_sum_sq: 0.0,
vols: VecDeque::with_capacity(lookback),
last: None,
})
}
/// Configured `(window, lookback)`.
pub const fn windows(&self) -> (usize, usize) {
(self.window, self.lookback)
}
/// Current value if available.
pub const fn value(&self) -> Option<VolatilityConeOutput> {
self.last
}
}
impl Indicator for VolatilityCone {
type Input = Candle;
type Output = VolatilityConeOutput;
fn update(&mut self, candle: Candle) -> Option<VolatilityConeOutput> {
let price = candle.close;
// A log return is undefined for a non-positive close; skip the tick.
if price <= 0.0 {
return self.last;
}
let Some(prev) = self.prev_close else {
self.prev_close = Some(price);
return None;
};
self.prev_close = Some(price);
// `prev` came from `self.prev_close`, gated by the guard above, so it is
// positive — the log return is always well-defined.
let r = (price / prev).ln();
// Stage one: rolling sample volatility of log returns.
if self.returns.len() == self.window {
let old = self.returns.pop_front().expect("returns window non-empty");
self.ret_sum -= old;
self.ret_sum_sq -= old * old;
}
self.returns.push_back(r);
self.ret_sum += r;
self.ret_sum_sq += r * r;
if self.returns.len() < self.window {
return None;
}
let current = sample_stddev(self.ret_sum, self.ret_sum_sq, self.window);
// Stage two: maintain the lookback envelope of volatility readings.
if self.vols.len() == self.lookback {
self.vols.pop_front();
}
self.vols.push_back(current);
if self.vols.len() < self.lookback {
return None;
}
let mut sorted: Vec<f64> = self.vols.iter().copied().collect();
sorted.sort_by(f64::total_cmp);
let min = sorted[0];
let max = sorted[self.lookback - 1];
let mid = self.lookback / 2;
let median = if self.lookback % 2 == 1 {
sorted[mid]
} else {
f64::midpoint(sorted[mid - 1], sorted[mid])
};
let count_le = self.vols.iter().filter(|&&v| v <= current).count();
let percentile = count_le as f64 / self.lookback as f64 * 100.0;
let out = VolatilityConeOutput {
current,
min,
median,
max,
percentile,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.prev_close = None;
self.returns.clear();
self.ret_sum = 0.0;
self.ret_sum_sq = 0.0;
self.vols.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
// One previous close for the first return, `window` returns for the
// first volatility, then `lookback` volatilities for the envelope.
self.window + self.lookback
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"VolatilityCone"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Candle whose close drives the indicator (open = high = low = close here).
fn close_candle(close: f64) -> Candle {
Candle::new_unchecked(close, close, close, close, 1_000.0, 0)
}
#[test]
fn rejects_zero_window() {
assert!(matches!(VolatilityCone::new(0, 10), Err(Error::PeriodZero)));
assert!(matches!(VolatilityCone::new(10, 0), Err(Error::PeriodZero)));
}
#[test]
fn rejects_window_one() {
assert!(matches!(
VolatilityCone::new(1, 10),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
VolatilityCone::new(10, 1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let vc = VolatilityCone::new(20, 60).unwrap();
assert_eq!(vc.windows(), (20, 60));
assert_eq!(vc.warmup_period(), 80);
assert_eq!(vc.name(), "VolatilityCone");
assert!(!vc.is_ready());
assert_eq!(vc.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut vc = VolatilityCone::new(2, 2).unwrap();
let prices = [100.0, 110.0, 121.0, 100.0, 105.0, 99.0];
let candles: Vec<Candle> = prices.iter().map(|p| close_candle(*p)).collect();
let out = vc.batch(&candles);
let warmup = vc.warmup_period(); // 4
assert_eq!(warmup, 4);
for v in out.iter().take(warmup - 1) {
assert!(v.is_none());
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn known_value() {
// window = 2 -> vol = |r_t r_{t1}| / √2; lookback = 2.
// prices: r1 = r2 = ln(1.1), r3 = ln(100/121).
let mut vc = VolatilityCone::new(2, 2).unwrap();
let candles: Vec<Candle> = [100.0, 110.0, 121.0, 100.0]
.iter()
.map(|p| close_candle(*p))
.collect();
let out = vc.batch(&candles);
let r2 = (121.0_f64 / 110.0).ln();
let r3 = (100.0_f64 / 121.0).ln();
let vol2 = (r2 - r3).abs() / 2.0_f64.sqrt();
let o = out[3].unwrap();
assert_relative_eq!(o.current, vol2, epsilon = 1e-9);
assert_relative_eq!(o.min, 0.0, epsilon = 1e-9); // vol1 = 0 (r1 == r2)
assert_relative_eq!(o.max, vol2, epsilon = 1e-9);
assert_relative_eq!(o.median, vol2 / 2.0, epsilon = 1e-9);
assert_relative_eq!(o.percentile, 100.0, epsilon = 1e-9);
}
#[test]
fn odd_lookback_median_is_middle() {
// lookback = 3 picks the middle of the sorted envelope.
let mut vc = VolatilityCone::new(2, 3).unwrap();
let candles: Vec<Candle> = [100.0, 101.0, 103.0, 100.0, 104.0, 99.0, 106.0]
.iter()
.map(|p| close_candle(*p))
.collect();
let out = vc.batch(&candles);
let o = out.last().unwrap().unwrap();
assert!(o.min <= o.median && o.median <= o.max);
}
#[test]
fn envelope_brackets_current() {
let mut vc = VolatilityCone::new(10, 30).unwrap();
let candles: Vec<Candle> = (0..200)
.map(|i| close_candle(100.0 + (f64::from(i) * 0.3).sin() * 12.0))
.collect();
for o in vc.batch(&candles).into_iter().flatten() {
assert!(o.min <= o.current && o.current <= o.max);
assert!(o.min <= o.median && o.median <= o.max);
assert!(o.percentile > 0.0 && o.percentile <= 100.0);
}
}
#[test]
fn constant_series_yields_zero_cone() {
let mut vc = VolatilityCone::new(5, 5).unwrap();
let candles: Vec<Candle> = (0..40).map(|_| close_candle(100.0)).collect();
for o in vc.batch(&candles).into_iter().flatten() {
assert_relative_eq!(o.current, 0.0, epsilon = 1e-12);
assert_relative_eq!(o.min, 0.0, epsilon = 1e-12);
assert_relative_eq!(o.max, 0.0, epsilon = 1e-12);
assert_relative_eq!(o.median, 0.0, epsilon = 1e-12);
assert_relative_eq!(o.percentile, 100.0, epsilon = 1e-12);
}
}
#[test]
fn skips_non_positive_close() {
let mut vc = VolatilityCone::new(2, 2).unwrap();
let candles: Vec<Candle> = [100.0, 110.0, 121.0, 100.0]
.iter()
.map(|p| close_candle(*p))
.collect();
let warmup = vc.batch(&candles);
let baseline = warmup.last().copied().flatten().expect("warmed up");
// A non-positive close is skipped and the previous value is returned.
assert_eq!(vc.update(close_candle(0.0)), Some(baseline));
// State untouched: a clone advanced by the same real tick agrees.
let mut control = vc.clone();
let after = vc.update(close_candle(105.0)).expect("ready");
assert_eq!(control.update(close_candle(105.0)).expect("ready"), after);
}
#[test]
fn skips_non_positive_before_first_close() {
let mut vc = VolatilityCone::new(2, 2).unwrap();
assert_eq!(vc.update(close_candle(0.0)), None);
assert_eq!(vc.update(close_candle(100.0)), None);
}
#[test]
fn reset_clears_state() {
let mut vc = VolatilityCone::new(2, 2).unwrap();
let candles: Vec<Candle> = [100.0, 110.0, 121.0, 100.0, 105.0]
.iter()
.map(|p| close_candle(*p))
.collect();
vc.batch(&candles);
assert!(vc.is_ready());
vc.reset();
assert!(!vc.is_ready());
assert_eq!(vc.value(), None);
assert_eq!(vc.update(close_candle(100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..200)
.map(|i| close_candle(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
.collect();
let batch = VolatilityCone::new(10, 30).unwrap().batch(&candles);
let mut b = VolatilityCone::new(10, 30).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,333 @@
//! Volatility of Volatility — the dispersion of a rolling volatility series.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Sample standard deviation from a running `(sum, sum_of_squares, count)`.
///
/// Uses Bessel's correction (divisor `n 1`) and clamps a tiny negative
/// floating-point residual to zero before the square root.
fn sample_stddev(sum: f64, sum_sq: f64, count: usize) -> f64 {
let n = count as f64;
let mean = sum / n;
let variance = ((sum_sq - n * mean * mean) / (n - 1.0)).max(0.0);
variance.sqrt()
}
/// Volatility of Volatility — the standard deviation of a rolling realized-
/// volatility series ("vol-of-vol").
///
/// ```text
/// r_t = ln(price_t / price_{t1})
/// vol_t = stddev_sample(r over vol_window) (rolling realized volatility)
/// VoV = stddev_sample(vol over vov_window) (dispersion of that series)
/// ```
///
/// This is a two-stage estimator: the first stage measures the rolling sample
/// volatility of log returns (the same quantity
/// [`HistoricalVolatility`](crate::HistoricalVolatility) annualises), and the
/// second stage measures how much *that* volatility itself moves. A high
/// vol-of-vol means the volatility regime is unstable — turbulent periods
/// alternate with calm ones — which is exactly the convexity that long-gamma and
/// volatility-trading strategies care about. Both stages use the unbiased
/// `n 1` sample standard deviation. Each `update` is O(1).
///
/// Non-finite and non-positive prices are ignored (the log return would be
/// undefined): the tick is dropped, state is left untouched, and the last value
/// is returned.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, VolatilityOfVolatility};
///
/// let mut indicator = VolatilityOfVolatility::new(20, 20).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct VolatilityOfVolatility {
vol_window: usize,
vov_window: usize,
prev_price: Option<f64>,
/// Rolling window of log returns (stage one).
returns: VecDeque<f64>,
ret_sum: f64,
ret_sum_sq: f64,
/// Rolling window of realized-volatility readings (stage two).
vols: VecDeque<f64>,
vol_sum: f64,
vol_sum_sq: f64,
last: Option<f64>,
}
impl VolatilityOfVolatility {
/// Construct a new vol-of-vol indicator.
///
/// `vol_window` is the window for the inner realized-volatility series;
/// `vov_window` is the window over which its dispersion is measured.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if either window is `0`, or
/// [`Error::InvalidPeriod`] if either is `1` (a sample standard deviation
/// needs at least two observations).
pub fn new(vol_window: usize, vov_window: usize) -> Result<Self> {
if vol_window == 0 || vov_window == 0 {
return Err(Error::PeriodZero);
}
if vol_window < 2 || vov_window < 2 {
return Err(Error::InvalidPeriod {
message: "vol-of-vol windows must both be >= 2",
});
}
Ok(Self {
vol_window,
vov_window,
prev_price: None,
returns: VecDeque::with_capacity(vol_window),
ret_sum: 0.0,
ret_sum_sq: 0.0,
vols: VecDeque::with_capacity(vov_window),
vol_sum: 0.0,
vol_sum_sq: 0.0,
last: None,
})
}
/// Configured `(vol_window, vov_window)`.
pub const fn windows(&self) -> (usize, usize) {
(self.vol_window, self.vov_window)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for VolatilityOfVolatility {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Non-finite / non-positive prices are skipped: `ln(input / prev)` is
// undefined, so the tick must not enter the return window.
if !input.is_finite() || input <= 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(input);
return None;
};
self.prev_price = Some(input);
// `prev` came from `self.prev_price`, gated by the guard above, so it is
// finite and positive — the log return is always well-defined.
let r = (input / prev).ln();
// Stage one: rolling sample volatility of log returns.
if self.returns.len() == self.vol_window {
let old = self.returns.pop_front().expect("returns window non-empty");
self.ret_sum -= old;
self.ret_sum_sq -= old * old;
}
self.returns.push_back(r);
self.ret_sum += r;
self.ret_sum_sq += r * r;
if self.returns.len() < self.vol_window {
return None;
}
let vol = sample_stddev(self.ret_sum, self.ret_sum_sq, self.vol_window);
// Stage two: rolling sample dispersion of the volatility series.
if self.vols.len() == self.vov_window {
let old = self.vols.pop_front().expect("vols window non-empty");
self.vol_sum -= old;
self.vol_sum_sq -= old * old;
}
self.vols.push_back(vol);
self.vol_sum += vol;
self.vol_sum_sq += vol * vol;
if self.vols.len() < self.vov_window {
return None;
}
let vov = sample_stddev(self.vol_sum, self.vol_sum_sq, self.vov_window);
self.last = Some(vov);
Some(vov)
}
fn reset(&mut self) {
self.prev_price = None;
self.returns.clear();
self.ret_sum = 0.0;
self.ret_sum_sq = 0.0;
self.vols.clear();
self.vol_sum = 0.0;
self.vol_sum_sq = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
// One previous price for the first return, `vol_window` returns for the
// first volatility, then `vov_window` volatilities for the dispersion.
// The two windows overlap on the bar axis, so this is the sum.
self.vol_window + self.vov_window
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"VolatilityOfVolatility"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use crate::HistoricalVolatility;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_window() {
assert!(matches!(
VolatilityOfVolatility::new(0, 10),
Err(Error::PeriodZero)
));
assert!(matches!(
VolatilityOfVolatility::new(10, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn rejects_window_one() {
assert!(matches!(
VolatilityOfVolatility::new(1, 10),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
VolatilityOfVolatility::new(10, 1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let vov = VolatilityOfVolatility::new(20, 10).unwrap();
assert_eq!(vov.windows(), (20, 10));
assert_eq!(vov.warmup_period(), 30);
assert_eq!(vov.name(), "VolatilityOfVolatility");
assert!(!vov.is_ready());
assert_eq!(vov.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut vov = VolatilityOfVolatility::new(3, 3).unwrap();
let prices: Vec<f64> = (1..=20)
.map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 4.0)
.collect();
let out = vov.batch(&prices);
let warmup = vov.warmup_period(); // 6
for v in out.iter().take(warmup - 1) {
assert!(v.is_none());
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn matches_two_stage_reference() {
// Stage one equals HistoricalVolatility(vol_window, 1) / 100 (sample
// stddev of log returns); stage two is the sample stddev of that series.
let (vol_window, vov_window) = (3, 3);
let prices: Vec<f64> = [100.0, 102.0, 101.0, 104.0, 103.5, 106.0, 105.0, 108.0].to_vec();
let mut hv = HistoricalVolatility::new(vol_window, 1).unwrap();
let vol_series: Vec<f64> = hv
.batch(&prices)
.into_iter()
.flatten()
.map(|v| v / 100.0)
.collect();
// Sample stddev of the last `vov_window` volatilities.
let tail = &vol_series[vol_series.len() - vov_window..];
let sum: f64 = tail.iter().sum();
let sum_sq: f64 = tail.iter().map(|v| v * v).sum();
let expected = sample_stddev(sum, sum_sq, vov_window);
let mut vov = VolatilityOfVolatility::new(vol_window, vov_window).unwrap();
let out = vov.batch(&prices);
assert_relative_eq!(out.last().unwrap().unwrap(), expected, epsilon = 1e-9);
}
#[test]
fn constant_series_yields_zero() {
let mut vov = VolatilityOfVolatility::new(5, 5).unwrap();
for v in vov.batch(&[100.0; 60]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn output_is_non_negative() {
let mut vov = VolatilityOfVolatility::new(10, 10).unwrap();
let prices: Vec<f64> = (1..=300)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
.collect();
for v in vov.batch(&prices).into_iter().flatten() {
assert!(v >= 0.0, "vol-of-vol must be non-negative, got {v}");
}
}
#[test]
fn ignores_non_finite_input() {
let mut vov = VolatilityOfVolatility::new(3, 3).unwrap();
let out = vov.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(vov.update(f64::NAN), last);
assert_eq!(vov.update(f64::INFINITY), last);
}
#[test]
fn skips_non_positive_prices() {
let mut vov = VolatilityOfVolatility::new(3, 3).unwrap();
let warmup = vov.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
let baseline = warmup.last().copied().flatten().expect("warmed up");
assert_eq!(vov.update(-5.0), Some(baseline));
assert_eq!(vov.update(0.0), Some(baseline));
// State untouched: a clone advanced by the same real tick agrees.
let mut control = vov.clone();
let after = vov.update(41.0).expect("ready");
assert_eq!(control.update(41.0).expect("ready"), after);
}
#[test]
fn reset_clears_state() {
let mut vov = VolatilityOfVolatility::new(3, 3).unwrap();
vov.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
assert!(vov.is_ready());
vov.reset();
assert!(!vov.is_ready());
assert_eq!(vov.value(), None);
assert_eq!(vov.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=200)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = VolatilityOfVolatility::new(10, 10).unwrap().batch(&prices);
let mut b = VolatilityOfVolatility::new(10, 10).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,285 @@
//! Schwager's Volatility Ratio — today's true range versus its typical level.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Schwager's Volatility Ratio — the current bar's true range divided by the
/// exponential moving average of the *prior* true ranges.
///
/// ```text
/// TR_t = true range of bar t
/// VR_t = TR_t / EMA_n(TR through bar t1)
/// ```
///
/// Jack Schwager's volatility ratio measures how today's range compares to its
/// recent typical level: a reading above `2.0` marks a **wide-ranging day** —
/// today's true range is more than twice the smoothed average — which often
/// precedes or accompanies a reversal. The denominator is the exponential
/// moving average of true range *excluding the current bar*, seeded with the
/// simple average of the first `period` true ranges, so a single large bar
/// stands out instead of inflating its own benchmark.
///
/// True range is `max(high low, |high prev_close|, |low prev_close|)`,
/// identical to the [`Atr`](crate::Atr) building block, but here it is compared
/// to a *standard* EMA (smoothing `2 / (period + 1)`) rather than Wilder
/// smoothing, which keeps the ratio distinct from `TR / ATR`. Each `update` is
/// O(1).
///
/// A flat market drives every true range — and the EMA — to `0`; the ratio is
/// then `0.0` rather than an undefined `0 / 0`. `Candle::new` rejects non-finite
/// fields, so no in-method finiteness guard is needed.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, VolatilityRatio};
///
/// let mut indicator = VolatilityRatio::new(14).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle = Candle::new(base, base + 2.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct VolatilityRatio {
period: usize,
alpha: f64,
prev_close: Option<f64>,
/// Sum and count of the first `period` true ranges, used to seed the EMA.
seed_sum: f64,
seed_count: usize,
/// EMA of true range through the previous bar; `None` until seeded.
ema: Option<f64>,
last: Option<f64>,
}
impl VolatilityRatio {
/// Construct a new volatility-ratio indicator.
///
/// `period` is the number of true ranges that seed and smooth the
/// denominator EMA.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
alpha: 2.0 / (period as f64 + 1.0),
prev_close: None,
seed_sum: 0.0,
seed_count: 0,
ema: None,
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for VolatilityRatio {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
// The first bar has no previous close, so no true range can be formed.
let Some(prev_close) = self.prev_close else {
self.prev_close = Some(candle.close);
return None;
};
let tr = candle.true_range(Some(prev_close));
self.prev_close = Some(candle.close);
match self.ema {
None => {
// Seeding the EMA with the simple average of the first `period`
// true ranges; emit nothing until it is established.
self.seed_sum += tr;
self.seed_count += 1;
if self.seed_count == self.period {
self.ema = Some(self.seed_sum / self.period as f64);
}
None
}
Some(prev_ema) => {
// Denominator excludes the current bar (it is the EMA through the
// previous bar). A flat benchmark yields 0.0, not 0/0.
let vr = if prev_ema > 0.0 { tr / prev_ema } else { 0.0 };
self.ema = Some(self.alpha * tr + (1.0 - self.alpha) * prev_ema);
self.last = Some(vr);
Some(vr)
}
}
}
fn reset(&mut self) {
self.prev_close = None;
self.seed_sum = 0.0;
self.seed_count = 0;
self.ema = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
// Bar 1 sets the previous close; bars 2..=period+1 seed the EMA; the
// first ratio is emitted on bar period + 2.
self.period + 2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"VolatilityRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Build a candle with the given high/low/close (open = low, fixed volume).
fn candle(high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(low, high, low, close, 1_000.0, 0)
}
#[test]
fn rejects_zero_period() {
assert!(matches!(VolatilityRatio::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let vr = VolatilityRatio::new(14).unwrap();
assert_eq!(vr.period(), 14);
assert_eq!(vr.warmup_period(), 16);
assert_eq!(vr.name(), "VolatilityRatio");
assert!(!vr.is_ready());
assert_eq!(vr.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut vr = VolatilityRatio::new(3).unwrap();
// Build enough constant-range candles to reach warmup.
let candles: Vec<Candle> = (0..10)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base)
})
.collect();
let out = vr.batch(&candles);
// warmup_period == period + 2 == 5: the first emission is at index 4.
let warmup = vr.warmup_period();
assert_eq!(warmup, 5);
for v in out.iter().take(warmup - 1) {
assert!(v.is_none());
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn wide_ranging_day_exceeds_two() {
// Steady true range of 2.0 seeds the EMA, then one bar with a far wider
// range pushes the ratio above 2.0.
let mut vr = VolatilityRatio::new(3).unwrap();
let mut candles: Vec<Candle> = (0..6)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base) // TR = 2.0 each
})
.collect();
// A wide bar: range 10 around the last close (~105).
candles.push(candle(110.0, 100.0, 105.0));
let out = vr.batch(&candles);
let last = out.last().unwrap().unwrap();
assert!(last > 2.0, "wide-ranging day should exceed 2.0, got {last}");
}
#[test]
fn steady_range_ratio_is_one() {
// Constant true range -> EMA equals it -> ratio is exactly 1.0.
let mut vr = VolatilityRatio::new(3).unwrap();
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base) // TR = 2.0 each
})
.collect();
let out = vr.batch(&candles);
assert_relative_eq!(out.last().unwrap().unwrap(), 1.0, epsilon = 1e-9);
}
#[test]
fn flat_market_yields_zero() {
// Zero-range candles: TR = 0, EMA = 0, ratio guarded to 0.0.
let mut vr = VolatilityRatio::new(3).unwrap();
let candles: Vec<Candle> = (0..10).map(|_| candle(100.0, 100.0, 100.0)).collect();
let out = vr.batch(&candles);
for v in out.into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn output_is_non_negative() {
let mut vr = VolatilityRatio::new(14).unwrap();
let candles: Vec<Candle> = (0..200)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0;
candle(base + 2.0, base - 2.0, base + 0.5)
})
.collect();
for v in vr.batch(&candles).into_iter().flatten() {
assert!(v >= 0.0, "volatility ratio must be non-negative, got {v}");
}
}
#[test]
fn reset_clears_state() {
let mut vr = VolatilityRatio::new(3).unwrap();
let candles: Vec<Candle> = (0..10)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base)
})
.collect();
vr.batch(&candles);
assert!(vr.is_ready());
vr.reset();
assert!(!vr.is_ready());
assert_eq!(vr.value(), None);
assert_eq!(vr.update(candle(101.0, 99.0, 100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
candle(base + 2.0, base - 1.5, base + 0.5)
})
.collect();
let batch = VolatilityRatio::new(14).unwrap().batch(&candles);
let mut b = VolatilityRatio::new(14).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
+65 -60
View File
@@ -60,30 +60,31 @@ pub use indicators::{
AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCycle, AdaptiveLaguerreFilter, Adl,
AdvanceBlock, AdvanceDecline, AdvanceDeclineRatio, Adx, AdxOutput, Adxr, Alligator,
AlligatorOutput, Alma, Alpha, AmihudIlliquidity, AnchoredRsi, AnchoredVwap, Apo, Aroon,
AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, AutoFib,
AutoFibOutput, Autocorrelation, AverageDailyRange, AverageDrawdown, AvgPrice,
AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BeltHold, Beta,
BetaNeutralSpread, BodySizePct, BollingerBands, BollingerBandwidth, BollingerOutput,
BreadthThrust, Breakaway, BullishPercentIndex, Butterfly, CalendarSpread, CalmarRatio,
Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow,
ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, CloseVsOpen,
ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput,
ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock, Counterattack, Crab,
CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle, CyberneticCycle, Cypher,
DayOfWeekProfile, DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex,
DemarkPivots, DemarkPivotsOutput, DepthSlope, DerivativeOscillator, DetrendedStdDev,
DisparityIndex, DistanceSsd, Doji, DojiStar, Donchian, DonchianOutput, DonchianStop,
DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, DoubleTopBottom,
DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, Dx, DynamicMomentumIndex,
EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma, ElderImpulse, ElderRay,
ElderRayOutput, Ema, EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, Expectancy,
FallingThreeMethods, Fama, FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence,
FibConfluenceOutput, FibExtension, FibExtensionOutput, FibFan, FibFanOutput, FibProjection,
FibProjectionOutput, FibRetracement, FibRetracementOutput, FibTimeZones, FibTimeZonesOutput,
FibonacciPivots, FibonacciPivotsOutput, FisherRsi, FisherTransform, FlagPennant, Footprint,
FootprintOutput, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis,
FundingRate, FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite,
AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrRatchet, AtrRatchetOutput,
AtrTrailingStop, AutoFib, AutoFibOutput, Autocorrelation, AverageDailyRange, AverageDrawdown,
AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BeltHold, Beta,
BetaNeutralSpread, BipowerVariation, BodySizePct, BollingerBands, BollingerBandwidth,
BollingerOutput, BomarBands, BomarBandsOutput, BreadthThrust, Breakaway, BullishPercentIndex,
Butterfly, CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity,
Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop,
ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots,
ClassicPivotsOutput, CloseVsOpen, ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration,
CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock,
Counterattack, Crab, CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle,
CyberneticCycle, Cypher, DayOfWeekProfile, DayOfWeekProfileOutput, Decycler,
DecyclerOscillator, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, DepthSlope,
DerivativeOscillator, DetrendedStdDev, DisparityIndex, DistanceSsd, Doji, DojiStar, Donchian,
DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput,
DoubleTopBottom, DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, Dx,
DynamicMomentumIndex, EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma, ElderImpulse,
ElderRay, ElderRayOutput, ElderSafeZone, ElderSafeZoneOutput, Ema, EmpiricalModeDecomposition,
Engulfing, EveningDojiStar, Evwma, EwmaVolatility, Expectancy, FallingThreeMethods, Fama,
FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence, FibConfluenceOutput,
FibExtension, FibExtensionOutput, FibFan, FibFanOutput, FibProjection, FibProjectionOutput,
FibRetracement, FibRetracementOutput, FibTimeZones, FibTimeZonesOutput, FibonacciPivots,
FibonacciPivotsOutput, FisherRsi, FisherTransform, FlagPennant, Footprint, FootprintOutput,
ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate,
FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite, Garch11,
GarmanKlassVolatility, Gartley, GatorOscillator, GatorOscillatorOutput, GeneralizedDema,
GeometricMa, GoldenPocket, GoldenPocketOutput, GrangerCausality, GravestoneDoji, Hammer,
HangingMan, Harami, HeadAndShoulders, HeikinAshi, HeikinAshiOutput, HiLoActivator,
@@ -93,47 +94,51 @@ pub use indicators::{
IdenticalThreeCrows, InNeck, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput,
InstantaneousTrendline, IntradayMomentumIndex, IntradayVolatilityProfile,
IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, Jma, JumpIndicator,
KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KasePermissionStochastic,
KasePermissionStochasticOutput, KellyCriterion, Keltner, KeltnerOutput, Kicking,
KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LadderBottom, LaguerreRsi,
LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel,
LinRegChannelOutput, LinRegIntercept, LinRegSlope, LinearRegression, LiquidationFeatures,
LiquidationFeaturesOutput, LogReturn, LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope,
MaEnvelopeOutput, MacdExt, MacdFix, MacdHistogram, MacdIndicator, MacdOutput, Mama, MamaOutput,
MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown,
McClellanOscillator, McClellanSummationIndex, McGinleyDynamic, MedianAbsoluteDeviation,
MedianMa, MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi, MinusDm, Mom,
MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nvi, OIPriceDivergence, OIWeighted,
Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu, OpeningRange, OpeningRangeOutput,
OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, OrderFlowImbalance,
OuHalfLife, OvernightGap, OvernightIntradayReturn, OvernightIntradayReturnOutput, PainIndex,
PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa,
PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo,
PointAndFigureBars, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Psar, Pvi,
Qqe, QqeOutput, Qstick, QuotedSpread, RSquared, RealizedSpread, RealizedVolatility,
RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB, RelativeStrengthOutput,
RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi, Roc, Rocp, Rocr, Rocr100,
RogersSatchellVolatility, RollMeasure, RollingCorrelation, RollingCovariance, RollingIqr,
RollingPercentileRank, RollingQuantile, RollingVwap, RoofingFilter, Rsi, Rsx, Rvi,
RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow,
SessionHighLowOutput, SessionRange, SessionRangeOutput, SessionVwap, Shark, SharpeRatio,
ShootingStar, ShortLine, SignedVolume, SineWave, SineWeightedMa, Skewness, Sma, Smi, Smma,
SortinoRatio, SpearmanCorrelation, SpinningTop, SpreadAr1Coefficient, SpreadBollingerBands,
SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError, StandardErrorBands,
StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop,
StickSandwich, StochRsi, Stochastic, StochasticCci, StochasticOutput, SuperSmoother,
SuperTrend, SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown,
TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential,
TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeDrives, ThreeInside,
ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex,
Tii, TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KaseDevStop, KaseDevStopOutput,
KasePermissionStochastic, KasePermissionStochasticOutput, KellyCriterion, Keltner,
KeltnerOutput, Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda,
LadderBottom, LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle,
LinRegChannel, LinRegChannelOutput, LinRegIntercept, LinRegSlope, LinearRegression,
LiquidationFeatures, LiquidationFeaturesOutput, LogReturn, LongLeggedDoji, LongLine,
LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, MacdHistogram, MacdIndicator,
MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex, MatHold,
MatchingLow, MaxDrawdown, McClellanOscillator, McClellanSummationIndex, McGinleyDynamic,
MedianAbsoluteDeviation, MedianChannel, MedianChannelOutput, MedianMa, MedianPrice, Mfi,
Microprice, MidPoint, MidPrice, MinusDi, MinusDm, ModifiedMaStop, ModifiedMaStopOutput, Mom,
MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nrtr, NrtrOutput, Nvi,
OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu,
OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1,
OrderBookImbalanceTopN, OrderFlowImbalance, OuHalfLife, OvernightGap, OvernightIntradayReturn,
OvernightIntradayReturnOutput, PainIndex, PairSpreadZScore, PairwiseBeta, ParkinsonVolatility,
PearsonCorrelation, PercentAboveMa, PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud,
PlusDi, PlusDm, Pmo, PointAndFigureBars, PolarizedFractalEfficiency, Ppo, PpoHistogram,
ProfitFactor, ProjectionBands, ProjectionBandsOutput, ProjectionOscillator, Psar, Pvi, Qqe,
QqeOutput, Qstick, QuartileBands, QuartileBandsOutput, QuotedSpread, RSquared, RealizedSpread,
RealizedVolatility, RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB,
RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi,
Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollMeasure, RollingCorrelation,
RollingCovariance, RollingIqr, RollingPercentileRank, RollingQuantile, RollingVwap,
RoofingFilter, Rsi, Rsx, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore,
SeparatingLines, SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput,
SessionVwap, Shark, SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave,
SineWeightedMa, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop,
SpreadAr1Coefficient, SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst,
StalledPattern, StandardError, StandardErrorBands, StandardErrorBandsOutput, StarcBands,
StarcBandsOutput, Stc, StdDev, StepTrailingStop, StickSandwich, StochRsi, Stochastic,
StochasticCci, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput,
TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential,
TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei,
TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema,
TermStructureBasis, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside,
ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, TimeBasedStop,
TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
TradeImbalance, TrendLabel, TrendStrengthIndex, TreynorRatio, Triangle, Trima, Trin,
TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput,
TtmTrend, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator,
UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea,
ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya,
VoltyStop, VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend,
VolatilityCone, VolatilityConeOutput, VolatilityOfVolatility, VolatilityRatio, VoltyStop,
VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend,
VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
VwapStdDevBandsOutput, Vwma, Vzo, WavePm, WaveTrend, WaveTrendOutput, Wedge, WeightedClose,
WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots,
+1 -1
View File
@@ -8,7 +8,7 @@ That includes:
[Python](https://docs.wickra.org/Quickstart-Python),
[Node](https://docs.wickra.org/Quickstart-Node), and
[WASM](https://docs.wickra.org/Quickstart-WASM).
- A per-indicator deep dive for every one of the **423 indicators** across
- A per-indicator deep dive for every one of the **440 indicators** across
the sixteen families (Moving Averages, Momentum Oscillators, Trend &
Directional, Price Oscillators, Volatility & Bands, Bands & Channels,
Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots &
+7 -7
View File
@@ -17,7 +17,7 @@
},
"../../bindings/node": {
"name": "wickra",
"version": "0.5.8",
"version": "0.6.2",
"license": "MIT OR Apache-2.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -26,12 +26,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.5.8",
"wickra-darwin-x64": "0.5.8",
"wickra-linux-arm64-gnu": "0.5.8",
"wickra-linux-x64-gnu": "0.5.8",
"wickra-win32-arm64-msvc": "0.5.8",
"wickra-win32-x64-msvc": "0.5.8"
"wickra-darwin-arm64": "0.6.2",
"wickra-darwin-x64": "0.6.2",
"wickra-linux-arm64-gnu": "0.6.2",
"wickra-linux-x64-gnu": "0.6.2",
"wickra-win32-arm64-msvc": "0.6.2",
"wickra-win32-x64-msvc": "0.6.2"
}
},
"node_modules/wickra": {
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wickra-examples"
version = "0.0.0"
version.workspace = true
publish = false
description = "Runnable Rust examples for the Wickra technical-analysis library."
authors.workspace = true
+26 -1
View File
@@ -14,7 +14,7 @@
//! `Ema(20)`. This target now covers every scalar indicator in the catalogue.
use libfuzzer_sys::fuzz_target;
use wickra_core::{AdaptiveCycle, AdaptiveLaguerreFilter, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BollingerBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DerivativeOscillator, DetrendedStdDev, DisparityIndex, DoubleBollinger, Dpo, DrawdownDuration, DynamicMomentumIndex, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, Expectancy, Fama, FisherRsi, FisherTransform, Frama, GainLossRatio, GeneralizedDema, GeometricMa, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, JumpIndicator, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, MaEnvelope, MaType, MacdExt, MacdFix, MacdHistogram, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Qqe, RSquared, RealizedVolatility, RecoveryFactor, RegimeLabel, RenkoTrailingStop, Rmi, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, Rsx, RviVolatility, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, TrendStrengthIndex, Trima, Trix, Tsf, TsfOscillator, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, WavePm, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3};
use wickra_core::{AdaptiveCycle, AdaptiveLaguerreFilter, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BipowerVariation, BollingerBands, BomarBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DerivativeOscillator, DetrendedStdDev, DisparityIndex, DoubleBollinger, Dpo, DrawdownDuration, DynamicMomentumIndex, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, EwmaVolatility, Expectancy, Fama, FisherRsi, FisherTransform, Frama, GainLossRatio, Garch11, GeneralizedDema, GeometricMa, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, JumpIndicator, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, MaEnvelope, MaType, MacdExt, MacdFix, MacdHistogram, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Qqe, QuartileBands, RSquared, RealizedVolatility, RecoveryFactor, RegimeLabel, RenkoTrailingStop, Rmi, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, Rsx, RviVolatility, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, TrendStrengthIndex, Trima, Trix, Tsf, TsfOscillator, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VolatilityOfVolatility, WavePm, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3};
/// Drive a single streaming + batch run through one scalar indicator. Marked
/// `#[inline(never)]` so a panic backtrace pin-points the specific indicator.
@@ -115,6 +115,10 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| HurstExponent::new(16, 4).unwrap(), &data);
drive(|| LogReturn::new(1).unwrap(), &data);
drive(|| RealizedVolatility::new(20).unwrap(), &data);
drive(|| EwmaVolatility::new(0.94).unwrap(), &data);
drive(|| Garch11::new(0.000_002, 0.1, 0.88).unwrap(), &data);
drive(|| BipowerVariation::new(20).unwrap(), &data);
drive(|| VolatilityOfVolatility::new(20, 20).unwrap(), &data);
drive(|| RollingQuantile::new(20, 0.5).unwrap(), &data);
drive(|| RollingIqr::new(14).unwrap(), &data);
drive(|| RollingPercentileRank::new(14).unwrap(), &data);
@@ -268,6 +272,27 @@ fuzz_target!(|data: Vec<f64>| {
}
// --- Family 05: scalar-input band/channel indicators (multi-output) ---
{
let mut medianchannel = MedianChannel::new(5, 2.0).unwrap();
for &x in &data {
let _ = medianchannel.update(x);
}
let _ = MedianChannel::new(5, 2.0).unwrap().batch(&data);
}
{
let mut bomarbands = BomarBands::new(4, 0.85).unwrap();
for &x in &data {
let _ = bomarbands.update(x);
}
let _ = BomarBands::new(4, 0.85).unwrap().batch(&data);
}
{
let mut quartilebands = QuartileBands::new(4).unwrap();
for &x in &data {
let _ = quartilebands.update(x);
}
let _ = QuartileBands::new(4).unwrap().batch(&data);
}
{
let mut env = MaEnvelope::new(20, 0.025).unwrap();
for &x in &data {
+11 -1
View File
@@ -22,7 +22,7 @@
//! WeightedClose.
use libfuzzer_sys::fuzz_target;
use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, ElderRay, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Psar, Pvi, Qstick, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, StochasticCci, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeOfDayReturnProfile, TpoProfile, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrRatchet, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, ElderRay, ElderSafeZone, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KaseDevStop, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, ModifiedMaStop, MorningDojiStar, MorningEveningStar, Natr, Nrtr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, ProjectionBands, ProjectionOscillator, Psar, Pvi, Qstick, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, StochasticCci, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeBasedStop, TimeOfDayReturnProfile, TpoProfile, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VolatilityCone, VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
/// Convert a flat `f64` stream into a `Vec<Candle>` by chunking it into
/// `[open, high, low, close, volume]` groups. Tuples that fail OHLCV
@@ -62,6 +62,7 @@ fuzz_target!(|data: Vec<f64>| {
}
// --- Volatility & ATR family ---
drive(|| VolatilityRatio::new(14).unwrap(), &candles);
drive(|| Atr::new(14).unwrap(), &candles);
drive(|| Natr::new(14).unwrap(), &candles);
drive(TrueRange::new, &candles);
@@ -72,10 +73,17 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| YangZhangVolatility::new(20, 252).unwrap(), &candles);
// --- Bands & Channels ---
drive(|| ProjectionOscillator::new(14).unwrap(), &candles);
drive(|| ProjectionBands::new(3).unwrap(), &candles);
drive(|| Keltner::new(20, 10, 2.0).unwrap(), &candles);
drive(|| Donchian::new(20).unwrap(), &candles);
// --- Trailing Stops ---
drive(|| ModifiedMaStop::new(14).unwrap(), &candles);
drive(|| TimeBasedStop::new(5).unwrap(), &candles);
drive(|| Nrtr::new(2.0).unwrap(), &candles);
drive(|| AtrRatchet::new(14, 4.0, 0.1).unwrap(), &candles);
drive(|| ElderSafeZone::new(14, 2.0).unwrap(), &candles);
drive(|| Psar::new(0.02, 0.02, 0.20).unwrap(), &candles);
drive(SarExt::classic, &candles);
drive(|| SuperTrend::new(14, 3.0).unwrap(), &candles);
@@ -85,6 +93,7 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| HiLoActivator::new(3).unwrap(), &candles);
drive(|| VoltyStop::new(14, 2.0).unwrap(), &candles);
drive(|| YoyoExit::new(14, 2.0).unwrap(), &candles);
drive(|| KaseDevStop::new(30, 1.0).unwrap(), &candles);
// --- Trend & Directional ---
drive(|| KasePermissionStochastic::new(9, 3).unwrap(), &candles);
@@ -246,6 +255,7 @@ fuzz_target!(|data: Vec<f64>| {
}
// --- Family 05: candle-input band/channel indicators (multi-output) ---
drive(|| VolatilityCone::new(20, 60).unwrap(), &candles);
{
let mut ab = AccelerationBands::new(20, 0.001).unwrap();
for c in &candles {